diff --git a/README.md b/README.md index 4bd4130..f103952 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/index.ts b/index.ts index bf4ce65..a829677 100644 --- a/index.ts +++ b/index.ts @@ -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); diff --git a/package-lock.json b/package-lock.json index 7b10285..3c12c5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 341987c..f66424b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/prompt.ts b/src/prompt.ts new file mode 100644 index 0000000..ac31f77 --- /dev/null +++ b/src/prompt.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"); +} diff --git a/src/state.ts b/src/state.ts index f76b148..f748f4a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -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(["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"]); diff --git a/src/tools.ts b/src/tools.ts index 4dba867..dd08f8b 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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), }; }, diff --git a/src/types.ts b/src/types.ts index 71c1ff3..33afc86 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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[]; diff --git a/src/ui.ts b/src/ui.ts index 2c88393..8575a6c 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -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; diff --git a/tests/prompt.test.ts b/tests/prompt.test.ts new file mode 100644 index 0000000..866b9f7 --- /dev/null +++ b/tests/prompt.test.ts @@ -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 unknown>(); + const tools = new Map(); + 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); +}); diff --git a/tests/state.test.ts b/tests/state.test.ts index 7c5dcfe..c701fe4 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -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"]]); }); diff --git a/tests/tools.test.ts b/tests/tools.test.ts index b0185bb..f487cf1 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -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); }); diff --git a/tests/ui.test.ts b/tests/ui.test.ts index fe3c1e1..5c2a1a5 100644 --- a/tests/ui.test.ts +++ b/tests/ui.test.ts @@ -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,