Make task schema fields explicit with null sentinels

This commit is contained in:
lesetong
2026-08-11 13:36:09 +08:00
parent 28d7e66d8a
commit 8af1c53c83
8 changed files with 270 additions and 75 deletions
+1 -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 {