diff --git a/README.md b/README.md index 12a54b1..e50c196 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/prompt.ts b/src/prompt.ts index ac31f77..63b1daf 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -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.", ]; diff --git a/src/state.ts b/src/state.ts index d4f0d39..5bc46cf 100644 --- a/src/state.ts +++ b/src/state.ts @@ -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}`); } diff --git a/src/tools.ts b/src/tools.ts index 3e83a78..36d4de4 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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; + 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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value as Static; + } + const input = value as Record; + return { + ...input, + tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskDraft) : input.tasks, + force: input.force === undefined ? false : input.force, + } as Static; +} + +function prepareTaskPatch(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const input = value as Record; + 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 { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return value as Static; + } + const input = value as Record; + return { + ...input, + tasks: Array.isArray(input.tasks) ? input.tasks.map(prepareTaskPatch) : input.tasks, + } as Static; +} + 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); diff --git a/src/types.ts b/src/types.ts index 3609818..567060d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/tests/prompt.test.ts b/tests/prompt.test.ts index 866b9f7..f455908 100644 --- a/tests/prompt.test.ts +++ b/tests/prompt.test.ts @@ -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); diff --git a/tests/state.test.ts b/tests/state.test.ts index 10d797e..a769a2a 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -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); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 23fc61f..008894a 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -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"), {