feat: package Pi extensions as Bun workspaces
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
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("registers the conflict-free /todo command", async () => {
|
||||
type RegisteredCommand = {
|
||||
description: string;
|
||||
handler(args: string, ctx: unknown): Promise<void>;
|
||||
};
|
||||
const commands = new Map<string, RegisteredCommand>();
|
||||
const pi = {
|
||||
on() {},
|
||||
registerTool() {},
|
||||
registerCommand(name: string, command: RegisteredCommand) {
|
||||
commands.set(name, command);
|
||||
},
|
||||
};
|
||||
|
||||
piTodoHerdr(pi as never);
|
||||
|
||||
assert.deepEqual([...commands.keys()], ["todo"]);
|
||||
const todo = commands.get("todo");
|
||||
assert.equal(todo?.description, "Show or hide the todo tree widget");
|
||||
|
||||
const notifications: string[] = [];
|
||||
await todo?.handler("", {
|
||||
ui: {
|
||||
setWidget() {},
|
||||
notify(message: string) {
|
||||
notifications.push(message);
|
||||
},
|
||||
},
|
||||
});
|
||||
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");
|
||||
assert.match(prompt, /work with 3\+ distinct steps/);
|
||||
assert.match(prompt, /explore enough to understand the scope/);
|
||||
assert.match(prompt, /then call set_tasks before implementation/);
|
||||
assert.match(prompt, /otherwise skip tracking/);
|
||||
assert.match(prompt, /Start one task in-progress/);
|
||||
assert.match(prompt, /patch_tasks when tasks start, block, or finish/);
|
||||
assert.match(prompt, /keep statuses accurate before the final response/);
|
||||
assert.ok(prompt.split(/\s+/).length < 40);
|
||||
});
|
||||
|
||||
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("patch_tasks").execute(
|
||||
"patch",
|
||||
{ tasks: [{ id: 1, status: "done" }] },
|
||||
undefined,
|
||||
undefined,
|
||||
ctx,
|
||||
);
|
||||
handlers.get("session_compact")?.({}, ctx);
|
||||
assert.equal(sent.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { TASK_STATE_ENTRY_TYPE, TaskState } from "../src/state.ts";
|
||||
import { TaskValidationError } from "../src/types.ts";
|
||||
|
||||
function names(state: TaskState): string[] {
|
||||
return state.list().filter((task) => task.parentId === null).map((task) => task.name);
|
||||
}
|
||||
|
||||
test("adds a nested batch with generated preorder IDs and derived relations", () => {
|
||||
const state = new TaskState();
|
||||
const added = state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
description: "Parent description",
|
||||
children: [
|
||||
{ name: "First child", description: "First" },
|
||||
{ name: "Second child", description: "Second", status: "blocked" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(added.map((task) => task.id), [1, 2, 3]);
|
||||
assert.deepEqual(state.get([1])[0]?.childrenIds, [2, 3]);
|
||||
assert.equal(state.get([2])[0]?.parentId, 1);
|
||||
assert.equal(state.get([2])[0]?.status, "pending");
|
||||
assert.equal(state.snapshot().nextId, 4);
|
||||
});
|
||||
|
||||
test("rejects invalid nested parentId atomically while accepting null sentinels", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Existing", description: "Existing" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
description: "Parent",
|
||||
children: [{ name: "Child", description: "Child", parentId: 1 }],
|
||||
},
|
||||
]),
|
||||
TaskValidationError,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
const added = state.add([
|
||||
{
|
||||
name: "Nullable parent",
|
||||
parentId: null,
|
||||
children: [{ name: "Implicit child", parentId: null }],
|
||||
},
|
||||
]);
|
||||
assert.equal(added[0]?.parentId, null);
|
||||
assert.equal(added[1]?.parentId, added[0]?.id);
|
||||
});
|
||||
|
||||
test("enforces done descendants against the final atomic update state", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Parent", description: "Parent", children: [{ name: "Child", description: "Child" }] }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
state.update([
|
||||
{ id: 2, status: "done" },
|
||||
{ id: 1, status: "done" },
|
||||
]);
|
||||
assert.deepEqual(state.list().map((task) => task.status), ["done", "done"]);
|
||||
|
||||
assert.throws(() => state.update([{ id: 2, status: "in-progress" }]), /unfinished descendants/);
|
||||
state.update([
|
||||
{ id: 1, status: "in-progress" },
|
||||
{ id: 2, status: "in-progress" },
|
||||
]);
|
||||
assert.deepEqual(state.list().map((task) => task.status), ["in-progress", "in-progress"]);
|
||||
});
|
||||
|
||||
test("preserves and accumulates timing across repeated updates and status changes", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const state = new TaskState(() => now);
|
||||
|
||||
const [started] = state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
assert.equal(started?.startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(started?.stoppedAt, undefined);
|
||||
assert.equal(started?.durationMs, 0);
|
||||
|
||||
now += 5_000;
|
||||
const [repeated] = state.update([{ id: 1, name: "Renamed", status: "in-progress" }]);
|
||||
assert.equal(repeated?.startedAt, started?.startedAt);
|
||||
assert.equal(repeated?.stoppedAt, undefined);
|
||||
assert.equal(repeated?.durationMs, 5_000);
|
||||
|
||||
now += 2_000;
|
||||
const [paused] = state.update([{ id: 1, status: "blocked" }]);
|
||||
assert.equal(paused?.startedAt, started?.startedAt);
|
||||
assert.equal(paused?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(paused?.durationMs, 7_000);
|
||||
|
||||
now += 10_000;
|
||||
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||
const [resumed] = state.update([{ id: 1, status: "in-progress" }]);
|
||||
assert.equal(resumed?.startedAt, started?.startedAt);
|
||||
assert.equal(resumed?.stoppedAt, undefined);
|
||||
assert.equal(resumed?.durationMs, 7_000);
|
||||
|
||||
now += 3_000;
|
||||
const [done] = state.update([{ id: 1, status: "done" }]);
|
||||
assert.equal(done?.startedAt, started?.startedAt);
|
||||
assert.equal(done?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(done?.durationMs, 10_000);
|
||||
assert.deepEqual(state.snapshot().timings, [
|
||||
{ taskId: 1, startedAt, stoppedAt: now, accumulatedMs: 10_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not stop a timer when an atomic update fails", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
status: "in-progress",
|
||||
children: [{ name: "Unfinished child" }],
|
||||
},
|
||||
]);
|
||||
|
||||
now += 5_000;
|
||||
assert.throws(() => state.update([{ id: 1, status: "done" }]), /unfinished descendants/);
|
||||
const afterFailure = state.get([1])[0];
|
||||
assert.equal(afterFailure?.stoppedAt, undefined);
|
||||
assert.equal(afterFailure?.durationMs, 5_000);
|
||||
|
||||
now += 2_000;
|
||||
assert.equal(state.get([1])[0]?.durationMs, 7_000);
|
||||
});
|
||||
|
||||
test("restores timers and starts legacy in-progress snapshots at load time", () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const source = new TaskState(() => now);
|
||||
source.add([{ name: "Persisted", status: "in-progress" }]);
|
||||
now += 2_500;
|
||||
source.update([{ id: 1, status: "pending" }]);
|
||||
|
||||
now += 20_000;
|
||||
const restored = new TaskState(() => now);
|
||||
restored.restore(source.snapshot());
|
||||
assert.equal(restored.get([1])[0]?.startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(restored.get([1])[0]?.stoppedAt, new Date(startedAt + 2_500).toISOString());
|
||||
assert.equal(restored.get([1])[0]?.durationMs, 2_500);
|
||||
|
||||
const legacy = new TaskState(() => now);
|
||||
legacy.restore({
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Legacy active",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
},
|
||||
],
|
||||
nextId: 2,
|
||||
});
|
||||
assert.equal(legacy.get([1])[0]?.startedAt, new Date(now).toISOString());
|
||||
assert.equal(legacy.get([1])[0]?.durationMs, 0);
|
||||
assert.equal(legacy.snapshot().timings?.[0]?.activeStartedAt, now);
|
||||
});
|
||||
|
||||
test("rejects unsafe restored identifiers and oversized timing collections", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
const task = { id: 1, name: "Task", status: "pending" as const, parentId: null, childrenIds: [] };
|
||||
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [{ ...task, id: Number.MAX_SAFE_INTEGER + 1 }], nextId: Number.MAX_SAFE_INTEGER }),
|
||||
/id must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() => state.restore({ tasks: [task], nextId: Number.MAX_SAFE_INTEGER + 1 }),
|
||||
/nextId must be a positive safe integer/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: Array.from({ length: 201 }, () => ({ taskId: 1, startedAt: 0, accumulatedMs: 0 })),
|
||||
}),
|
||||
/timings are limited to 200 entries/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [task],
|
||||
nextId: 2,
|
||||
timings: [{ taskId: 1, startedAt: 0, stoppedAt: 0, accumulatedMs: Number.MAX_SAFE_INTEGER }],
|
||||
}),
|
||||
/accumulatedMs must be an integer between/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects additions when no safe generated task ID remains", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.restore({ tasks: [], nextId: Number.MAX_SAFE_INTEGER });
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "Unsafe ID" }]), /no safe task IDs remain/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("rejects out-of-range persisted timestamps without replacing state", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Keep" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: "Corrupt timer",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
},
|
||||
],
|
||||
nextId: 2,
|
||||
timings: [
|
||||
{
|
||||
taskId: 1,
|
||||
startedAt: Number.MAX_SAFE_INTEGER,
|
||||
activeStartedAt: Number.MAX_SAFE_INTEGER,
|
||||
accumulatedMs: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
/startedAt must be a valid non-negative timestamp/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("finalizes an active timer in removed task details", () => {
|
||||
let now = Date.UTC(2026, 0, 1);
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Canceled", status: "in-progress" }]);
|
||||
now += 4_000;
|
||||
|
||||
const [removed] = state.remove([1]);
|
||||
assert.equal(removed?.stoppedAt, new Date(now).toISOString());
|
||||
assert.equal(removed?.durationMs, 4_000);
|
||||
assert.deepEqual(state.list(), []);
|
||||
});
|
||||
|
||||
test("rejects cycles and duplicate patches without partial changes", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "Root", description: "Root", children: [{ name: "Child", description: "Child" }] }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, parentId: 2 }, { id: 2, name: "Changed" }]), /cycle/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(() => state.update([{ id: 2, name: "One" }, { id: 2, name: "Two" }]), /duplicate task patches/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("reorders roots with zero-based sequential positions", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{ name: "A", description: "A" },
|
||||
{ name: "B", description: "B" },
|
||||
{ name: "C", description: "C" },
|
||||
]);
|
||||
|
||||
state.update([{ id: 1, position: 2 }]);
|
||||
assert.deepEqual(names(state), ["B", "C", "A"]);
|
||||
|
||||
state.update([
|
||||
{ id: 1, position: 0 },
|
||||
{ id: 2, position: 2 },
|
||||
]);
|
||||
assert.deepEqual(names(state), ["A", "C", "B"]);
|
||||
});
|
||||
|
||||
test("reparents and positions tasks among their final siblings", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "One",
|
||||
description: "One",
|
||||
children: [
|
||||
{ name: "A", description: "A" },
|
||||
{ name: "B", description: "B" },
|
||||
],
|
||||
},
|
||||
{ name: "Two", description: "Two", children: [{ name: "C", description: "C" }] },
|
||||
]);
|
||||
|
||||
state.update([{ id: 3, parentId: 4, position: 0 }]);
|
||||
assert.deepEqual(state.get([4])[0]?.childrenIds, [3, 5]);
|
||||
assert.deepEqual(state.get([1])[0]?.childrenIds, [2]);
|
||||
});
|
||||
|
||||
test("removes overlapping parent and child IDs as one cascade", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "Root",
|
||||
description: "Root",
|
||||
children: [{ name: "Child", description: "Child", children: [{ name: "Leaf", description: "Leaf" }] }],
|
||||
},
|
||||
{ name: "Keep", description: "Keep" },
|
||||
]);
|
||||
|
||||
const removed = state.remove([1, 2, 1]);
|
||||
assert.deepEqual(removed.map((task) => task.id), [1, 2, 3]);
|
||||
assert.deepEqual(state.list().map((task) => task.id), [4]);
|
||||
assert.equal(state.snapshot().nextId, 5);
|
||||
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.remove([999]), /do not exist/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("restores snapshots and never reuses deleted IDs", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "One", description: "One" }, { name: "Two", description: "Two" }]);
|
||||
state.remove([2]);
|
||||
const snapshot = state.snapshot();
|
||||
|
||||
const restored = new TaskState();
|
||||
restored.restore(snapshot);
|
||||
const added = restored.add([{ name: "Three", description: "Three" }]);
|
||||
assert.equal(added[0]?.id, 3);
|
||||
});
|
||||
|
||||
test("loads the latest valid mutation snapshot from a session branch", () => {
|
||||
const first = new TaskState();
|
||||
first.add([{ name: "First", description: "First" }]);
|
||||
const second = new TaskState();
|
||||
second.restore(first.snapshot());
|
||||
second.add([{ name: "Second", description: "Second" }]);
|
||||
|
||||
const branch = [first.snapshot(), second.snapshot()].map((snapshot, index) => ({
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolName: index === 0 ? "add_task" : "patch_tasks",
|
||||
details: { action: "add", snapshot, affected: [] },
|
||||
},
|
||||
}));
|
||||
const state = new TaskState();
|
||||
state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never);
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["First", "Second"]);
|
||||
});
|
||||
|
||||
test("loads legacy update_task mutation snapshots", () => {
|
||||
const completed = new TaskState();
|
||||
completed.add([{ name: "Legacy completed task", status: "done" }]);
|
||||
const branch = [
|
||||
{
|
||||
type: "message",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolName: "update_task",
|
||||
details: { action: "update", snapshot: completed.snapshot(), affected: [] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const state = new TaskState();
|
||||
state.loadFromSession({ sessionManager: { getBranch: () => branch } } as never);
|
||||
assert.deepEqual(state.list().map((task) => task.name), ["Legacy completed task"]);
|
||||
});
|
||||
|
||||
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" }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
for (const name of ["Escape\u001b[2J", "Tab\tname", "Arabic mark\u061cname", "Override\u202eevil"]) {
|
||||
assert.throws(() => state.add([{ name }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
}
|
||||
|
||||
assert.throws(() => state.update([{ id: 1, description: "Clipboard\u009d52;c;payload" }]), /unsafe control/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces task name and description length limits by Unicode character", () => {
|
||||
const state = new TaskState();
|
||||
state.add([{ name: "😀".repeat(200), description: "d".repeat(4_000) }]);
|
||||
const before = state.snapshot();
|
||||
|
||||
assert.throws(() => state.add([{ name: "n".repeat(201) }]), /name must be at most 200 characters/);
|
||||
assert.throws(() => state.update([{ id: 1, description: "d".repeat(4_001) }]), /description must be at most 4000 characters/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("enforces per-mutation and per-session task limits atomically", () => {
|
||||
const state = new TaskState();
|
||||
const batch = Array.from({ length: 50 }, (_, index) => ({ name: `Task ${index + 1}` }));
|
||||
|
||||
assert.throws(() => state.add([...batch, { name: "Too many" }]), /at most 50 tasks/);
|
||||
assert.equal(state.list().length, 0);
|
||||
|
||||
for (let index = 0; index < 4; index += 1) state.add(batch);
|
||||
assert.equal(state.list().length, 200);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([{ name: "Session overflow" }]), /session is limited to 200 tasks/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
new TaskState().restore({
|
||||
tasks: Array.from({ length: 201 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Restored ${index + 1}`,
|
||||
status: "pending" as const,
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
})),
|
||||
nextId: 202,
|
||||
}),
|
||||
/session is limited to 200 tasks/,
|
||||
);
|
||||
});
|
||||
|
||||
test("allows three stored levels beneath the virtual root and rejects deeper task trees atomically", () => {
|
||||
const nested = (depth: number) => {
|
||||
let task: { name: string; children?: Array<{ name: string; children?: unknown[] }> } = { name: `Level ${depth}` };
|
||||
for (let level = depth - 1; level >= 1; level -= 1) {
|
||||
task = { name: `Level ${level}`, children: [task] };
|
||||
}
|
||||
return task;
|
||||
};
|
||||
|
||||
const state = new TaskState();
|
||||
state.add([nested(3) as never, { name: "Other category" }]);
|
||||
assert.equal(state.list().length, 4);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.add([nested(4) as never]), /depth must not exceed 3 levels/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
assert.throws(() => state.update([{ id: 4, parentId: 3 }]), /depth must not exceed 3 levels/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
state.restore({
|
||||
tasks: [
|
||||
{ id: 1, name: "Category", status: "pending", parentId: null, childrenIds: [2] },
|
||||
{ id: 2, name: "Item", status: "pending", parentId: 1, childrenIds: [3] },
|
||||
{ id: 3, name: "Leaf", status: "pending", parentId: 2, childrenIds: [4] },
|
||||
{ id: 4, name: "Too deep", status: "pending", parentId: 3, childrenIds: [] },
|
||||
],
|
||||
nextId: 5,
|
||||
}),
|
||||
/depth must not exceed 3 levels/,
|
||||
);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
test("supports nullable descriptions and reports invalid children as validation errors", () => {
|
||||
const state = new TaskState();
|
||||
assert.throws(() => state.add([{ name: "Bad\nname", description: "Valid" }]), /single line/);
|
||||
assert.throws(() => state.add([{ name: "Blank description", description: " " }]), /must not be empty/);
|
||||
assert.throws(
|
||||
() => state.add([{ name: "Invalid children", children: "not-an-array" } as never]),
|
||||
/children must be an array/,
|
||||
);
|
||||
state.add([
|
||||
{ name: "No description" },
|
||||
{ name: "Null description", description: null },
|
||||
{ name: "Detailed", description: "Line one\nLine two" },
|
||||
]);
|
||||
assert.equal(state.list()[0]?.description, undefined);
|
||||
assert.equal(state.list()[1]?.description, undefined);
|
||||
assert.equal(state.list()[2]?.description, "Line one\nLine two");
|
||||
});
|
||||
|
||||
test("set append accepts false force and rejects true force", () => {
|
||||
const state = new TaskState();
|
||||
assert.throws(() => state.set("append", [], false), /tasks must not be empty/);
|
||||
state.set("append", [{ name: "Task" }], false);
|
||||
const before = state.snapshot();
|
||||
assert.throws(() => state.set("append", [{ name: "Other" }], true), /force must be false in append mode/);
|
||||
assert.deepEqual(state.snapshot(), before);
|
||||
});
|
||||
|
||||
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", parentId: null, children: [{ name: "Child", parentId: null }] }],
|
||||
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"]]);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
|
||||
const { validateToolArguments } = await import(
|
||||
new URL("./utils/validation.js", import.meta.resolve("@earendil-works/pi-ai")).href
|
||||
);
|
||||
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, (_ctx, restartAutoClear = false) => {
|
||||
changes += 1;
|
||||
autoClearRestarts.push(restartAutoClear);
|
||||
});
|
||||
return { tools, state, changes: () => changes, autoClearRestarts };
|
||||
}
|
||||
|
||||
async function execute(tool: any, params: unknown) {
|
||||
const prepared = tool.prepareArguments?.(params) ?? params;
|
||||
const validated = validateToolArguments(tool, { name: tool.name, arguments: prepared } as never);
|
||||
return tool.execute("call", validated, undefined, undefined, {});
|
||||
}
|
||||
|
||||
test("registers five focused tools without the old add_task alias", () => {
|
||||
const { tools } = registeredTools();
|
||||
assert.deepEqual([...tools.keys()], ["set_tasks", "patch_tasks", "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("patch_tasks").parameters.properties), ["tasks"]);
|
||||
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"]);
|
||||
assert.match(tools.get("patch_tasks").description, /do not recreate cleared completed tasks/);
|
||||
});
|
||||
|
||||
test("set_tasks schema hoists $defs to the root with #/$defs/ refs", () => {
|
||||
const { tools } = registeredTools();
|
||||
const params = tools.get("set_tasks").parameters;
|
||||
const itemSchema = params.properties.tasks.items;
|
||||
// $defs must live at the document root: root-style refs resolve from there,
|
||||
// and moonshot-flavored validators reject nested definitions (Console Go).
|
||||
assert.ok(params.$defs.AddTask, "$defs hoisted to the parameter schema root");
|
||||
assert.deepEqual(params.required, ["mode", "tasks", "force"]);
|
||||
assert.deepEqual(params.$defs.AddTask.required, ["name", "description", "status", "parentId", "children"]);
|
||||
assert.equal(itemSchema.$defs, undefined, "no $defs nested inside tasks items");
|
||||
assert.equal(itemSchema.$ref, "#/$defs/AddTask");
|
||||
assert.equal(params.$defs.AddTask.properties.children.items.$ref, "#/$defs/AddTask");
|
||||
assert.equal(params.$defs.AddTask.$id, undefined, "synthetic $id stripped");
|
||||
const serialized = JSON.stringify(params);
|
||||
assert.ok(!/"\$ref":"(?!\#)/.test(serialized), "no bare $ref values remain");
|
||||
});
|
||||
|
||||
test("task schemas publish text and batch limits", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setParams = tools.get("set_tasks").parameters;
|
||||
const addTask = setParams.$defs.AddTask;
|
||||
const updateTask = tools.get("patch_tasks").parameters.properties.tasks.items;
|
||||
|
||||
assert.equal(setParams.properties.tasks.maxItems, 50);
|
||||
assert.equal(addTask.properties.children.maxItems, 50);
|
||||
assert.equal(addTask.properties.name.maxLength, 200);
|
||||
assert.match(addTask.properties.name.pattern, /061C/i);
|
||||
assert.equal(addTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||
assert.match(addTask.properties.description.anyOf[0].pattern, /061C/i);
|
||||
assert.equal(tools.get("patch_tasks").parameters.properties.tasks.maxItems, 50);
|
||||
assert.equal(updateTask.properties.name.anyOf[0].maxLength, 200);
|
||||
assert.equal(updateTask.properties.description.anyOf[0].maxLength, 4_000);
|
||||
assert.equal(tools.get("rm_task").parameters.properties.ids.maxItems, 50);
|
||||
assert.equal(tools.get("get_task").parameters.properties.ids.maxItems, 50);
|
||||
|
||||
const setTool = tools.get("set_tasks");
|
||||
const validUnicode = setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: [{ name: "😀".repeat(200), description: "line one\nline two" }],
|
||||
});
|
||||
assert.doesNotThrow(() =>
|
||||
validateToolArguments(setTool, { name: setTool.name, arguments: validUnicode } as never),
|
||||
);
|
||||
const unsafeText = setTool.prepareArguments({ mode: "append", tasks: [{ name: "unsafe\u061cname" }] });
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: unsafeText } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("set_tasks preparation bounds hostile recursive input before validation", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
let nested: Record<string, unknown> = { name: "Leaf" };
|
||||
for (let depth = 0; depth < 10_000; depth += 1) nested = { name: `Level ${depth}`, children: [nested] };
|
||||
|
||||
const prepared = setTool.prepareArguments({ mode: "append", tasks: [nested] });
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: prepared } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
|
||||
const oversized = setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ name: `Task ${index}` })),
|
||||
});
|
||||
assert.equal(oversized.tasks.length, 51);
|
||||
assert.throws(
|
||||
() => validateToolArguments(setTool, { name: setTool.name, arguments: oversized } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
|
||||
const updateTool = tools.get("patch_tasks");
|
||||
const oversizedUpdate = updateTool.prepareArguments({
|
||||
tasks: Array.from({ length: 1_000 }, (_, index) => ({ id: index + 1 })),
|
||||
});
|
||||
assert.equal(oversizedUpdate.tasks.length, 51);
|
||||
assert.throws(
|
||||
() => validateToolArguments(updateTool, { name: updateTool.name, arguments: oversizedUpdate } as never),
|
||||
/validation failed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("strict task schemas normalize legacy optional arguments", () => {
|
||||
const { tools } = registeredTools();
|
||||
const setTool = tools.get("set_tasks");
|
||||
const updateTool = tools.get("patch_tasks");
|
||||
|
||||
assert.deepEqual(updateTool.parameters.properties.tasks.items.required, [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"status",
|
||||
"parentId",
|
||||
"position",
|
||||
]);
|
||||
assert.deepEqual(setTool.prepareArguments({
|
||||
mode: "append",
|
||||
tasks: [{ name: "Parent", children: [{ name: "Child" }] }],
|
||||
}), {
|
||||
mode: "append",
|
||||
tasks: [
|
||||
{
|
||||
name: "Parent",
|
||||
description: null,
|
||||
status: "pending",
|
||||
parentId: null,
|
||||
children: [
|
||||
{
|
||||
name: "Child",
|
||||
description: null,
|
||||
status: "pending",
|
||||
parentId: null,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
force: false,
|
||||
});
|
||||
assert.deepEqual(updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }), {
|
||||
tasks: [
|
||||
{
|
||||
id: 1,
|
||||
name: null,
|
||||
description: null,
|
||||
status: "done",
|
||||
parentId: -1,
|
||||
position: -1,
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(updateTool.prepareArguments({ tasks: [{ id: 1, parentId: null }] }).tasks[0].parentId, 0);
|
||||
|
||||
const validated = validateToolArguments(updateTool, {
|
||||
name: "patch_tasks",
|
||||
arguments: updateTool.prepareArguments({ tasks: [{ id: 1, status: "done" }] }),
|
||||
} as never);
|
||||
assert.equal(validated.tasks[0].parentId, -1);
|
||||
assert.equal(validated.tasks[0].position, -1);
|
||||
});
|
||||
|
||||
test("validated update sentinels preserve order and map legacy null parent to root", async () => {
|
||||
const harness = registeredTools();
|
||||
await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [
|
||||
{ name: "Parent", children: [{ name: "Child" }] },
|
||||
{ name: "Sibling" },
|
||||
],
|
||||
});
|
||||
|
||||
await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 3, status: "blocked" }] });
|
||||
assert.deepEqual(
|
||||
harness.state.list().filter((task) => task.parentId === null).map((task) => task.id),
|
||||
[1, 3],
|
||||
);
|
||||
|
||||
await execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 2, parentId: null }] });
|
||||
assert.equal(harness.state.get([2])[0]?.parentId, null);
|
||||
});
|
||||
|
||||
test("patch_tasks explains an auto-cleared empty tree without recreating tasks", async () => {
|
||||
const harness = registeredTools();
|
||||
|
||||
await assert.rejects(
|
||||
execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, status: "done" }] }),
|
||||
/Patch failed · 1 validation error\n- task tree is empty and may have auto-cleared after completion; do not recreate completed tasks\. Use set_tasks only for genuinely new or remaining work/,
|
||||
);
|
||||
assert.deepEqual(harness.state.list(), []);
|
||||
assert.equal(harness.changes(), 0);
|
||||
});
|
||||
|
||||
test("set_tasks appends optional-description tasks and persists a complete snapshot", async () => {
|
||||
const harness = registeredTools();
|
||||
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);
|
||||
assert.deepEqual(harness.autoClearRestarts, [false]);
|
||||
});
|
||||
|
||||
test("set_tasks atomically replaces or clears the tree and resets IDs", async () => {
|
||||
const harness = registeredTools();
|
||||
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 });
|
||||
assert.deepEqual(harness.autoClearRestarts, [false, true, true]);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
await assert.rejects(
|
||||
execute(harness.tools.get("patch_tasks"), { tasks: [{ id: 1, parentId: 2 }] }),
|
||||
/Patch failed · 1 validation error\n- task [12]: parentId creates a cycle/,
|
||||
);
|
||||
assert.deepEqual(harness.state.snapshot(), before);
|
||||
assert.equal(harness.changes(), 1);
|
||||
});
|
||||
|
||||
test("rm_task requires and reports a constrained removal reason", async () => {
|
||||
const harness = registeredTools();
|
||||
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"), {});
|
||||
assert.equal(list.content[0].text, "1 [pending] Parent\n└─ 2 [pending] Child");
|
||||
|
||||
const get = await execute(harness.tools.get("get_task"), { ids: [1, 2] });
|
||||
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);
|
||||
});
|
||||
|
||||
test("get_task exposes cumulative timing while list output stays compact", async () => {
|
||||
const startedAt = Date.UTC(2026, 0, 1);
|
||||
let now = startedAt;
|
||||
const harness = registeredTools(new TaskState(() => now));
|
||||
const added = await execute(harness.tools.get("set_tasks"), {
|
||||
mode: "append",
|
||||
tasks: [{ name: "Timed", status: "in-progress" }],
|
||||
});
|
||||
assert.equal(added.content[0].text, "Added 1 task: 1. 0/1 done.");
|
||||
assert.equal(added.details.snapshot.timings[0].activeStartedAt, startedAt);
|
||||
|
||||
now += 65_432;
|
||||
const list = await execute(harness.tools.get("list_task"), {});
|
||||
assert.equal(list.content[0].text, "1 [in-progress] Timed");
|
||||
|
||||
const active = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||
assert.deepEqual(active[0], {
|
||||
id: 1,
|
||||
name: "Timed",
|
||||
status: "in-progress",
|
||||
parentId: null,
|
||||
childrenIds: [],
|
||||
startedAt: new Date(startedAt).toISOString(),
|
||||
durationMs: 65_432,
|
||||
});
|
||||
|
||||
now += 568;
|
||||
const updated = await execute(harness.tools.get("patch_tasks"), {
|
||||
tasks: [{ id: 1, status: "blocked" }],
|
||||
});
|
||||
assert.equal(updated.content[0].text, "Updated 1 task: 1. 0/1 done.");
|
||||
|
||||
now += 10_000;
|
||||
const paused = JSON.parse((await execute(harness.tools.get("get_task"), { ids: [1] })).content[0].text);
|
||||
assert.equal(paused[0].startedAt, new Date(startedAt).toISOString());
|
||||
assert.equal(paused[0].stoppedAt, new Date(startedAt + 66_000).toISOString());
|
||||
assert.equal(paused[0].durationMs, 66_000);
|
||||
});
|
||||
@@ -0,0 +1,910 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { visibleWidth } from "@earendil-works/pi-tui";
|
||||
import { currentTaskLabel, currentTaskTokens } from "../src/herdr.ts";
|
||||
import { TaskState } from "../src/state.ts";
|
||||
import {
|
||||
formatDuration,
|
||||
renderProgressHeader,
|
||||
renderTaskLine,
|
||||
renderToolResult,
|
||||
renderTodoPanel,
|
||||
renderTree,
|
||||
renderWidgetTree,
|
||||
snapshotStats,
|
||||
} from "../src/ui.ts";
|
||||
import { clearTaskWidget, TODO_ANIMATION_INTERVAL_MS, updateTaskWidget } from "../src/widget.ts";
|
||||
|
||||
const plainTheme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => `~${text}~`,
|
||||
};
|
||||
|
||||
type TestTui = { requestRender(): void };
|
||||
type TestWidget = { render(width: number): string[]; dispose(): void };
|
||||
|
||||
function mountWidget(widget: unknown, tui: TestTui = { requestRender() {} }, theme: unknown = plainTheme): TestWidget {
|
||||
return (widget as (tui: TestTui, theme: unknown) => TestWidget)(tui, theme);
|
||||
}
|
||||
|
||||
function setSubagentsIntegration(integration: unknown): void {
|
||||
const globals = globalThis as typeof globalThis & { __pi_interactive_subagents?: unknown };
|
||||
if (integration === undefined) delete globals.__pi_interactive_subagents;
|
||||
else globals.__pi_interactive_subagents = integration;
|
||||
}
|
||||
|
||||
function withSubagentsIntegration<T>(integration: unknown, callback: () => T): T {
|
||||
const globals = globalThis as typeof globalThis & { __pi_interactive_subagents?: unknown };
|
||||
const previous = globals.__pi_interactive_subagents;
|
||||
setSubagentsIntegration(integration);
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
setSubagentsIntegration(previous);
|
||||
}
|
||||
}
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
function fixture(): TaskState {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{
|
||||
name: "Build auth",
|
||||
description: "Auth work",
|
||||
status: "in-progress",
|
||||
children: [
|
||||
{
|
||||
name: "Resolve token issue",
|
||||
description: "Investigate token validation and provider behavior.",
|
||||
status: "blocked",
|
||||
children: [{ name: "Inspect provider response", description: "Inspect response" }],
|
||||
},
|
||||
{ name: "Add middleware", description: "Middleware" },
|
||||
],
|
||||
},
|
||||
{ name: "Define schema", description: "Schema", status: "done" },
|
||||
]);
|
||||
return state;
|
||||
}
|
||||
|
||||
test("renders an adaptive progress bar and stays within width", () => {
|
||||
const stats = fixture().stats();
|
||||
const wide = stripAnsi(renderProgressHeader(stats, plainTheme as never, 40));
|
||||
assert.match(wide, /^ Todos 1\/5 · !1 ━{4}─{16}$/);
|
||||
assert.ok(visibleWidth(wide) <= 40);
|
||||
|
||||
const narrow = renderProgressHeader(stats, plainTheme as never, 12);
|
||||
assert.ok(visibleWidth(narrow) <= 12);
|
||||
assert.doesNotMatch(stripAnsi(narrow), /━|─/);
|
||||
});
|
||||
|
||||
test("renders lightweight connectors without a detached root continuation", () => {
|
||||
const output = renderTree(fixture().list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(output, [
|
||||
" ◉ 1. Build auth · 00:00",
|
||||
" ├─ ! 2. Resolve token issue",
|
||||
" │ └─ ○ 3. Inspect provider response",
|
||||
" └─ ○ 4. Add middleware",
|
||||
" ✓ 5. ~Define schema~",
|
||||
]);
|
||||
});
|
||||
|
||||
test("collapses completed widget subtrees while keeping list trees expanded", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{
|
||||
name: "Release",
|
||||
children: [
|
||||
{
|
||||
name: "Completed branch",
|
||||
children: [
|
||||
{ name: "Done one", status: "done" },
|
||||
{ name: "Done two", status: "done" },
|
||||
],
|
||||
},
|
||||
{ name: "Open branch", children: [{ name: "Open leaf" }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Pending parent",
|
||||
children: [{ name: "Finished child", status: "done" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const widgetLines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(widgetLines, [
|
||||
" ├─ I. ○ Release",
|
||||
" │ ├─ 1. ○ Completed branch · 2/2 done",
|
||||
" │ └─ 2. ○ Open branch",
|
||||
" │ └─ ○ Open leaf",
|
||||
" └─ II. ○ Pending parent · 1/1 done",
|
||||
]);
|
||||
|
||||
const fullTree = renderTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.ok(fullTree.some((line) => line.includes("Done one")));
|
||||
assert.ok(fullTree.some((line) => line.includes("Finished child")));
|
||||
|
||||
state.update([{ id: 3, status: "pending" }]);
|
||||
const reopened = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.ok(reopened.some((line) => line.includes("Done one")));
|
||||
assert.ok(reopened.some((line) => line.includes("Done two")));
|
||||
});
|
||||
|
||||
test("uses only the outline levels needed while leaving leaf tasks unlabeled", () => {
|
||||
const flatState = new TaskState(() => 0);
|
||||
flatState.add([{ name: "First leaf" }, { name: "Second leaf" }]);
|
||||
assert.deepEqual(renderWidgetTree(flatState.list(), plainTheme as never, 80).map(stripAnsi), [
|
||||
" ├─ ○ First leaf",
|
||||
" └─ ○ Second leaf",
|
||||
]);
|
||||
|
||||
const twoLevelState = new TaskState(() => 0);
|
||||
twoLevelState.add([
|
||||
{ name: "First group", children: [{ name: "First nested leaf" }] },
|
||||
{ name: "Second group", children: [{ name: "Second nested leaf" }] },
|
||||
]);
|
||||
assert.deepEqual(renderWidgetTree(twoLevelState.list(), plainTheme as never, 80).map(stripAnsi), [
|
||||
" ├─ 1. ○ First group",
|
||||
" │ └─ ○ First nested leaf",
|
||||
" └─ 2. ○ Second group",
|
||||
" └─ ○ Second nested leaf",
|
||||
]);
|
||||
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Category one" },
|
||||
{ name: "Category two" },
|
||||
{ name: "Category three" },
|
||||
{
|
||||
name: "Category four",
|
||||
children: [
|
||||
{ name: "First item", children: [{ name: "Leaf sub-item" }] },
|
||||
{ name: "Second item" },
|
||||
],
|
||||
},
|
||||
{ name: "Category five", children: [{ name: "Reset item numbering" }] },
|
||||
]);
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(lines, [
|
||||
" ├─ I. ○ Category one",
|
||||
" ├─ II. ○ Category two",
|
||||
" ├─ III. ○ Category three",
|
||||
" ├─ IV. ○ Category four",
|
||||
" │ ├─ 1. ○ First item",
|
||||
" │ │ └─ ○ Leaf sub-item",
|
||||
" │ └─ 2. ○ Second item",
|
||||
" └─ V. ○ Category five",
|
||||
" └─ 1. ○ Reset item numbering",
|
||||
]);
|
||||
|
||||
const narrow = renderWidgetTree(state.list(), plainTheme as never, 18);
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 18));
|
||||
|
||||
const statusPriority = renderWidgetTree(state.list(), plainTheme as never, 6).map(stripAnsi);
|
||||
assert.match(statusPriority[3] ?? "", /○/);
|
||||
const childStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 10).map(stripAnsi);
|
||||
assert.match(childStatusPriority[4] ?? "", /○/);
|
||||
const leafStatusPriority = renderWidgetTree(state.list(), plainTheme as never, 12).map(stripAnsi);
|
||||
assert.match(leafStatusPriority[5] ?? "", /○/);
|
||||
const listLeafStatusPriority = renderTree(state.list(), plainTheme as never, 12).map(stripAnsi);
|
||||
assert.match(listLeafStatusPriority[5] ?? "", /○/);
|
||||
|
||||
const timedState = new TaskState(() => 0);
|
||||
timedState.add([
|
||||
{ name: "Timed category", status: "in-progress", children: [{ name: "Timed item", status: "in-progress" }] },
|
||||
]);
|
||||
const timedRoot = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 15)[0] ?? "");
|
||||
const timedChild = stripAnsi(renderWidgetTree(timedState.list(), plainTheme as never, 18)[1] ?? "");
|
||||
assert.match(timedRoot, /◉/);
|
||||
assert.match(timedChild, /◉/);
|
||||
});
|
||||
|
||||
test("animates only in-progress widget circles while preserving narrow-width status markers", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Active", status: "in-progress" },
|
||||
{ name: "Pending" },
|
||||
{ name: "Blocked", status: "blocked" },
|
||||
{ name: "Done", status: "done" },
|
||||
]);
|
||||
|
||||
const orbitFrames = ["◐", "◓", "◑", "◒"];
|
||||
for (const [frame, icon] of orbitFrames.entries()) {
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80, " ", frame).map(stripAnsi);
|
||||
assert.match(lines[0] ?? "", new RegExp(icon));
|
||||
assert.match(lines[1] ?? "", /○/);
|
||||
assert.match(lines[2] ?? "", /!/);
|
||||
assert.match(lines[3] ?? "", /✓/);
|
||||
|
||||
const narrow = renderWidgetTree(state.list(), plainTheme as never, 6, " ", frame).map(stripAnsi);
|
||||
assert.match(narrow[0] ?? "", new RegExp(icon));
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 6));
|
||||
}
|
||||
|
||||
assert.match(stripAnsi(renderTree(state.list(), plainTheme as never, 80)[0] ?? ""), /◉/);
|
||||
});
|
||||
|
||||
test("renumbers widget labels after task reordering and reparenting", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([
|
||||
{ name: "Category A", children: [{ name: "A1" }, { name: "A2" }] },
|
||||
{ name: "Category B", children: [{ name: "B1" }] },
|
||||
]);
|
||||
state.update([
|
||||
{ id: 4, position: 0 },
|
||||
{ id: 3, parentId: 4, position: 0 },
|
||||
]);
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.deepEqual(lines, [
|
||||
" ├─ 1. ○ Category B",
|
||||
" │ ├─ ○ A2",
|
||||
" │ └─ ○ B1",
|
||||
" └─ 2. ○ Category A",
|
||||
" └─ ○ A1",
|
||||
]);
|
||||
});
|
||||
|
||||
test("caps the widget at twelve task rows with an honest overflow summary", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` })));
|
||||
|
||||
const lines = renderWidgetTree(state.list(), plainTheme as never, 60).map(stripAnsi);
|
||||
assert.equal(lines.length, 13);
|
||||
assert.match(lines[0] ?? "", /Task 1/);
|
||||
assert.match(lines[11] ?? "", /Task 12/);
|
||||
assert.equal(lines[12], " … 8 more tasks");
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 60));
|
||||
|
||||
const collapsedOverflow = new TaskState(() => 0);
|
||||
collapsedOverflow.add([
|
||||
...Array.from({ length: 12 }, (_, index) => ({ name: `Visible ${index + 1}` })),
|
||||
{
|
||||
name: "Hidden completed branch",
|
||||
children: [
|
||||
{ name: "Hidden done one", status: "done" as const },
|
||||
{ name: "Hidden done two", status: "done" as const },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const overflowLines = renderWidgetTree(collapsedOverflow.list(), plainTheme as never, 60).map(stripAnsi);
|
||||
assert.equal(overflowLines[12], " … 3 more tasks");
|
||||
});
|
||||
|
||||
test("hides a completed widget summary before squeezing the task name", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "A long parent task name", children: [{ name: "Done", status: "done" }] }]);
|
||||
|
||||
const line = renderWidgetTree(state.list(), plainTheme as never, 20)[0] ?? "";
|
||||
assert.ok(visibleWidth(line) <= 20);
|
||||
assert.doesNotMatch(stripAnsi(line), /done$/);
|
||||
});
|
||||
|
||||
test("formats compact timers and adds hours only when needed", () => {
|
||||
assert.equal(formatDuration(0), "00:00");
|
||||
assert.equal(formatDuration(65_432), "01:05");
|
||||
assert.equal(formatDuration(3_599_999), "59:59");
|
||||
assert.equal(formatDuration(3_600_000), "01:00:00");
|
||||
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
now = 65_432;
|
||||
const line = stripAnsi(renderTaskLine(state.get([1])[0]!, " ", plainTheme as never, 24));
|
||||
assert.match(line, / · 01:05$/);
|
||||
assert.ok(visibleWidth(line) <= 24);
|
||||
});
|
||||
|
||||
test("truncates every task row at narrow widths", () => {
|
||||
const lines = renderTree(fixture().list(), plainTheme as never, 20);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 20));
|
||||
assert.ok(lines.some((line) => stripAnsi(line).includes("...")));
|
||||
});
|
||||
|
||||
test("uses semantic icon colors and old-plugin text styling", () => {
|
||||
const calls: Array<{ color: string; text: string }> = [];
|
||||
const theme = {
|
||||
fg: (color: string, text: string) => {
|
||||
calls.push({ color, text });
|
||||
return text;
|
||||
},
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => `~${text}~`,
|
||||
};
|
||||
const tasks = fixture().list();
|
||||
for (const task of tasks) renderTaskLine(task, "", theme as never, 80);
|
||||
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "○"));
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text === "◉"));
|
||||
assert.ok(calls.some((call) => call.color === "error" && call.text === "!"));
|
||||
assert.ok(calls.some((call) => call.color === "success" && call.text === "✓"));
|
||||
assert.ok(calls.some((call) => call.color === "warning" && call.text === "Build auth"));
|
||||
assert.ok(calls.some((call) => call.color === "dim" && call.text === "~Define schema~"));
|
||||
assert.ok(!calls.some((call) => call.color === "error" && call.text === "Resolve token issue"));
|
||||
});
|
||||
|
||||
test("selects the most specific in-progress task and compresses parallel leaves", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{
|
||||
name: "Parent",
|
||||
description: "Parent",
|
||||
status: "in-progress",
|
||||
children: [{ name: "Child", description: "Child", status: "in-progress" }],
|
||||
},
|
||||
{ name: "Parallel", description: "Parallel", status: "in-progress" },
|
||||
]);
|
||||
assert.equal(currentTaskLabel(state.list()), "#2 Child +1");
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: "#2 Child",
|
||||
task_count: "+1",
|
||||
task_progress: "0/3",
|
||||
});
|
||||
});
|
||||
|
||||
test("uses blocked tasks only when nothing is in progress", () => {
|
||||
const state = new TaskState();
|
||||
state.add([
|
||||
{ name: "Blocked one", description: "One", status: "blocked" },
|
||||
{ name: "Blocked two", description: "Two", status: "blocked" },
|
||||
]);
|
||||
assert.equal(currentTaskLabel(state.list()), "#1 Blocked one +1");
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: "#1 Blocked one",
|
||||
task_count: "+1",
|
||||
task_progress: "0/2",
|
||||
});
|
||||
state.update([{ id: 1, status: "done" }, { id: 2, status: "done" }]);
|
||||
assert.equal(currentTaskLabel(state.list()), null);
|
||||
assert.deepEqual(currentTaskTokens(state.list()), {
|
||||
task: null,
|
||||
task_count: null,
|
||||
task_progress: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("clears sidebar progress for an empty task tree", () => {
|
||||
assert.deepEqual(currentTaskTokens([]), {
|
||||
task: null,
|
||||
task_count: null,
|
||||
task_progress: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("hides an empty widget and renders a populated one", () => {
|
||||
let widget: unknown = "unset";
|
||||
const ctx = {
|
||||
ui: {
|
||||
setWidget: (_id: string, content: unknown) => {
|
||||
widget = content;
|
||||
},
|
||||
},
|
||||
};
|
||||
const state = new TaskState();
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(widget, undefined);
|
||||
|
||||
state.add([{ name: "Task", description: "Task" }]);
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(typeof widget, "function");
|
||||
const component = mountWidget(widget);
|
||||
const lines = component.render(30).map(stripAnsi);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0] ?? "", /^Todos.*0\/1/);
|
||||
assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 30));
|
||||
});
|
||||
|
||||
test("renders compact unboxed solo panels at narrow widths without overflowing", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "A long task name", status: "blocked" }]);
|
||||
|
||||
for (const width of [80, 60]) {
|
||||
const lines = renderTodoPanel(state.list(), plainTheme as never, width).map(stripAnsi);
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= width));
|
||||
assert.match(lines[0] ?? "", /^Todos.*0\/1.*!1/);
|
||||
assert.match(lines[0] ?? "", /━|─/);
|
||||
assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/);
|
||||
}
|
||||
|
||||
const narrow = renderTodoPanel(state.list(), plainTheme as never, 20).map(stripAnsi);
|
||||
assert.ok(narrow.every((line) => visibleWidth(line) <= 20));
|
||||
assert.doesNotMatch(narrow.join("\n"), /[╭╮╰╯]/);
|
||||
assert.match(narrow[0] ?? "", /0\/1/);
|
||||
|
||||
const boundary = new TaskState(() => 0);
|
||||
boundary.add(Array.from({ length: 13 }, (_, index) => ({ name: `Boundary ${index + 1}` })));
|
||||
const boundaryLines = renderTodoPanel(boundary.list(), plainTheme as never, 80).map(stripAnsi);
|
||||
assert.equal(boundaryLines.length, 14);
|
||||
assert.match(boundaryLines[12] ?? "", /Boundary 12/);
|
||||
assert.match(boundaryLines[13] ?? "", /… 1 more task/);
|
||||
assert.ok(boundaryLines.every((line) => !line.includes("Boundary 13")));
|
||||
});
|
||||
|
||||
test("passes the current Pi theme unchanged to composed Subagents rendering", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
const theme = {
|
||||
fg: (_color: string, text: string) => text,
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => text,
|
||||
};
|
||||
let receivedTheme: unknown;
|
||||
const statusWidget = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
isClaimed: () => true,
|
||||
render: (_width: number, _maxBodyRows?: number, providerTheme?: unknown) => {
|
||||
receivedTheme = providerTheme;
|
||||
return ["subagents"];
|
||||
},
|
||||
claim: () => () => {},
|
||||
};
|
||||
withSubagentsIntegration({ statusWidget }, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget, { requestRender() {} }, theme);
|
||||
component.render(80);
|
||||
assert.equal(receivedTheme, theme);
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps an unboxed Subagents header plus capped body rows composed at 120 columns", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let receivedMaxBodyRows: number | undefined;
|
||||
const statusWidget = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 2,
|
||||
isClaimed: () => true,
|
||||
render: (_width: number, maxBodyRows?: number) => {
|
||||
receivedMaxBodyRows = maxBodyRows;
|
||||
return ["Subagents", "agent one", "agent two"];
|
||||
},
|
||||
claim: () => () => {},
|
||||
};
|
||||
withSubagentsIntegration({ statusWidget }, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
const lines = component.render(120).map(stripAnsi);
|
||||
assert.equal(receivedMaxBodyRows, 2);
|
||||
assert.ok(lines.some((line) => line.includes("Subagents")));
|
||||
assert.ok(lines.some((line) => line.includes("agent two")));
|
||||
assert.equal(lines[2], "");
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 120));
|
||||
assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/);
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("switches from side-by-side to stacked panels at the 120-column breakpoint", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let claimCount = 0;
|
||||
let releaseCount = 0;
|
||||
const renderCalls: Array<[number, number | undefined]> = [];
|
||||
const statusWidget = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 2,
|
||||
isClaimed: () => true,
|
||||
render: (width: number, maxBodyRows?: number) => {
|
||||
renderCalls.push([width, maxBodyRows]);
|
||||
return ["Subagents"];
|
||||
},
|
||||
claim: (_onChange: () => void) => {
|
||||
claimCount += 1;
|
||||
return () => {
|
||||
releaseCount += 1;
|
||||
};
|
||||
},
|
||||
};
|
||||
withSubagentsIntegration({ statusWidget }, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
const tui = { requestRender() {} };
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget, tui);
|
||||
const side = component.render(121).map(stripAnsi);
|
||||
assert.match(side[0] ?? "", /Todos.*Subagents/);
|
||||
assert.ok(side.every((line) => visibleWidth(line) === 121));
|
||||
assert.equal(side[1]?.slice(61), " ".repeat(60));
|
||||
assert.doesNotMatch(side.join("\n"), /[╭╮╰╯]/);
|
||||
|
||||
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Extra ${index + 1}` })));
|
||||
const unequal = component.render(121).map(stripAnsi);
|
||||
const rightPanel = unequal.map((line) => line.slice(61));
|
||||
assert.equal(rightPanel.at(-1), " ".repeat(60));
|
||||
assert.doesNotMatch(unequal.join("\n"), /[╭╮╰╯]/);
|
||||
|
||||
const stacked = component.render(120);
|
||||
assert.match(stripAnsi(stacked[0] ?? ""), /Todos/);
|
||||
const stackedSubagentsIndex = stacked.findIndex((line) => stripAnsi(line).includes("Subagents"));
|
||||
assert.ok(stackedSubagentsIndex > 0);
|
||||
assert.ok(stackedSubagentsIndex < stacked.length);
|
||||
assert.ok(stacked.every((line) => visibleWidth(line) <= 120));
|
||||
assert.equal(stacked[stackedSubagentsIndex - 1], "");
|
||||
assert.doesNotMatch(stacked.map(stripAnsi).join("\n"), /[╭╮╰╯]/);
|
||||
assert.deepEqual(renderCalls.map(([, maxRows]) => maxRows), [10, 10, 2]);
|
||||
assert.equal(claimCount, 1);
|
||||
|
||||
component.dispose();
|
||||
assert.equal(releaseCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to a full-width Todo panel for absent and old Subagents APIs", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
const tui = { requestRender() {} };
|
||||
const empty = {
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => [],
|
||||
claim: () => () => {},
|
||||
isClaimed: () => true,
|
||||
},
|
||||
};
|
||||
const malformed = {
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => new Array(1),
|
||||
claim: () => () => {},
|
||||
isClaimed: () => true,
|
||||
},
|
||||
};
|
||||
for (const integration of [undefined, { statusWidget: { render: () => [] } }, empty, malformed]) {
|
||||
withSubagentsIntegration(integration, () => {
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget, tui);
|
||||
const lines = component.render(80).map(stripAnsi);
|
||||
assert.equal(lines.length, 2);
|
||||
assert.match(lines[0] ?? "", /Todos/);
|
||||
assert.doesNotMatch(lines.join("\n"), /[╭╮╰╯]/);
|
||||
component.dispose();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("reacquires a claim after the provider invalidates its callback", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let claims = 0;
|
||||
let releases = 0;
|
||||
let providerCallback: (() => void) | undefined;
|
||||
let providerClaimed = false;
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => ["subagents"],
|
||||
isClaimed: (onChange: () => void) => providerClaimed && providerCallback === onChange,
|
||||
claim: (onChange: () => void) => {
|
||||
claims += 1;
|
||||
providerCallback = onChange;
|
||||
providerClaimed = true;
|
||||
return () => {
|
||||
releases += 1;
|
||||
if (providerCallback === onChange) {
|
||||
providerCallback = undefined;
|
||||
providerClaimed = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
component.render(80);
|
||||
assert.equal(claims, 1);
|
||||
component.render(80);
|
||||
assert.equal(claims, 1);
|
||||
|
||||
providerClaimed = false;
|
||||
providerCallback = undefined;
|
||||
component.render(80);
|
||||
assert.equal(claims, 2);
|
||||
assert.equal(releases, 1);
|
||||
component.dispose();
|
||||
assert.equal(releases, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mask Todo rendering failures as optional integration failures", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => ["subagents"],
|
||||
isClaimed: () => true,
|
||||
claim: () => () => {},
|
||||
},
|
||||
}, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
const failure = new Error("Todo renderer failed");
|
||||
const badTheme = {
|
||||
fg: () => {
|
||||
throw failure;
|
||||
},
|
||||
bold: (text: string) => text,
|
||||
strikethrough: (text: string) => text,
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget, { requestRender() {} }, badTheme);
|
||||
assert.throws(() => component.render(80), (error) => error === failure);
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not compose or claim Subagents outside the TUI mode", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let claims = 0;
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
render: () => ["Subagents"],
|
||||
claim: () => {
|
||||
claims += 1;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "rpc" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
const lines = component.render(80).map(stripAnsi);
|
||||
assert.equal(claims, 0);
|
||||
assert.ok(lines.every((line) => !line.includes("Subagents")));
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("shares ten stacked body rows and keeps Todo overflow honest", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add(Array.from({ length: 20 }, (_, index) => ({ name: `Task ${index + 1}` })));
|
||||
const calls: Array<[number, number | undefined]> = [];
|
||||
const statusWidget = {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 3,
|
||||
isClaimed: () => true,
|
||||
render: (width: number, maxBodyRows?: number) => {
|
||||
calls.push([width, maxBodyRows]);
|
||||
return ["one", "two", "three"];
|
||||
},
|
||||
claim: () => () => {},
|
||||
};
|
||||
withSubagentsIntegration({ statusWidget }, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
const lines = component.render(120).map(stripAnsi);
|
||||
assert.deepEqual(calls, [[120, 3]]);
|
||||
assert.ok(lines.some((line) => line.includes("… 14 more tasks")));
|
||||
assert.ok(lines.every((line) => visibleWidth(line) <= 120));
|
||||
component.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("releases a Subagents claim when Todos hide or clear", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
let releases = 0;
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
isClaimed: () => true,
|
||||
render: () => ["subagents"],
|
||||
claim: () => () => {
|
||||
releases += 1;
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => (widget = content) },
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const component = mountWidget(widget);
|
||||
component.render(80);
|
||||
updateTaskWidget(state, ctx as never, false);
|
||||
assert.equal(releases, 1);
|
||||
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
const second = mountWidget(widget);
|
||||
second.render(80);
|
||||
state.remove([1]);
|
||||
updateTaskWidget(state, ctx as never, true);
|
||||
assert.equal(releases, 2);
|
||||
second.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles zero and one-column panel widths cleanly", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo" }]);
|
||||
assert.deepEqual(renderTodoPanel(state.list(), plainTheme as never, 0), []);
|
||||
const oneColumn = renderTodoPanel(state.list(), plainTheme as never, 1);
|
||||
assert.ok(oneColumn.length > 0);
|
||||
assert.ok(oneColumn.every((line) => visibleWidth(line) <= 1));
|
||||
});
|
||||
|
||||
test("ignores stale widget generations when Pi replaces the widget", () => {
|
||||
const state = new TaskState(() => 0);
|
||||
state.add([{ name: "Todo", status: "in-progress" }]);
|
||||
let claims = 0;
|
||||
let releases = 0;
|
||||
withSubagentsIntegration({
|
||||
statusWidget: {
|
||||
hasContent: () => true,
|
||||
getBodyRowCount: () => 1,
|
||||
isClaimed: () => true,
|
||||
render: () => ["subagents"],
|
||||
claim: () => {
|
||||
claims += 1;
|
||||
return () => {
|
||||
releases += 1;
|
||||
};
|
||||
},
|
||||
},
|
||||
}, () => {
|
||||
const widgets: unknown[] = [];
|
||||
const ctx = {
|
||||
mode: "tui" as const,
|
||||
ui: { setWidget: (_id: string, content: unknown) => widgets.push(content) },
|
||||
};
|
||||
const tui = { requestRender() {} };
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
const first = mountWidget(widgets[0], tui);
|
||||
first.render(80);
|
||||
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
const second = mountWidget(widgets[1], tui);
|
||||
second.render(80);
|
||||
assert.equal(claims, 2);
|
||||
assert.equal(releases, 1);
|
||||
assert.deepEqual(first.render(80), []);
|
||||
|
||||
first.dispose();
|
||||
assert.equal(releases, 1);
|
||||
clearTaskWidget(ctx as never);
|
||||
assert.equal(releases, 2);
|
||||
second.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test("animates active todos twice per second", () => {
|
||||
assert.equal(TODO_ANIMATION_INTERVAL_MS, 500);
|
||||
});
|
||||
|
||||
test("live widgets request rerenders and dispose their interval", async () => {
|
||||
let now = 0;
|
||||
const state = new TaskState(() => now);
|
||||
state.add([{ name: "Timed", status: "in-progress" }]);
|
||||
|
||||
let widget: unknown;
|
||||
const ctx = {
|
||||
ui: {
|
||||
setWidget: (_id: string, content: unknown) => {
|
||||
widget = content;
|
||||
},
|
||||
},
|
||||
};
|
||||
updateTaskWidget(state, ctx as never, true, 5);
|
||||
|
||||
let renderRequests = 0;
|
||||
const observedFrames = new Set<string>();
|
||||
let component: TestWidget;
|
||||
component = mountWidget(widget, {
|
||||
requestRender() {
|
||||
renderRequests += 1;
|
||||
const frame = stripAnsi(component.render(40)[1] ?? "").match(/[◐◓◑◒]/)?.[0];
|
||||
if (frame !== undefined) observedFrames.add(frame);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /◐.* · 00:00/);
|
||||
now = 65_000;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.ok(renderRequests > 0);
|
||||
assert.ok(observedFrames.size > 0);
|
||||
assert.match(stripAnsi(component.render(40)[1] ?? ""), /[◐◓◑◒].* · 01:05/);
|
||||
} finally {
|
||||
component.dispose();
|
||||
}
|
||||
|
||||
const requestsAfterDispose = renderRequests;
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
assert.equal(renderRequests, requestsAfterDispose);
|
||||
});
|
||||
|
||||
test("tool results collapse summaries and expand themed details", () => {
|
||||
const state = fixture();
|
||||
const details = { action: "list" as const, snapshot: state.snapshot(), affected: state.list() };
|
||||
const result = { content: [{ type: "text", text: "list" }], details };
|
||||
const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(collapsed, ["✓ 5 tasks · 1/5 done"]);
|
||||
|
||||
const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.equal(expanded.length, 6);
|
||||
assert.match(expanded[1] ?? "", /Build auth/);
|
||||
});
|
||||
|
||||
test("tool errors show one summary line until expanded", () => {
|
||||
const result = {
|
||||
content: [{ type: "text", text: "Update failed · 2 validation errors\n- first\n- second" }],
|
||||
isError: true,
|
||||
};
|
||||
const collapsed = renderToolResult(result, false, plainTheme as never).render(80).map(stripAnsi);
|
||||
assert.deepEqual(collapsed, ["✗ Update failed · 2 validation errors"]);
|
||||
const expanded = renderToolResult(result, true, plainTheme as never).render(80).map(stripAnsi);
|
||||
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,
|
||||
pending: 2,
|
||||
inProgress: 1,
|
||||
blocked: 1,
|
||||
done: 1,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user