Auto-clear completed todo trees

This commit is contained in:
jb
2026-08-26 18:07:46 -04:00
parent 91a019de29
commit fbe8baf18c
9 changed files with 283 additions and 17 deletions
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createCompletedTaskAutoClear, TODO_AUTO_CLEAR_DELAY_MS } from "../src/auto-clear.ts";
import { TaskState } from "../src/state.ts";
function clearState(state: TaskState, onClear: () => void): boolean {
state.set("replace", [], false);
onClear();
return true;
}
test("clears the task tree ten seconds after every task remains done", (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const state = new TaskState();
state.add([{ name: "Done", status: "done" }]);
let clearCount = 0;
const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {
clearCount += 1;
}));
autoClear.sync();
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS - 1);
assert.equal(state.stats().total, 1);
context.mock.timers.tick(1);
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
assert.equal(clearCount, 1);
});
test("cancels a pending clear when unfinished work appears", (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const state = new TaskState();
state.add([{ name: "Done", status: "done" }]);
let clearCount = 0;
const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {
clearCount += 1;
}));
autoClear.sync();
state.add([{ name: "New work" }]);
autoClear.sync();
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS);
assert.equal(state.stats().total, 2);
assert.equal(clearCount, 0);
});
test("preserves the deadline for unrelated refreshes", (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const state = new TaskState();
state.add([{ name: "Done", status: "done" }]);
const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {}));
autoClear.sync();
context.mock.timers.tick(9_000);
state.update([{ id: 1, name: "Renamed" }]);
autoClear.sync();
context.mock.timers.tick(1_000);
assert.equal(state.stats().total, 0);
});
test("restarts the delay for changed or restored completed trees", (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const state = new TaskState();
state.add([{ name: "Done", status: "done" }]);
const autoClear = createCompletedTaskAutoClear(state, () => clearState(state, () => {}));
autoClear.sync();
context.mock.timers.tick(9_000);
state.add([{ name: "Also done", status: "done" }]);
autoClear.sync();
context.mock.timers.tick(1_000);
assert.equal(state.stats().total, 2);
context.mock.timers.tick(8_000);
autoClear.sync(true);
context.mock.timers.tick(2_000);
assert.equal(state.stats().total, 2);
context.mock.timers.tick(8_000);
assert.equal(state.stats().total, 0);
});
test("retries after a clear transaction fails and supports shutdown cancellation", (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
const state = new TaskState();
state.add([{ name: "Done", status: "done" }]);
let attempts = 0;
const autoClear = createCompletedTaskAutoClear(state, () => {
attempts += 1;
if (attempts === 1) return false;
return clearState(state, () => {});
});
autoClear.sync();
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS);
assert.equal(state.stats().total, 1);
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS);
assert.equal(state.stats().total, 0);
state.add([{ name: "Done again", status: "done" }]);
autoClear.sync();
autoClear.cancel();
context.mock.timers.tick(TODO_AUTO_CLEAR_DELAY_MS);
assert.equal(state.stats().total, 1);
});
+50
View File
@@ -42,6 +42,56 @@ test("registers the conflict-free /todo command", async () => {
assert.deepEqual(notifications, ["No todos yet."]);
});
test("persists automatic clears before deletion, retries failures, and cancels on shutdown", async (context) => {
context.mock.timers.enable({ apis: ["setTimeout"] });
context.mock.method(console, "error", () => {});
const handlers = new Map<string, (event: any, ctx: any) => unknown>();
const tools = new Map<string, any>();
const entries: Array<{ customType: string; data: unknown }> = [];
let persistAttempts = 0;
const pi = {
on(name: string, handler: (event: any, ctx: any) => unknown) {
handlers.set(name, handler);
},
registerTool(tool: any) {
tools.set(tool.name, tool);
},
registerCommand() {},
appendEntry(customType: string, data: unknown) {
persistAttempts += 1;
if (persistAttempts === 1) throw new Error("disk unavailable");
entries.push({ customType, data });
},
};
const ctx = { ui: { setWidget() {} } };
piTodoHerdr(pi as never);
await tools.get("set_tasks").execute(
"set",
{ mode: "append", tasks: [{ name: "Done", status: "done" }], force: false },
undefined,
undefined,
ctx,
);
context.mock.timers.tick(10_000);
assert.equal((await tools.get("list_task").execute()).content[0].text, "1 [done] Done");
context.mock.timers.tick(10_000);
assert.equal((await tools.get("list_task").execute()).content[0].text, "No tasks.");
assert.deepEqual(entries, [{ customType: "pi-todo-herdr-state", data: { tasks: [], nextId: 1 } }]);
await tools.get("set_tasks").execute(
"set",
{ mode: "append", tasks: [{ name: "Done again", status: "done" }], force: false },
undefined,
undefined,
ctx,
);
await handlers.get("session_shutdown")?.({ reason: "reload" }, ctx);
context.mock.timers.tick(10_000);
assert.equal((await tools.get("list_task").execute()).content[0].text, "1 [done] Done again");
});
test("keeps task guidance short with planning after exploration", () => {
assert.equal(TASK_PROMPT_GUIDELINES.length, 2);
const prompt = TASK_PROMPT_GUIDELINES.join("\n");
+25 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { TaskState } from "../src/state.ts";
import { TASK_STATE_ENTRY_TYPE, TaskState } from "../src/state.ts";
import { TaskValidationError } from "../src/types.ts";
function names(state: TaskState): string[] {
@@ -362,6 +362,30 @@ test("loads the latest valid mutation snapshot from a session branch", () => {
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
});
test("loads a persisted automatic clear after task tool snapshots", () => {
const completed = new TaskState();
completed.add([{ name: "Completed", status: "done" }]);
const branch = [
{
type: "message",
message: {
role: "toolResult",
toolName: "update_task",
details: { action: "update", snapshot: completed.snapshot(), affected: [] },
},
},
{
type: "custom",
customType: TASK_STATE_ENTRY_TYPE,
data: { tasks: [], nextId: 1 },
},
];
const state = new TaskState();
state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never);
assert.deepEqual(state.snapshot(), { tasks: [], nextId: 1 });
});
test("rejects terminal controls and bidirectional overrides without changing state", () => {
const state = new TaskState();
state.add([{ name: "Safe", description: "Line one\nLine two" }]);
+6 -2
View File
@@ -7,15 +7,17 @@ import { registerTaskTools } from "../src/tools.ts";
function registeredTools(state = new TaskState()) {
const tools = new Map<string, any>();
let changes = 0;
const autoClearRestarts: boolean[] = [];
const pi = {
registerTool(tool: any) {
tools.set(tool.name, tool);
},
};
registerTaskTools(pi as never, state, () => {
registerTaskTools(pi as never, state, (_ctx, restartAutoClear = false) => {
changes += 1;
autoClearRestarts.push(restartAutoClear);
});
return { tools, state, changes: () => changes };
return { tools, state, changes: () => changes, autoClearRestarts };
}
async function execute(tool: any, params: unknown) {
@@ -209,6 +211,7 @@ test("set_tasks appends optional-description tasks and persists a complete snaps
assert.equal(result.details.snapshot.tasks[0].description, undefined);
assert.equal(result.details.snapshot.nextId, 3);
assert.equal(harness.changes(), 1);
assert.deepEqual(harness.autoClearRestarts, [false]);
});
test("set_tasks atomically replaces or clears the tree and resets IDs", async () => {
@@ -232,6 +235,7 @@ test("set_tasks atomically replaces or clears the tree and resets IDs", async ()
});
assert.equal(cleared.content[0].text, "Cleared task tree. Task IDs reset to 1.");
assert.deepEqual(harness.state.snapshot(), { tasks: [], nextId: 1 });
assert.deepEqual(harness.autoClearRestarts, [false, true, true]);
});
test("tool validation errors preserve atomic state", async () => {