Files
harness-tools/extensions/todo-herdr/tests/state.test.ts
T

553 lines
19 KiB
TypeScript

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"]]);
});