553 lines
19 KiB
TypeScript
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"]]);
|
|
});
|