feat: strengthen task workflow and compaction recovery

This commit is contained in:
lesetong
2026-07-23 01:23:10 +08:00
parent fe05b278a4
commit 7d876735b2
13 changed files with 380 additions and 81 deletions
+12 -8
View File
@@ -6,11 +6,12 @@ Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic
## Features
- Five focused tools: `add_task`, `update_task`, `rm_task`, `list_task`, and `get_task`
- Parent/child task trees with generated, stable integer IDs
- Five focused tools: `set_tasks`, `update_task`, `rm_task`, `list_task`, and `get_task`
- Parent/child task trees with generated integer IDs and atomic append/replace modes
- Atomic batch mutations: every requested change succeeds or none of it is applied
- Four states: `pending`, `in-progress`, `blocked`, and `done`
- Session-branch persistence across reload, resume, fork, and tree navigation
- Session-branch persistence across reload, resume, fork, tree navigation, and context compaction
- A concise first-action workflow prompt plus a one-shot post-compaction recovery reminder
- Compact Pi widget with hierarchy, status colors, and an adaptive progress bar
- Optional Herdr metadata showing the current task in the agent sidebar
@@ -45,20 +46,23 @@ pi remove npm:pi-todo-herdr
## Usage
For complex work, Pi can create and maintain the task tree automatically. The extension adds one short prompt guideline encouraging task tracking for multi-step work without forcing it for trivial requests.
For large, multi-stage work, the extension instructs Pi to call `set_tasks` before exploration as the only tool in the first batch. Smaller work remains untracked. Pi then updates task states at each real transition instead of reconstructing progress at the end.
After automatic or manual context compaction, the extension sends one short hidden reminder only when unfinished tasks exist. Pi calls `list_task` to recover the authoritative current IDs and statuses; the full tree is not injected on every model call.
| Tool | Parameters | Behavior |
| --- | --- | --- |
| `add_task` | `{ tasks: [...] }` | Atomically adds multiple tasks. Nested `children` create descendants. |
| `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. |
| `rm_task` | `{ ids: [...] }` | Removes multiple tasks. Removing a parent cascades through its descendants. |
| `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 and child IDs. |
Example add call:
Example append call:
```json
{
"mode": "append",
"tasks": [
{
"name": "Implement authentication",
@@ -75,7 +79,7 @@ Example add call:
}
```
Task IDs are generated by the extension and never reused within a session branch. Multiple tasks may be `in-progress`. A parent can be marked `done` only when all of its descendants are also `done`.
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`.
## Pi Widget
+16
View File
@@ -1,5 +1,10 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { clearCurrentTask, reportCurrentTask } from "./src/herdr.ts";
import {
COMPACTION_REMINDER,
COMPACTION_REMINDER_TYPE,
hasUnfinishedTasks,
} from "./src/prompt.ts";
import { TaskState } from "./src/state.ts";
import { registerTaskTools } from "./src/tools.ts";
import { clearTaskWidget, updateTaskWidget } from "./src/widget.ts";
@@ -23,6 +28,17 @@ export default function piTodoHerdr(pi: ExtensionAPI): void {
pi.on("session_shutdown", async (event) => {
if (event.reason === "quit") await clearCurrentTask();
});
pi.on("session_compact", () => {
if (!hasUnfinishedTasks(state.list())) return;
pi.sendMessage(
{
customType: COMPACTION_REMINDER_TYPE,
content: COMPACTION_REMINDER,
display: false,
},
{ deliverAs: "steer" },
);
});
registerTaskTools(pi, state, refresh);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pi-todo-herdr",
"version": "0.1.1",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-todo-herdr",
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"devDependencies": {
"@earendil-works/pi-ai": "^0.81.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pi-todo-herdr",
"version": "0.1.1",
"version": "0.2.0",
"description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.",
"type": "module",
"main": "./index.ts",
+22
View File
@@ -0,0 +1,22 @@
import type { Task } from "./types.ts";
import { orderedTreeRows } from "./ui.ts";
export const COMPACTION_REMINDER_TYPE = "pi-todo-herdr-compaction";
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.",
"Use update_task when tracked work starts, blocks, or finishes; reconcile the tree before the final response.",
];
export function formatCompactTaskTree(tasks: Task[]): string {
if (tasks.length === 0) return "No tasks.";
return orderedTreeRows(tasks)
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
.join("\n");
}
export function hasUnfinishedTasks(tasks: Task[]): boolean {
return tasks.some((task) => task.status !== "done");
}
+30 -5
View File
@@ -10,13 +10,13 @@ import type {
} from "./types.ts";
import { TaskValidationError } from "./types.ts";
const MUTATION_TOOLS = new Set(["add_task", "update_task", "rm_task"]);
const MUTATION_TOOLS = new Set(["add_task", "set_tasks", "update_task", "rm_task"]);
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
interface TaskRecord {
id: number;
name: string;
description: string;
description?: string;
status: TaskStatus;
parentId: number | null;
}
@@ -63,7 +63,9 @@ function validateGraph(records: TaskRecord[]): string[] {
ids.add(task.id);
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
if (/[\r\n]/.test(task.name)) errors.push(`task ${task.id}: name must be a single line`);
if (!task.description.trim()) errors.push(`task ${task.id}: description must not be empty`);
if (task.description !== undefined && !task.description.trim()) {
errors.push(`task ${task.id}: description must not be empty`);
}
if (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
if (task.parentId !== null && !byId.has(task.parentId)) {
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
@@ -191,7 +193,7 @@ 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 (typeof draft.description !== "string" || !draft.description.trim()) {
if (draft.description !== undefined && (typeof draft.description !== "string" || !draft.description.trim())) {
errors.push(`${path}.description must not be empty`);
}
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
@@ -214,7 +216,7 @@ export class TaskState {
working.push({
id,
name: typeof draft.name === "string" ? draft.name.trim() : "",
description: typeof draft.description === "string" ? draft.description.trim() : "",
...(typeof draft.description === "string" ? { description: draft.description.trim() } : {}),
status: draft.status ?? "pending",
parentId,
});
@@ -233,6 +235,29 @@ export class TaskState {
return this.get(addedIds);
}
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"]);
return this.add(drafts);
}
if (mode !== "replace") throw new TaskValidationError(["mode must be append or replace"]);
if (!Array.isArray(drafts)) throw new TaskValidationError(["tasks must be an array"]);
const errors: string[] = [];
if (this.records.some((task) => task.status !== "done") && force !== true) {
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 (errors.length > 0) throw new TaskValidationError(errors);
const replacement = new TaskState();
const affected = drafts.length === 0 ? [] : replacement.add(drafts);
this.restore(replacement.snapshot());
return affected;
}
update(patches: TaskPatch[]): Task[] {
const errors: string[] = [];
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
+70 -40
View File
@@ -2,24 +2,33 @@ 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 { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
import type { TaskState } from "./state.ts";
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
import { TaskValidationError } from "./types.ts";
import { orderedTreeRows, renderToolResult } from "./ui.ts";
import { renderToolResult } from "./ui.ts";
const TaskStatusSchema = StringEnum(["pending", "in-progress", "blocked", "done"] as const, {
description: "Task status. Defaults to pending when omitted during add.",
description: "Task status. Defaults to pending.",
});
const SetTaskModeSchema = StringEnum(["append", "replace"] as const);
const RemovalReasonSchema = StringEnum(["mistaken", "duplicate", "canceled"] as const);
const AddTaskSchema = Type.Cyclic(
{
AddTask: Type.Object(
{
name: Type.String({ minLength: 1 }),
description: 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. Allowed only on top-level tasks." }),
Type.Integer({ minimum: 1, description: "Existing parent ID; append mode and top-level tasks only." }),
),
children: Type.Optional(Type.Array(Type.Ref("AddTask"))),
},
@@ -29,9 +38,15 @@ const AddTaskSchema = Type.Cyclic(
"AddTask",
);
export const AddTaskParams = Type.Object(
export const SetTaskParams = Type.Object(
{
tasks: Type.Array(AddTaskSchema, { minItems: 1, description: "Tasks to add in one atomic batch." }),
mode: SetTaskModeSchema,
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." }),
),
},
{ additionalProperties: false },
);
@@ -61,20 +76,27 @@ export const UpdateTaskParams = Type.Object(
{ additionalProperties: false },
);
export const IdsParams = Type.Object(
const TaskIdsSchema = Type.Array(Type.Integer({ minimum: 1 }), {
minItems: 1,
description: "Task IDs to process. Duplicate IDs are deduplicated.",
});
export const RemoveTaskParams = Type.Object(
{
ids: Type.Array(Type.Integer({ minimum: 1 }), {
minItems: 1,
description: "Task IDs to process in one call. Duplicate IDs are deduplicated.",
}),
ids: TaskIdsSchema,
reason: RemovalReasonSchema,
},
{ additionalProperties: false },
);
export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalProperties: false });
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
interface AddTaskInput {
interface SetTaskInput {
mode: "append" | "replace";
tasks: AddTaskDraft[];
force?: boolean;
}
interface UpdateTaskInput {
@@ -85,19 +107,16 @@ interface IdsInput {
ids: number[];
}
interface RemoveTaskInput extends IdsInput {
reason: "mistaken" | "duplicate" | "canceled";
}
type ChangeHandler = (ctx: ExtensionContext) => void;
function taskIds(tasks: Task[]): string {
return tasks.map((task) => task.id).join(", ");
}
function compactTree(tasks: Task[]): string {
if (tasks.length === 0) return "No tasks.";
return orderedTreeRows(tasks)
.map(({ task, prefix }) => `${prefix}${task.id} [${task.status}] ${task.name}`)
.join("\n");
}
function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails {
return { action, snapshot: state.snapshot(), affected };
}
@@ -107,6 +126,13 @@ function mutationText(verb: string, affected: Task[], state: TaskState): string
return `${verb} ${affected.length} task${affected.length === 1 ? "" : "s"}: ${taskIds(affected)}. ${stats.done}/${stats.total} done.`;
}
function setTaskText(mode: SetTaskInput["mode"], affected: Task[], state: TaskState): string {
if (mode === "append") return mutationText("Added", affected, state);
if (affected.length === 0) return "Cleared task tree. Task IDs reset to 1.";
const stats = state.stats();
return `Replaced task tree with ${affected.length} task${affected.length === 1 ? "" : "s"}. ${stats.done}/${stats.total} done. Task IDs restart at 1.`;
}
function throwToolError(label: string, error: unknown): never {
if (error instanceof TaskValidationError) {
const count = error.errors.length;
@@ -132,28 +158,26 @@ function notifyChange(onChange: ChangeHandler, ctx: ExtensionContext): void {
export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void {
pi.registerTool({
name: "add_task",
label: "Add Tasks",
name: "set_tasks",
label: "Set Tasks",
description:
"Atomically add one or more tasks. Use nested children for new descendants and parentId on top-level items for an existing parent.",
promptGuidelines: [
"Use add_task for complex multi-step work; use update_task immediately when tasks start, block, resume, or finish; skip task tracking for simple one-step work.",
],
parameters: AddTaskParams,
async execute(_toolCallId, params: AddTaskInput, _signal, _onUpdate, ctx) {
"Atomically append tasks or replace the task tree. Replace may clear with an empty array; force is required to discard unfinished tasks.",
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
parameters: SetTaskParams,
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
try {
const affected = state.add(params.tasks);
const affected = state.set(params.mode, params.tasks, params.force);
notifyChange(onChange, ctx);
return {
content: [{ type: "text" as const, text: mutationText("Added", affected, state) }],
details: details("add", state, affected),
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
details: details(params.mode, state, affected),
};
} catch (error) {
throwToolError("Add", error);
throwToolError("Set", error);
}
},
renderCall(args: AddTaskInput, theme: Theme) {
return renderCall("Add Tasks", args.tasks?.length, theme);
renderCall(args: SetTaskInput, theme: Theme) {
return renderCall(`Set Tasks · ${args.mode}`, args.tasks?.length, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
@@ -189,22 +213,28 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
pi.registerTool({
name: "rm_task",
label: "Remove Tasks",
description: "Atomically remove multiple task IDs. Removing a parent also removes its entire descendant subtree.",
parameters: IdsParams,
async execute(_toolCallId, params: IdsInput, _signal, _onUpdate, ctx) {
description:
"Remove tasks only when mistaken, duplicate, or canceled. Removing a parent also removes its descendant subtree.",
parameters: RemoveTaskParams,
async execute(_toolCallId, params: RemoveTaskInput, _signal, _onUpdate, ctx) {
try {
const affected = state.remove(params.ids);
notifyChange(onChange, ctx);
return {
content: [{ type: "text" as const, text: mutationText("Removed", affected, state) }],
content: [
{
type: "text" as const,
text: `${mutationText("Removed", affected, state)} Reason: ${params.reason}.`,
},
],
details: details("remove", state, affected),
};
} catch (error) {
throwToolError("Remove", error);
}
},
renderCall(args: IdsInput, theme: Theme) {
return renderCall("Remove Tasks", args.ids?.length, theme);
renderCall(args: RemoveTaskInput, theme: Theme) {
return renderCall(`Remove Tasks · ${args.reason}`, args.ids?.length, theme);
},
renderResult(result, { expanded }, theme) {
return renderToolResult(result, expanded, theme);
@@ -219,7 +249,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
async execute() {
const affected = state.list();
return {
content: [{ type: "text" as const, text: compactTree(affected) }],
content: [{ type: "text" as const, text: formatCompactTaskTree(affected) }],
details: details("list", state, affected),
};
},
+3 -3
View File
@@ -3,7 +3,7 @@ export type TaskStatus = "pending" | "in-progress" | "blocked" | "done";
export interface Task {
id: number;
name: string;
description: string;
description?: string;
status: TaskStatus;
parentId: number | null;
childrenIds: number[];
@@ -16,7 +16,7 @@ export interface TaskSnapshot {
export interface AddTaskDraft {
name: string;
description: string;
description?: string;
status?: TaskStatus;
parentId?: number;
children?: AddTaskDraft[];
@@ -40,7 +40,7 @@ export interface TaskStats {
}
export interface TaskToolDetails {
action: "add" | "update" | "remove" | "list" | "get";
action: "add" | "append" | "replace" | "update" | "remove" | "list" | "get";
snapshot: TaskSnapshot;
affected: Task[];
errors?: string[];
+11 -6
View File
@@ -103,7 +103,10 @@ function summaryFor(details: TaskToolDetails): string {
const progress = `${stats.done}/${stats.total} done`;
switch (details.action) {
case "add":
case "append":
return `${details.affected.length} added · ${progress}`;
case "replace":
return stats.total === 0 ? "task tree cleared" : `${stats.total} installed · ${progress}`;
case "update":
return `${details.affected.length} updated · ${progress}`;
case "remove":
@@ -137,12 +140,14 @@ function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[]
const parent = task.parentId === null ? "root" : `#${task.parentId}`;
const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", ");
lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width));
const prefix = " ";
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
for (const sourceLine of task.description.split("\n")) {
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
if (wrapped.length === 0) lines.push(prefix);
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
if (task.description) {
const prefix = " ";
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
for (const sourceLine of task.description.split("\n")) {
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
if (wrapped.length === 0) lines.push(prefix);
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
}
}
}
return lines;
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import test from "node:test";
import piTodoHerdr from "../index.ts";
import {
COMPACTION_REMINDER,
COMPACTION_REMINDER_TYPE,
formatCompactTaskTree,
hasUnfinishedTasks,
TASK_PROMPT_GUIDELINES,
} from "../src/prompt.ts";
import { TaskState } from "../src/state.ts";
test("keeps task guidance short while requiring planning before large work", () => {
assert.equal(TASK_PROMPT_GUIDELINES.length, 2);
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, /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);
});
test("uses a compact one-shot recovery reminder without embedding task data", () => {
assert.equal(
COMPACTION_REMINDER,
"Active tasks exist. Call list_task now; treat its result as authoritative.",
);
assert.equal(COMPACTION_REMINDER.split(/\s+/).length, 11);
assert.doesNotMatch(COMPACTION_REMINDER, /pending|in-progress|blocked|done/);
});
test("reminds after compaction only while unfinished tasks exist", () => {
const state = new TaskState();
assert.equal(hasUnfinishedTasks(state.list()), false);
state.add([{ name: "Pending" }]);
assert.equal(hasUnfinishedTasks(state.list()), true);
state.update([{ id: 1, status: "done" }]);
assert.equal(hasUnfinishedTasks(state.list()), false);
});
test("formats the compact authoritative tree without descriptions", () => {
const state = new TaskState();
state.add([
{
name: "Parent",
description: "Detailed context that should not appear.",
status: "in-progress",
children: [{ name: "Child" }],
},
]);
assert.equal(
formatCompactTaskTree(state.list()),
"1 [in-progress] Parent\n└─ 2 [pending] Child",
);
});
test("session_compact sends one hidden reminder only for unfinished tasks", async () => {
const handlers = new Map<string, (event: any, ctx: any) => unknown>();
const tools = new Map<string, any>();
const sent: Array<{ message: any; options: any }> = [];
const pi = {
on(name: string, handler: (event: any, ctx: any) => unknown) {
handlers.set(name, handler);
},
registerTool(tool: any) {
tools.set(tool.name, tool);
},
registerCommand() {},
sendMessage(message: any, options: any) {
sent.push({ message, options });
},
};
piTodoHerdr(pi as never);
const ctx = { ui: { setWidget() {} } };
await tools.get("set_tasks").execute(
"set",
{ mode: "append", tasks: [{ name: "Continue work", status: "in-progress" }] },
undefined,
undefined,
ctx,
);
handlers.get("session_compact")?.({}, ctx);
assert.deepEqual(sent, [
{
message: {
customType: COMPACTION_REMINDER_TYPE,
content: COMPACTION_REMINDER,
display: false,
},
options: { deliverAs: "steer" },
},
]);
await tools.get("update_task").execute(
"update",
{ tasks: [{ id: 1, status: "done" }] },
undefined,
undefined,
ctx,
);
handlers.get("session_compact")?.({}, ctx);
assert.equal(sent.length, 1);
});
+43 -3
View File
@@ -170,9 +170,49 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
});
test("rejects multiline names while allowing multiline descriptions", () => {
test("supports optional descriptions and rejects invalid provided values", () => {
const state = new TaskState();
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
state.add([{ name: "Valid", description: "Line one\nLine two" }]);
assert.equal(state.list()[0]?.description, "Line one\nLine two");
assert.throws(() => state.add([{ name: "Blank description", description: " " }]), /must not be empty/);
state.add([
{ name: "No description" },
{ name: "Detailed", description: "Line one\nLine two" },
]);
assert.equal(state.list()[0]?.description, undefined);
assert.equal(state.list()[1]?.description, "Line one\nLine two");
});
test("set append rejects force and still requires at least one task", () => {
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 });
});
test("set replace is atomic, guarded, and resets IDs", () => {
const state = new TaskState();
state.add([{ name: "Old", status: "in-progress" }]);
const before = state.snapshot();
assert.throws(() => state.set("replace", [{ name: "New" }]), /requires force/);
assert.throws(
() => state.set("replace", [{ name: "Invalid parent", parentId: 1 }], true),
/parentId is not allowed in replace mode/,
);
assert.deepEqual(state.snapshot(), before);
const replaced = state.set("replace", [{ name: "New", children: [{ name: "Child" }] }], 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);
state.set("replace", [], true);
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
});
test("set replace does not require force after all existing tasks are done", () => {
const state = new TaskState();
state.add([{ name: "Done", status: "done" }]);
state.set("replace", [{ name: "Fresh" }]);
assert.deepEqual(state.list().map((task) => [task.id, task.name]), [[1, "Fresh"]]);
});
+53 -13
View File
@@ -21,32 +21,59 @@ async function execute(tool: any, params: unknown) {
return tool.execute("call", params, undefined, undefined, {});
}
test("registers the five requested tools with minimal top-level parameters", () => {
test("registers five focused tools without the old add_task alias", () => {
const { tools } = registeredTools();
assert.deepEqual([...tools.keys()], ["add_task", "update_task", "rm_task", "list_task", "get_task"]);
assert.deepEqual(Object.keys(tools.get("add_task").parameters.properties), ["tasks"]);
assert.deepEqual([...tools.keys()], ["set_tasks", "update_task", "rm_task", "list_task", "get_task"]);
assert.deepEqual(Object.keys(tools.get("set_tasks").parameters.properties), ["mode", "tasks", "force"]);
assert.deepEqual(Object.keys(tools.get("update_task").parameters.properties), ["tasks"]);
assert.deepEqual(Object.keys(tools.get("rm_task").parameters.properties), ["ids"]);
assert.deepEqual(Object.keys(tools.get("rm_task").parameters.properties), ["ids", "reason"]);
assert.deepEqual(Object.keys(tools.get("list_task").parameters.properties), []);
assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]);
});
test("mutations return compact content and complete persistence snapshots", async () => {
test("set_tasks appends optional-description tasks and persists a complete snapshot", async () => {
const harness = registeredTools();
const result = await execute(harness.tools.get("add_task"), {
tasks: [{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }],
const result = await execute(harness.tools.get("set_tasks"), {
mode: "append",
tasks: [{ name: "Parent", children: [{ name: "Child", description: "Useful detail" }] }],
});
assert.equal(result.content[0].text, "Added 2 tasks: 1, 2. 0/2 done.");
assert.equal(result.details.action, "append");
assert.equal(result.details.snapshot.tasks.length, 2);
assert.equal(result.details.snapshot.tasks[0].description, undefined);
assert.equal(result.details.snapshot.nextId, 3);
assert.equal(harness.changes(), 1);
});
test("tool validation errors are summarized while preserving atomic state", async () => {
test("set_tasks atomically replaces or clears the tree and resets IDs", async () => {
const harness = registeredTools();
await execute(harness.tools.get("add_task"), {
tasks: [{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }],
await execute(harness.tools.get("set_tasks"), {
mode: "append",
tasks: [{ name: "Old", status: "done" }],
});
const replaced = await execute(harness.tools.get("set_tasks"), {
mode: "replace",
tasks: [{ name: "New", status: "in-progress" }],
});
assert.equal(replaced.content[0].text, "Replaced task tree with 1 task. 0/1 done. Task IDs restart at 1.");
assert.deepEqual(harness.state.list().map((task) => [task.id, task.name]), [[1, "New"]]);
const cleared = await execute(harness.tools.get("set_tasks"), {
mode: "replace",
tasks: [],
force: true,
});
assert.equal(cleared.content[0].text, "Cleared task tree. Task IDs reset to 1.");
assert.deepEqual(harness.state.snapshot(), { tasks: [], nextId: 1 });
});
test("tool validation errors preserve atomic state", async () => {
const harness = registeredTools();
await execute(harness.tools.get("set_tasks"), {
mode: "append",
tasks: [{ name: "Parent", children: [{ name: "Child" }] }],
});
const before = harness.state.snapshot();
@@ -58,10 +85,22 @@ test("tool validation errors are summarized while preserving atomic state", asyn
assert.equal(harness.changes(), 1);
});
test("list is compact and get returns full JSON for multiple IDs", async () => {
test("rm_task requires and reports a constrained removal reason", async () => {
const harness = registeredTools();
await execute(harness.tools.get("add_task"), {
tasks: [{ name: "Parent", description: "Detailed", children: [{ name: "Child", description: "Child detail" }] }],
await execute(harness.tools.get("set_tasks"), {
mode: "append",
tasks: [{ name: "Duplicate" }],
});
const removed = await execute(harness.tools.get("rm_task"), { ids: [1], reason: "duplicate" });
assert.match(removed.content[0].text, /Reason: duplicate\.$/);
assert.deepEqual(harness.state.list(), []);
});
test("list is compact and get returns available details for multiple IDs", async () => {
const harness = registeredTools();
await execute(harness.tools.get("set_tasks"), {
mode: "append",
tasks: [{ name: "Parent", description: "Detailed", children: [{ name: "Child" }] }],
});
const list = await execute(harness.tools.get("list_task"), {});
@@ -71,5 +110,6 @@ test("list is compact and get returns full JSON for multiple IDs", async () => {
const parsed = JSON.parse(get.content[0].text);
assert.equal(parsed[0].description, "Detailed");
assert.deepEqual(parsed[0].childrenIds, [2]);
assert.equal(parsed[1].description, undefined);
assert.equal(parsed[1].parentId, 1);
});
+11
View File
@@ -170,6 +170,17 @@ test("tool errors show one summary line until expanded", () => {
assert.deepEqual(expanded, ["✗ Update failed · 2 validation errors", " - first", " - second"]);
});
test("expanded task details tolerate an omitted description", () => {
const state = new TaskState();
state.add([{ name: "Name only" }]);
const result = {
content: [{ type: "text", text: "details" }],
details: { action: "get" as const, snapshot: state.snapshot(), affected: state.list() },
};
const lines = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
assert.deepEqual(lines, ["✓ 1 task", " ○ 1. Name only", " Parent root · Children none"]);
});
test("snapshot stats count every task node", () => {
assert.deepEqual(snapshotStats(fixture().list()), {
total: 5,