feat: strengthen task workflow and compaction recovery
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
|
||||
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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user