Make task schema fields explicit with null sentinels
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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"), {
|
||||
|
||||
Reference in New Issue
Block a user