feat: strengthen task workflow and compaction recovery
This commit is contained in:
@@ -6,11 +6,12 @@ Hierarchical, session-local task tracking for [Pi](https://pi.dev), with atomic
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Five focused tools: `add_task`, `update_task`, `rm_task`, `list_task`, and `get_task`
|
- Five focused tools: `set_tasks`, `update_task`, `rm_task`, `list_task`, and `get_task`
|
||||||
- Parent/child task trees with generated, stable integer IDs
|
- 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
|
- Atomic batch mutations: every requested change succeeds or none of it is applied
|
||||||
- Four states: `pending`, `in-progress`, `blocked`, and `done`
|
- 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
|
- Compact Pi widget with hierarchy, status colors, and an adaptive progress bar
|
||||||
- Optional Herdr metadata showing the current task in the agent sidebar
|
- Optional Herdr metadata showing the current task in the agent sidebar
|
||||||
|
|
||||||
@@ -45,20 +46,23 @@ pi remove npm:pi-todo-herdr
|
|||||||
|
|
||||||
## Usage
|
## 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 |
|
| 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. |
|
| `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. |
|
| `list_task` | `{}` | Returns the full tree as compact `id/status/name` lines. |
|
||||||
| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent and child IDs. |
|
| `get_task` | `{ ids: [...] }` | Returns complete task details, including parent and child IDs. |
|
||||||
|
|
||||||
Example add call:
|
Example append call:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"mode": "append",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"name": "Implement authentication",
|
"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
|
## Pi Widget
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||||
import { clearCurrentTask, reportCurrentTask } from "./src/herdr.ts";
|
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 { TaskState } from "./src/state.ts";
|
||||||
import { registerTaskTools } from "./src/tools.ts";
|
import { registerTaskTools } from "./src/tools.ts";
|
||||||
import { clearTaskWidget, updateTaskWidget } from "./src/widget.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) => {
|
pi.on("session_shutdown", async (event) => {
|
||||||
if (event.reason === "quit") await clearCurrentTask();
|
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);
|
registerTaskTools(pi, state, refresh);
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-todo-herdr",
|
"name": "pi-todo-herdr",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pi-todo-herdr",
|
"name": "pi-todo-herdr",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@earendil-works/pi-ai": "^0.81.1",
|
"@earendil-works/pi-ai": "^0.81.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pi-todo-herdr",
|
"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.",
|
"description": "Hierarchical task tools for Pi with a live widget and Herdr sidebar integration.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./index.ts",
|
"main": "./index.ts",
|
||||||
|
|||||||
@@ -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
@@ -10,13 +10,13 @@ import type {
|
|||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
import { TaskValidationError } 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"]);
|
const VALID_STATUSES = new Set<TaskStatus>(["pending", "in-progress", "blocked", "done"]);
|
||||||
|
|
||||||
interface TaskRecord {
|
interface TaskRecord {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
parentId: number | null;
|
parentId: number | null;
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,9 @@ function validateGraph(records: TaskRecord[]): string[] {
|
|||||||
ids.add(task.id);
|
ids.add(task.id);
|
||||||
if (!task.name.trim()) errors.push(`task ${task.id}: name must not be empty`);
|
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 (/[\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 (!VALID_STATUSES.has(task.status)) errors.push(`task ${task.id}: invalid status '${task.status}'`);
|
||||||
if (task.parentId !== null && !byId.has(task.parentId)) {
|
if (task.parentId !== null && !byId.has(task.parentId)) {
|
||||||
errors.push(`task ${task.id}: parent ${task.parentId} does not exist`);
|
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" || !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.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`);
|
errors.push(`${path}.description must not be empty`);
|
||||||
}
|
}
|
||||||
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
|
if (draft.status !== undefined && !VALID_STATUSES.has(draft.status)) {
|
||||||
@@ -214,7 +216,7 @@ export class TaskState {
|
|||||||
working.push({
|
working.push({
|
||||||
id,
|
id,
|
||||||
name: typeof draft.name === "string" ? draft.name.trim() : "",
|
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",
|
status: draft.status ?? "pending",
|
||||||
parentId,
|
parentId,
|
||||||
});
|
});
|
||||||
@@ -233,6 +235,29 @@ export class TaskState {
|
|||||||
return this.get(addedIds);
|
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[] {
|
update(patches: TaskPatch[]): Task[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
if (!Array.isArray(patches) || patches.length === 0) throw new TaskValidationError(["tasks must not be empty"]);
|
||||||
|
|||||||
+70
-40
@@ -2,24 +2,33 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|||||||
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
||||||
import { Text } from "@earendil-works/pi-tui";
|
import { Text } from "@earendil-works/pi-tui";
|
||||||
import { Type } from "typebox";
|
import { Type } from "typebox";
|
||||||
|
import { formatCompactTaskTree, TASK_PROMPT_GUIDELINES } from "./prompt.ts";
|
||||||
import type { TaskState } from "./state.ts";
|
import type { TaskState } from "./state.ts";
|
||||||
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
|
import type { AddTaskDraft, Task, TaskPatch, TaskToolDetails } from "./types.ts";
|
||||||
import { TaskValidationError } 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, {
|
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(
|
const AddTaskSchema = Type.Cyclic(
|
||||||
{
|
{
|
||||||
AddTask: Type.Object(
|
AddTask: Type.Object(
|
||||||
{
|
{
|
||||||
name: Type.String({ minLength: 1 }),
|
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),
|
status: Type.Optional(TaskStatusSchema),
|
||||||
parentId: Type.Optional(
|
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"))),
|
children: Type.Optional(Type.Array(Type.Ref("AddTask"))),
|
||||||
},
|
},
|
||||||
@@ -29,9 +38,15 @@ const AddTaskSchema = Type.Cyclic(
|
|||||||
"AddTask",
|
"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 },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
@@ -61,20 +76,27 @@ export const UpdateTaskParams = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ 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 }), {
|
ids: TaskIdsSchema,
|
||||||
minItems: 1,
|
reason: RemovalReasonSchema,
|
||||||
description: "Task IDs to process in one call. Duplicate IDs are deduplicated.",
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IdsParams = Type.Object({ ids: TaskIdsSchema }, { additionalProperties: false });
|
||||||
|
|
||||||
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
export const ListTaskParams = Type.Object({}, { additionalProperties: false });
|
||||||
|
|
||||||
interface AddTaskInput {
|
interface SetTaskInput {
|
||||||
|
mode: "append" | "replace";
|
||||||
tasks: AddTaskDraft[];
|
tasks: AddTaskDraft[];
|
||||||
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateTaskInput {
|
interface UpdateTaskInput {
|
||||||
@@ -85,19 +107,16 @@ interface IdsInput {
|
|||||||
ids: number[];
|
ids: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RemoveTaskInput extends IdsInput {
|
||||||
|
reason: "mistaken" | "duplicate" | "canceled";
|
||||||
|
}
|
||||||
|
|
||||||
type ChangeHandler = (ctx: ExtensionContext) => void;
|
type ChangeHandler = (ctx: ExtensionContext) => void;
|
||||||
|
|
||||||
function taskIds(tasks: Task[]): string {
|
function taskIds(tasks: Task[]): string {
|
||||||
return tasks.map((task) => task.id).join(", ");
|
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 {
|
function details(action: TaskToolDetails["action"], state: TaskState, affected: Task[]): TaskToolDetails {
|
||||||
return { action, snapshot: state.snapshot(), affected };
|
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.`;
|
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 {
|
function throwToolError(label: string, error: unknown): never {
|
||||||
if (error instanceof TaskValidationError) {
|
if (error instanceof TaskValidationError) {
|
||||||
const count = error.errors.length;
|
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 {
|
export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange: ChangeHandler): void {
|
||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "add_task",
|
name: "set_tasks",
|
||||||
label: "Add Tasks",
|
label: "Set Tasks",
|
||||||
description:
|
description:
|
||||||
"Atomically add one or more tasks. Use nested children for new descendants and parentId on top-level items for an existing parent.",
|
"Atomically append tasks or replace the task tree. Replace may clear with an empty array; force is required to discard unfinished tasks.",
|
||||||
promptGuidelines: [
|
promptGuidelines: [...TASK_PROMPT_GUIDELINES],
|
||||||
"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: SetTaskParams,
|
||||||
],
|
async execute(_toolCallId, params: SetTaskInput, _signal, _onUpdate, ctx) {
|
||||||
parameters: AddTaskParams,
|
|
||||||
async execute(_toolCallId, params: AddTaskInput, _signal, _onUpdate, ctx) {
|
|
||||||
try {
|
try {
|
||||||
const affected = state.add(params.tasks);
|
const affected = state.set(params.mode, params.tasks, params.force);
|
||||||
notifyChange(onChange, ctx);
|
notifyChange(onChange, ctx);
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text: mutationText("Added", affected, state) }],
|
content: [{ type: "text" as const, text: setTaskText(params.mode, affected, state) }],
|
||||||
details: details("add", state, affected),
|
details: details(params.mode, state, affected),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throwToolError("Add", error);
|
throwToolError("Set", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderCall(args: AddTaskInput, theme: Theme) {
|
renderCall(args: SetTaskInput, theme: Theme) {
|
||||||
return renderCall("Add Tasks", args.tasks?.length, theme);
|
return renderCall(`Set Tasks · ${args.mode}`, args.tasks?.length, theme);
|
||||||
},
|
},
|
||||||
renderResult(result, { expanded }, theme) {
|
renderResult(result, { expanded }, theme) {
|
||||||
return renderToolResult(result, expanded, theme);
|
return renderToolResult(result, expanded, theme);
|
||||||
@@ -189,22 +213,28 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
|||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "rm_task",
|
name: "rm_task",
|
||||||
label: "Remove Tasks",
|
label: "Remove Tasks",
|
||||||
description: "Atomically remove multiple task IDs. Removing a parent also removes its entire descendant subtree.",
|
description:
|
||||||
parameters: IdsParams,
|
"Remove tasks only when mistaken, duplicate, or canceled. Removing a parent also removes its descendant subtree.",
|
||||||
async execute(_toolCallId, params: IdsInput, _signal, _onUpdate, ctx) {
|
parameters: RemoveTaskParams,
|
||||||
|
async execute(_toolCallId, params: RemoveTaskInput, _signal, _onUpdate, ctx) {
|
||||||
try {
|
try {
|
||||||
const affected = state.remove(params.ids);
|
const affected = state.remove(params.ids);
|
||||||
notifyChange(onChange, ctx);
|
notifyChange(onChange, ctx);
|
||||||
return {
|
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),
|
details: details("remove", state, affected),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throwToolError("Remove", error);
|
throwToolError("Remove", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderCall(args: IdsInput, theme: Theme) {
|
renderCall(args: RemoveTaskInput, theme: Theme) {
|
||||||
return renderCall("Remove Tasks", args.ids?.length, theme);
|
return renderCall(`Remove Tasks · ${args.reason}`, args.ids?.length, theme);
|
||||||
},
|
},
|
||||||
renderResult(result, { expanded }, theme) {
|
renderResult(result, { expanded }, theme) {
|
||||||
return renderToolResult(result, expanded, theme);
|
return renderToolResult(result, expanded, theme);
|
||||||
@@ -219,7 +249,7 @@ export function registerTaskTools(pi: ExtensionAPI, state: TaskState, onChange:
|
|||||||
async execute() {
|
async execute() {
|
||||||
const affected = state.list();
|
const affected = state.list();
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text" as const, text: compactTree(affected) }],
|
content: [{ type: "text" as const, text: formatCompactTaskTree(affected) }],
|
||||||
details: details("list", state, affected),
|
details: details("list", state, affected),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@ export type TaskStatus = "pending" | "in-progress" | "blocked" | "done";
|
|||||||
export interface Task {
|
export interface Task {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
parentId: number | null;
|
parentId: number | null;
|
||||||
childrenIds: number[];
|
childrenIds: number[];
|
||||||
@@ -16,7 +16,7 @@ export interface TaskSnapshot {
|
|||||||
|
|
||||||
export interface AddTaskDraft {
|
export interface AddTaskDraft {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description?: string;
|
||||||
status?: TaskStatus;
|
status?: TaskStatus;
|
||||||
parentId?: number;
|
parentId?: number;
|
||||||
children?: AddTaskDraft[];
|
children?: AddTaskDraft[];
|
||||||
@@ -40,7 +40,7 @@ export interface TaskStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskToolDetails {
|
export interface TaskToolDetails {
|
||||||
action: "add" | "update" | "remove" | "list" | "get";
|
action: "add" | "append" | "replace" | "update" | "remove" | "list" | "get";
|
||||||
snapshot: TaskSnapshot;
|
snapshot: TaskSnapshot;
|
||||||
affected: Task[];
|
affected: Task[];
|
||||||
errors?: string[];
|
errors?: string[];
|
||||||
|
|||||||
@@ -103,7 +103,10 @@ function summaryFor(details: TaskToolDetails): string {
|
|||||||
const progress = `${stats.done}/${stats.total} done`;
|
const progress = `${stats.done}/${stats.total} done`;
|
||||||
switch (details.action) {
|
switch (details.action) {
|
||||||
case "add":
|
case "add":
|
||||||
|
case "append":
|
||||||
return `${details.affected.length} added · ${progress}`;
|
return `${details.affected.length} added · ${progress}`;
|
||||||
|
case "replace":
|
||||||
|
return stats.total === 0 ? "task tree cleared" : `${stats.total} installed · ${progress}`;
|
||||||
case "update":
|
case "update":
|
||||||
return `${details.affected.length} updated · ${progress}`;
|
return `${details.affected.length} updated · ${progress}`;
|
||||||
case "remove":
|
case "remove":
|
||||||
@@ -137,12 +140,14 @@ function renderTaskDetails(tasks: Task[], theme: Theme, width: number): string[]
|
|||||||
const parent = task.parentId === null ? "root" : `#${task.parentId}`;
|
const parent = task.parentId === null ? "root" : `#${task.parentId}`;
|
||||||
const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", ");
|
const children = task.childrenIds.length === 0 ? "none" : task.childrenIds.map((id) => `#${id}`).join(", ");
|
||||||
lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width));
|
lines.push(truncateToWidth(theme.fg("dim", ` Parent ${parent} · Children ${children}`), width));
|
||||||
const prefix = " ";
|
if (task.description) {
|
||||||
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
|
const prefix = " ";
|
||||||
for (const sourceLine of task.description.split("\n")) {
|
const descriptionWidth = Math.max(1, width - visibleWidth(prefix));
|
||||||
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
|
for (const sourceLine of task.description.split("\n")) {
|
||||||
if (wrapped.length === 0) lines.push(prefix);
|
const wrapped = wrapTextWithAnsi(theme.fg("muted", sourceLine), descriptionWidth);
|
||||||
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
|
if (wrapped.length === 0) lines.push(prefix);
|
||||||
|
else lines.push(...wrapped.map((line) => `${prefix}${line}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
|
|||||||
@@ -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
@@ -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"]);
|
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();
|
const state = new TaskState();
|
||||||
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
|
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
|
||||||
state.add([{ name: "Valid", description: "Line one\nLine two" }]);
|
assert.throws(() => state.add([{ name: "Blank description", description: " " }]), /must not be empty/);
|
||||||
assert.equal(state.list()[0]?.description, "Line one\nLine two");
|
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
@@ -21,32 +21,59 @@ async function execute(tool: any, params: unknown) {
|
|||||||
return tool.execute("call", params, undefined, undefined, {});
|
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();
|
const { tools } = registeredTools();
|
||||||
assert.deepEqual([...tools.keys()], ["add_task", "update_task", "rm_task", "list_task", "get_task"]);
|
assert.deepEqual([...tools.keys()], ["set_tasks", "update_task", "rm_task", "list_task", "get_task"]);
|
||||||
assert.deepEqual(Object.keys(tools.get("add_task").parameters.properties), ["tasks"]);
|
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("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("list_task").parameters.properties), []);
|
||||||
assert.deepEqual(Object.keys(tools.get("get_task").parameters.properties), ["ids"]);
|
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 harness = registeredTools();
|
||||||
const result = await execute(harness.tools.get("add_task"), {
|
const result = await execute(harness.tools.get("set_tasks"), {
|
||||||
tasks: [{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }],
|
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.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.length, 2);
|
||||||
|
assert.equal(result.details.snapshot.tasks[0].description, undefined);
|
||||||
assert.equal(result.details.snapshot.nextId, 3);
|
assert.equal(result.details.snapshot.nextId, 3);
|
||||||
assert.equal(harness.changes(), 1);
|
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();
|
const harness = registeredTools();
|
||||||
await execute(harness.tools.get("add_task"), {
|
await execute(harness.tools.get("set_tasks"), {
|
||||||
tasks: [{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }],
|
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();
|
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);
|
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();
|
const harness = registeredTools();
|
||||||
await execute(harness.tools.get("add_task"), {
|
await execute(harness.tools.get("set_tasks"), {
|
||||||
tasks: [{ name: "Parent", description: "Detailed", children: [{ name: "Child", description: "Child detail" }] }],
|
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"), {});
|
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);
|
const parsed = JSON.parse(get.content[0].text);
|
||||||
assert.equal(parsed[0].description, "Detailed");
|
assert.equal(parsed[0].description, "Detailed");
|
||||||
assert.deepEqual(parsed[0].childrenIds, [2]);
|
assert.deepEqual(parsed[0].childrenIds, [2]);
|
||||||
|
assert.equal(parsed[1].description, undefined);
|
||||||
assert.equal(parsed[1].parentId, 1);
|
assert.equal(parsed[1].parentId, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -170,6 +170,17 @@ test("tool errors show one summary line until expanded", () => {
|
|||||||
assert.deepEqual(expanded, ["✗ Update failed · 2 validation errors", " - first", " - second"]);
|
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", () => {
|
test("snapshot stats count every task node", () => {
|
||||||
assert.deepEqual(snapshotStats(fixture().list()), {
|
assert.deepEqual(snapshotStats(fixture().list()), {
|
||||||
total: 5,
|
total: 5,
|
||||||
|
|||||||
Reference in New Issue
Block a user