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