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
+12 -5
View File
@@ -53,8 +53,8 @@ After automatic or manual context compaction, the extension sends one short hidd
| Tool | Parameters | Behavior |
| --- | --- | --- |
| `set_tasks` | `{ mode, tasks, force? }` | `append` extends the tree; `replace` atomically installs a new tree or clears it with `[]`. Replacing unfinished work requires `force: true`. |
| `update_task` | `{ tasks: [...] }` | Atomically patches multiple tasks. `parentId: null` moves to the root; `position` is a zero-based sibling index. |
| `set_tasks` | `{ mode, tasks, force }` | `append` extends the tree with `force: false`; `replace` atomically installs a new tree or clears it with `[]`. Replacing unfinished work requires `force: true`. |
| `update_task` | `{ tasks: [...] }` | Atomically patches multiple tasks. Use `null` for unchanged text/status, `parentId: -1` for unchanged or `0` to move to root, and `position: -1` to keep the current order. |
| `rm_task` | `{ ids: [...], reason }` | Removes only mistaken, duplicate, or canceled tasks. Removing a parent cascades through its descendants. |
| `list_task` | `{}` | Returns the full tree as compact `id/status/name` lines. |
| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent/child IDs and timing for tasks that have started. |
@@ -69,18 +69,25 @@ Example append call:
"name": "Implement authentication",
"description": "Add session validation and route protection.",
"status": "in-progress",
"parentId": null,
"children": [
{
"name": "Add auth middleware",
"description": "Validate the session before protected handlers run."
"description": "Validate the session before protected handlers run.",
"status": "pending",
"parentId": null,
"children": []
}
]
}
]
],
"force": false
}
```
Descriptions are optional, but useful scope, constraints, and done criteria improve recovery through `get_task`. IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`.
Task input keys are explicit so strict tool-schema bridges do not invent values for omitted properties. Use `description: null` when no detail is needed, `status: "pending"` for the default state, `parentId: null` for root tasks and nested children, and `children: []` for leaves. `prepareArguments` still expands legacy calls that omit these fields.
IDs are not reused by append/remove operations; `replace` deliberately rebuilds IDs from `1`. Replace forbids non-null `parentId`, so new hierarchy must use nested `children`. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all descendants are also `done`.
A task starts timing the first time it enters `in-progress`. Leaving that state records a stop and pauses the timer; entering it again resumes with a new active segment while preserving the cumulative duration. Repeated updates with the same status, as well as name, description, hierarchy, and position changes, do not reset timing. For started tasks, `get_task` includes the first `startedAt` timestamp, a `stoppedAt` timestamp while paused, and cumulative `durationMs`. Timestamps use ISO 8601.
+1 -1
View File
@@ -6,7 +6,7 @@ export const COMPACTION_REMINDER =
"Active tasks exist. Call list_task now; treat its result as authoritative.";
export const TASK_PROMPT_GUIDELINES = [
"For large, multi-stage work, call set_tasks before exploration as the only first-batch tool. Use append by default and replace only for unrelated finished or explicitly abandoned work. Start one task in-progress; skip smaller work.",
"For large, multi-stage work, call set_tasks before exploration as the only first-batch tool. Use append with force false by default; use replace with force true only for explicitly abandoned unfinished work. Start one task in-progress; skip smaller work.",
"Use update_task when tracked work starts, blocks, or finishes; reconcile the tree before the final response.",
];
+46 -24
View File
@@ -307,17 +307,23 @@ export class TaskState {
}
if (typeof draft.name !== "string" || !draft.name.trim()) errors.push(`${path}.name must not be empty`);
if (typeof draft.name === "string" && /[\r\n]/.test(draft.name)) errors.push(`${path}.name must be a single line`);
if (draft.description !== undefined && (typeof draft.description !== "string" || !draft.description.trim())) {
if (
draft.description !== undefined &&
draft.description !== null &&
(typeof draft.description !== "string" || !draft.description.trim())
) {
errors.push(`${path}.description must not be empty`);
}
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
if (draft.status !== undefined && draft.status !== null && !VALID_STATUSES.has(draft.status)) {
errors.push(`${path}.status is invalid`);
}
if (nested && draft.parentId !== undefined) errors.push(`${path}.parentId is not allowed inside children`);
if (nested && draft.parentId !== undefined && draft.parentId !== null) {
errors.push(`${path}.parentId is not allowed inside children`);
}
if (draft.children !== undefined && !Array.isArray(draft.children)) errors.push(`${path}.children must be an array`);
let parentId = implicitParentId;
if (!nested && draft.parentId !== undefined) {
if (!nested && draft.parentId !== undefined && draft.parentId !== null) {
if (!Number.isInteger(draft.parentId) || !existingIds.has(draft.parentId)) {
errors.push(`${path}.parentId ${draft.parentId} does not exist`);
} else {
@@ -338,7 +344,8 @@ export class TaskState {
...(status === "in-progress" ? { startedAt: now, activeStartedAt: now } : {}),
});
for (const [index, child] of (draft.children ?? []).entries()) {
const children = Array.isArray(draft.children) ? draft.children : [];
for (const [index, child] of children.entries()) {
append(child, id, `${path}.children[${index}]`, true);
}
};
@@ -354,7 +361,7 @@ export class TaskState {
set(mode: "append" | "replace", drafts: AddTaskDraft[], force?: boolean): Task[] {
if (mode === "append") {
if (force !== undefined) throw new TaskValidationError(["force is allowed only in replace mode"]);
if (force === true) throw new TaskValidationError(["force must be false in append mode"]);
return this.add(drafts);
}
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
@@ -365,7 +372,9 @@ export class TaskState {
errors.push("replace requires force when unfinished tasks exist");
}
drafts.forEach((draft, index) => {
if (draft?.parentId !== undefined) errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
if (draft?.parentId !== undefined && draft.parentId !== null) {
errors.push(`tasks[${index}].parentId is not allowed in replace mode`);
}
});
if (errors.length > 0) throw new TaskValidationError(errors);
@@ -394,36 +403,49 @@ export class TaskState {
continue;
}
const hasChange = ["name", "description", "status", "parentId", "position"].some((key) =>
Object.prototype.hasOwnProperty.call(patch, key),
);
if (!hasChange) errors.push(`${path} must include at least one field to update`);
if (patch.name !== undefined && (typeof patch.name !== "string" || !patch.name.trim())) {
const hasNameChange = patch.name !== undefined && patch.name !== null;
const hasDescriptionChange = patch.description !== undefined && patch.description !== null;
const hasStatusChange = patch.status !== undefined && patch.status !== null;
const hasParentChange = patch.parentId !== undefined && patch.parentId !== -1;
const hasPositionChange = patch.position !== undefined && patch.position !== null && patch.position !== -1;
if (!hasNameChange && !hasDescriptionChange && !hasStatusChange && !hasParentChange && !hasPositionChange) {
errors.push(`${path} must include at least one field to update`);
}
if (hasNameChange && (typeof patch.name !== "string" || !patch.name.trim())) {
errors.push(`${path}.name must not be empty`);
}
if (typeof patch.name === "string" && /[\r\n]/.test(patch.name)) errors.push(`${path}.name must be a single line`);
if (patch.description !== undefined && (typeof patch.description !== "string" || !patch.description.trim())) {
if (hasDescriptionChange && (typeof patch.description !== "string" || !patch.description.trim())) {
errors.push(`${path}.description must not be empty`);
}
if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) errors.push(`${path}.status is invalid`);
if (patch.parentId !== undefined && patch.parentId !== null) {
if (!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId)) {
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
}
if (hasStatusChange && !VALID_STATUSES.has(patch.status as TaskStatus)) errors.push(`${path}.status is invalid`);
if (
hasParentChange &&
patch.parentId !== null &&
patch.parentId !== 0 &&
(!Number.isInteger(patch.parentId) || !working.some((candidate) => candidate.id === patch.parentId))
) {
errors.push(`${path}.parentId ${patch.parentId} does not exist`);
}
if (patch.name !== undefined) task.name = patch.name.trim();
if (patch.description !== undefined) task.description = patch.description.trim();
if (patch.status !== undefined && patch.status !== task.status) {
if (patch.name !== undefined && patch.name !== null) task.name = patch.name.trim();
if (patch.description !== undefined && patch.description !== null) task.description = patch.description.trim();
if (patch.status !== undefined && patch.status !== null && patch.status !== task.status) {
if (task.status === "in-progress" && patch.status !== "in-progress") stopTimer(task, now);
if (task.status !== "in-progress" && patch.status === "in-progress") startTimer(task, now);
task.status = patch.status;
}
if (patch.parentId !== undefined && (patch.parentId === null || working.some((item) => item.id === patch.parentId))) {
task.parentId = patch.parentId;
if (
patch.parentId !== undefined &&
patch.parentId !== -1 &&
(patch.parentId === null ||
patch.parentId === 0 ||
working.some((item) => item.id === patch.parentId))
) {
task.parentId = patch.parentId === 0 ? null : patch.parentId;
moveToSiblingEnd(working, task.id);
}
if (patch.position !== undefined) {
if (patch.position !== undefined && patch.position !== null && patch.position !== -1) {
const positionError = insertAtSiblingPosition(working, task.id, patch.position);
if (positionError) errors.push(`${path}.position: ${positionError}`);
}
+88 -29
View File
@@ -1,7 +1,7 @@
import { StringEnum } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { Type, type Static } from "typebox";
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
import type { TaskState } from "./state.ts";
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
@@ -9,7 +9,7 @@ import { TaskValidationError } from "./types.ts";
import { renderToolResult } from "./ui.ts";
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
description: "Task status. Defaults to pending.",
description: "Task status.",
});
const SetTaskModeSchema = StringEnum(["append", "replace"] as const);
@@ -78,17 +78,14 @@ const AddTaskSchema = Type.Cyclic(
AddTask: Type.Object(
{
name: Type.String({ minLength: 1 }),
description: Type.Optional(
Type.String({
minLength: 1,
description: "Detailed scope, constraints, and done criteria. Include when useful.",
}),
),
status: Type.Optional(TaskStatusSchema),
parentId: Type.Optional(
Type.Integer({ minimum: 1, description: "Existing parent ID; append mode and top-level tasks only." }),
),
children: Type.Optional(Type.Array(Type.Ref("AddTask"))),
description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
description: "Detailed scope, constraints, and done criteria; use null when no detail is needed.",
}),
status: TaskStatusSchema,
parentId: Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
description: "Existing parent ID for a top-level append; use null for roots, nested children, and replace mode.",
}),
children: Type.Array(Type.Ref("AddTask")),
},
{ additionalProperties: false },
),
@@ -104,9 +101,9 @@ export const SetTaskParams = hoistDefsToRoot(
tasks: Type.Array(AddTaskSchema, {
description: "Tasks to append or install. May be empty only in replace mode.",
}),
force: Type.Optional(
Type.Boolean({ description: "Use only when the user explicitly abandons unfinished tasks." }),
),
force: Type.Boolean({
description: "Set true only when the user explicitly abandons unfinished tasks; otherwise use false.",
}),
},
{ additionalProperties: false },
),
@@ -116,17 +113,23 @@ export const SetTaskParams = hoistDefsToRoot(
const TaskPatchSchema = Type.Object(
{
id: Type.Integer({ minimum: 1 }),
name: Type.Optional(Type.String({ minLength: 1 })),
description: Type.Optional(Type.String({ minLength: 1 })),
status: Type.Optional(TaskStatusSchema),
parentId: Type.Optional(
Type.Union([Type.Integer({ minimum: 1 }), Type.Null()], {
description: "New parent ID, or null to move the task to the root.",
}),
),
position: Type.Optional(
Type.Integer({ minimum: 0, description: "Zero-based insertion index among the task's final siblings." }),
),
name: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
description: "New name, or null to leave it unchanged.",
}),
description: Type.Union([Type.String({ minLength: 1 }), Type.Null()], {
description: "New description, or null to leave it unchanged.",
}),
status: Type.Union([TaskStatusSchema, Type.Null()], {
description: "New status, or null to leave it unchanged.",
}),
parentId: Type.Integer({
minimum: -1,
description: "New parent ID; use -1 to leave it unchanged and 0 to move to root.",
}),
position: Type.Integer({
minimum: -1,
description: "Zero-based position among final siblings, or -1 to leave it unchanged.",
}),
},
{ additionalProperties: false },
);
@@ -155,6 +158,60 @@ export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalPropert
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
function prepareTaskDraft(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const input = value as Record<string, unknown>;
const children =
input.children === undefined
? []
: Array.isArray(input.children)
? input.children.map(prepareTaskDraft)
: input.children;
return {
...input,
description: input.description === undefined ? null : input.description,
status: input.status === undefined ? "pending" : input.status,
parentId: input.parentId === undefined ? null : input.parentId,
children,
};
}
function prepareSetTaskArguments(value: unknown): Static<typeof SetTaskParams> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value as Static<typeof SetTaskParams>;
}
const input = value as Record<string, unknown>;
return {
...input,
tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskDraft) : input.tasks,
force: input.force === undefined ? false : input.force,
} as Static<typeof SetTaskParams>;
}
function prepareTaskPatch(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
const input = value as Record<string, unknown>;
return {
...input,
name: input.name === undefined ? null : input.name,
description: input.description === undefined ? null : input.description,
status: input.status === undefined ? null : input.status,
parentId: input.parentId === undefined ? -1 : input.parentId === null ? 0 : input.parentId,
position: input.position === undefined || input.position === null ? -1 : input.position,
};
}
function prepareUpdateTaskArguments(value: unknown): Static<typeof UpdateTaskParams> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return value as Static<typeof UpdateTaskParams>;
}
const input = value as Record<string, unknown>;
return {
...input,
tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskPatch) : input.tasks,
} as Static<typeof UpdateTaskParams>;
}
interface SetTaskInput {
mode: "append" | "replace";
tasks: AddTaskDraft[];
@@ -223,9 +280,10 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
name: "set_tasks",
label: "Set Tasks",
description:
"Atomically append tasks or replace the task tree. Replace may clear with an empty array; force is required to discard unfinished tasks.",
"Atomically append tasks or replace the task tree. Pass force false unless unfinished work is explicitly discarded.",
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
parameters: SetTaskParams,
prepareArguments: prepareSetTaskArguments,
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
try {
const affected = state.set(params.mode, params.tasks, params.force);
@@ -250,8 +308,9 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
name: "update_task",
label: "Update Tasks",
description:
"Atomically patch one or more tasks. Fields are optional except id; parentId null moves to root and position is a zero-based sibling index.",
"Atomically patch tasks. Use null for unchanged text/status, parentId -1 for unchanged or 0 for root, and position -1 for unchanged.",
parameters: UpdateTaskParams,
prepareArguments: prepareUpdateTaskArguments,
async execute(_toolCallId, params: UpdateTaskInput, _signal, _onUpdate, ctx) {
try {
const affected = state.update(params.tasks);
+7 -7
View File
@@ -28,19 +28,19 @@ export interface TaskSnapshot {
export interface AddTaskDraft {
name: string;
description?: string;
status?: TaskStatus;
parentId?: number;
description?: string | null;
status?: TaskStatus | null;
parentId?: number | null;
children?: AddTaskDraft[];
}
export interface TaskPatch {
id: number;
name?: string;
description?: string;
status?: TaskStatus;
name?: string | null;
description?: string | null;
status?: TaskStatus | null;
parentId?: number | null;
position?: number;
position?: number | null;
}
export interface TaskStats {
+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"), {