feat: pi-lgtm -- LGTM sign-off layer on pi-tasks

- Strip: TaskExecute, TaskOutput, TaskStop, process-tracker, subagent RPC, settings menu
- Add done_criterion (required, falsifiable) to TaskCreate
- Block status=completed in TaskUpdate -- must use /lgtm
- Add lgtm_ask tool: evidence + 2 failure modes + evidence_vs_failures + remaining_uncertainty
- Add /lgtm command: human-only sign-off with stored evidence review
- Persist all lgtm_ask fields in task.metadata for async review
- Widget shows 👀 for pending_approval tasks
- Update README, package.json author

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wassname
2026-04-17 05:41:18 +08:00
parent 46cca7a734
commit 8ea225d119
13 changed files with 610 additions and 2596 deletions
+81 -56
View File
@@ -13,8 +13,9 @@ describe("auto-clear: on_task_complete mode", () => {
});
it("does not clear completed task before REMINDER_INTERVAL turns", () => {
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
// Turns 2, 3, 4 — not enough
@@ -26,8 +27,9 @@ describe("auto-clear: on_task_complete mode", () => {
});
it("clears completed task after REMINDER_INTERVAL turns", () => {
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
// Turn 5 = turn 1 + 4 (REMINDER_INTERVAL)
@@ -37,13 +39,15 @@ describe("auto-clear: on_task_complete mode", () => {
});
it("clears each task independently based on its own completion turn", () => {
store.create("Task A", "Desc");
store.create("Task B", "Desc");
store.create("Task A", "Desc", "done");
store.create("Task B", "Desc", "done");
store.update("1", { status: "completed" });
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
store.update("2", { status: "completed" });
store.update("2", { pending_approval: true });
store.complete("2");
manager.trackCompletion("2", 3);
// Turn 5: Task A expires (1+4), Task B still lingers (3+4=7)
@@ -57,11 +61,12 @@ describe("auto-clear: on_task_complete mode", () => {
});
it("does not clear pending or in_progress tasks", () => {
store.create("Pending", "Desc");
store.create("In Progress", "Desc");
store.create("Completed", "Desc");
store.create("Pending", "Desc", "done");
store.create("In Progress", "Desc", "done");
store.create("Completed", "Desc", "done");
store.update("2", { status: "in_progress" });
store.update("3", { status: "completed" });
store.update("3", { pending_approval: true });
store.complete("3");
manager.trackCompletion("3", 1);
manager.onTurnStart(5);
@@ -71,10 +76,11 @@ describe("auto-clear: on_task_complete mode", () => {
});
it("cleans up dependency edges when auto-clearing", () => {
store.create("Blocker", "Desc");
store.create("Blocked", "Desc");
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
store.update("1", { status: "completed" });
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
manager.onTurnStart(5);
@@ -83,8 +89,9 @@ describe("auto-clear: on_task_complete mode", () => {
});
it("returns true when tasks are cleared", () => {
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
expect(manager.onTurnStart(4)).toBe(false);
@@ -102,9 +109,10 @@ describe("auto-clear: on_list_complete mode", () => {
});
it("does not clear when some tasks are still pending", () => {
store.create("Done", "Desc");
store.create("Pending", "Desc");
store.update("1", { status: "completed" });
store.create("Done", "Desc", "done");
store.create("Pending", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
for (let turn = 2; turn <= 10; turn++) {
@@ -115,10 +123,12 @@ describe("auto-clear: on_list_complete mode", () => {
});
it("does not clear immediately when all tasks complete", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.update("1", { status: "completed" });
store.update("2", { status: "completed" });
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
store.update("2", { pending_approval: true });
store.complete("2");
manager.trackCompletion("2", 1);
// Turns 2-4: not enough
@@ -129,10 +139,12 @@ describe("auto-clear: on_list_complete mode", () => {
});
it("clears all completed tasks after REMINDER_INTERVAL turns when all are completed", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.update("1", { status: "completed" });
store.update("2", { status: "completed" });
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
store.update("2", { pending_approval: true });
store.complete("2");
manager.trackCompletion("2", 1);
manager.onTurnStart(5);
@@ -140,14 +152,15 @@ describe("auto-clear: on_list_complete mode", () => {
});
it("resets countdown when a new task is created before REMINDER_INTERVAL", () => {
store.create("A", "Desc");
store.update("1", { status: "completed" });
store.create("A", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
// Turn 3: new task created — reset countdown
manager.onTurnStart(3);
manager.resetBatchCountdown();
store.create("B", "Desc");
store.create("B", "Desc", "done");
// Turn 5 would have cleared, but countdown was reset at turn 3
manager.onTurnStart(5);
@@ -155,10 +168,12 @@ describe("auto-clear: on_list_complete mode", () => {
});
it("resets countdown when a task goes back to in_progress", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.update("1", { status: "completed" });
store.update("2", { status: "completed" });
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
store.update("2", { pending_approval: true });
store.complete("2");
manager.trackCompletion("2", 1);
// Turn 3: task 2 goes back to in_progress
@@ -172,8 +187,9 @@ describe("auto-clear: on_list_complete mode", () => {
});
it("returns true when tasks are cleared", () => {
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
expect(manager.onTurnStart(4)).toBe(false);
@@ -191,10 +207,12 @@ describe("auto-clear: never mode", () => {
});
it("never clears completed tasks regardless of turns", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.update("1", { status: "completed" });
store.update("2", { status: "completed" });
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
store.update("2", { pending_approval: true });
store.complete("2");
manager.trackCompletion("1", 1);
manager.trackCompletion("2", 1);
@@ -205,8 +223,9 @@ describe("auto-clear: never mode", () => {
});
it("trackCompletion is a no-op", () => {
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
manager.onTurnStart(100);
@@ -220,8 +239,9 @@ describe("auto-clear: dynamic mode switching", () => {
let mode: AutoClearMode = "never";
const manager = new AutoClearManager(() => store, () => mode);
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
// Track in never mode — no-op
manager.trackCompletion("1", 1);
@@ -241,13 +261,14 @@ describe("auto-clear: store getter (session switch)", () => {
let store = new TaskStore();
const manager = new AutoClearManager(() => store, () => "on_task_complete");
store.create("Old task", "Desc");
store.update("1", { status: "completed" });
store.create("Old task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
// Simulate session switch — swap store
store = new TaskStore();
store.create("New task", "Desc");
store.create("New task", "Desc", "done");
manager.reset();
// Old task tracking was reset, new store has no completed tasks
@@ -262,8 +283,9 @@ describe("auto-clear: store getter (session switch)", () => {
// Swap to new store with a completed task
store = new TaskStore();
store.create("Task in new store", "Desc");
store.update("1", { status: "completed" });
store.create("Task in new store", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
manager.onTurnStart(5);
@@ -276,8 +298,9 @@ describe("auto-clear: reset (new session)", () => {
const store = new TaskStore();
const manager = new AutoClearManager(() => store, () => "on_task_complete");
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
// Simulate /new — reset before the delay expires
@@ -292,8 +315,9 @@ describe("auto-clear: reset (new session)", () => {
const store = new TaskStore();
const manager = new AutoClearManager(() => store, () => "on_list_complete");
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
// Simulate /new — reset before the delay expires
@@ -308,8 +332,9 @@ describe("auto-clear: reset (new session)", () => {
const store = new TaskStore();
const manager = new AutoClearManager(() => store, () => "on_task_complete");
store.create("Task", "Desc");
store.update("1", { status: "completed" });
store.create("Task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
manager.trackCompletion("1", 1);
manager.reset();
-178
View File
@@ -1,178 +0,0 @@
import { spawn } from "node:child_process";
import { beforeEach, describe, expect, it } from "vitest";
import { ProcessTracker } from "../src/process-tracker.js";
describe("ProcessTracker", () => {
let tracker: ProcessTracker;
beforeEach(() => {
tracker = new ProcessTracker();
});
it("returns undefined for untracked task", () => {
expect(tracker.getOutput("999")).toBeUndefined();
expect(tracker.getProcess("999")).toBeUndefined();
});
it("tracks a process and captures stdout", async () => {
const proc = spawn("echo", ["hello world"]);
tracker.track("1", proc, "echo hello world");
await new Promise<void>((r) => proc.on("close", r));
// Small delay for event processing
await new Promise((r) => setTimeout(r, 50));
const out = tracker.getOutput("1");
expect(out).toBeDefined();
expect(out!.output).toContain("hello world");
expect(out!.status).toBe("completed");
expect(out!.exitCode).toBe(0);
expect(out!.command).toBe("echo hello world");
expect(out!.startedAt).toBeGreaterThan(0);
expect(out!.completedAt).toBeGreaterThan(0);
});
it("tracks a process and captures stderr", async () => {
const proc = spawn("sh", ["-c", "echo errdata >&2"]);
tracker.track("1", proc);
await new Promise<void>((r) => proc.on("close", r));
await new Promise((r) => setTimeout(r, 50));
const out = tracker.getOutput("1");
expect(out!.output).toContain("errdata");
});
it("reports error status for non-zero exit", async () => {
const proc = spawn("sh", ["-c", "exit 42"]);
tracker.track("1", proc);
await new Promise<void>((r) => proc.on("close", r));
await new Promise((r) => setTimeout(r, 50));
const out = tracker.getOutput("1");
expect(out!.status).toBe("error");
expect(out!.exitCode).toBe(42);
});
it("waitForCompletion returns immediately for already-completed process", async () => {
const proc = spawn("echo", ["done"]);
tracker.track("1", proc);
await new Promise<void>((r) => proc.on("close", r));
await new Promise((r) => setTimeout(r, 50));
const out = await tracker.waitForCompletion("1", 1000);
expect(out).toBeDefined();
expect(out!.status).toBe("completed");
});
it("waitForCompletion returns undefined for untracked task", async () => {
const out = await tracker.waitForCompletion("999", 1000);
expect(out).toBeUndefined();
});
it("waitForCompletion waits for process to finish", async () => {
const proc = spawn("sh", ["-c", "sleep 0.1 && echo waited"]);
tracker.track("1", proc);
const out = await tracker.waitForCompletion("1", 5000);
expect(out).toBeDefined();
expect(out!.output).toContain("waited");
expect(out!.status).toBe("completed");
});
it("waitForCompletion times out if process takes too long", async () => {
const proc = spawn("sleep", ["10"]);
tracker.track("1", proc);
const out = await tracker.waitForCompletion("1", 200);
expect(out).toBeDefined();
expect(out!.status).toBe("running");
// Cleanup
proc.kill("SIGKILL");
});
it("stop sends SIGTERM and marks process stopped", async () => {
const proc = spawn("sleep", ["10"]);
tracker.track("1", proc);
// Small delay to let process start
await new Promise((r) => setTimeout(r, 50));
const stopped = await tracker.stop("1");
expect(stopped).toBe(true);
const out = tracker.getOutput("1");
expect(out!.status).toBe("stopped");
expect(out!.completedAt).toBeGreaterThan(0);
});
it("stop returns false for untracked task", async () => {
expect(await tracker.stop("999")).toBe(false);
});
it("stop returns false for already-completed process", async () => {
const proc = spawn("echo", ["quick"]);
tracker.track("1", proc);
await new Promise<void>((r) => proc.on("close", r));
await new Promise((r) => setTimeout(r, 50));
expect(await tracker.stop("1")).toBe(false);
});
it("getProcess returns the background process record", () => {
const proc = spawn("echo", ["test"]);
tracker.track("1", proc, "echo test");
const bp = tracker.getProcess("1");
expect(bp).toBeDefined();
expect(bp!.taskId).toBe("1");
expect(bp!.command).toBe("echo test");
expect(bp!.status).toBe("running");
expect(bp!.pid).toBeGreaterThan(0);
proc.kill("SIGKILL");
});
it("handles process error event", async () => {
const proc = spawn("nonexistent-binary-that-does-not-exist-xyz");
tracker.track("1", proc);
await new Promise<void>((r) => proc.on("error", () => r()));
await new Promise((r) => setTimeout(r, 50));
const out = tracker.getOutput("1");
expect(out!.status).toBe("error");
expect(out!.output).toContain("Process error:");
});
it("waitForCompletion respects abort signal", async () => {
const proc = spawn("sleep", ["10"]);
tracker.track("1", proc);
const ac = new AbortController();
setTimeout(() => ac.abort(), 100);
const out = await tracker.waitForCompletion("1", 60000, ac.signal);
expect(out).toBeDefined();
expect(out!.status).toBe("running");
proc.kill("SIGKILL");
});
it("notifies waiters when process completes", async () => {
const proc = spawn("sh", ["-c", "sleep 0.1"]);
tracker.track("1", proc);
const [r1, r2] = await Promise.all([
tracker.waitForCompletion("1", 5000),
tracker.waitForCompletion("1", 5000),
]);
expect(r1!.status).toBe("completed");
expect(r2!.status).toBe("completed");
});
});
-893
View File
@@ -1,893 +0,0 @@
/**
* Tests for task-subagent integration: TaskExecute tool, completion listener,
* auto-cascade, and widget agent ID display.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import initExtension from "../src/index.js";
import { TaskStore } from "../src/task-store.js";
import { TaskWidget, type Theme, type UICtx } from "../src/ui/task-widget.js";
// Force in-memory task store for all integration tests — prevents file-backed
// store from loading stale tasks across test instances.
beforeEach(() => { process.env.PI_TASKS = "off"; });
afterEach(() => { delete process.env.PI_TASKS; });
// ---- Mock pi ----
type MockEventBus = {
on: (channel: string, handler: (data: unknown) => void) => () => void;
emit: (channel: string, data: unknown) => void;
};
/** Minimal mock of ExtensionAPI with events, tool capture, and event hooks. */
function mockPi() {
const tools = new Map<string, any>();
const commands = new Map<string, any>();
const eventHandlers = new Map<string, ((data: unknown) => void)[]>();
const lifecycleHandlers = new Map<string, ((...args: any[]) => any)[]>();
const pi = {
registerTool(def: any) { tools.set(def.name, def); },
registerCommand(name: string, def: any) { commands.set(name, def); },
on(event: string, handler: any) {
if (!lifecycleHandlers.has(event)) lifecycleHandlers.set(event, []);
lifecycleHandlers.get(event)!.push(handler);
},
events: {
emit(channel: string, data: unknown) {
for (const h of eventHandlers.get(channel) ?? []) h(data);
},
on(channel: string, handler: (data: unknown) => void) {
if (!eventHandlers.has(channel)) eventHandlers.set(channel, []);
eventHandlers.get(channel)!.push(handler);
return () => {
const arr = eventHandlers.get(channel);
if (arr) eventHandlers.set(channel, arr.filter(h => h !== handler));
};
},
},
sendUserMessage: vi.fn(),
};
return {
pi,
tools,
commands,
/** Execute a registered tool by name. */
async executeTool(name: string, params: any, ctx?: any) {
const tool = tools.get(name);
if (!tool) throw new Error(`Tool ${name} not registered`);
return tool.execute("call-1", params, undefined, undefined, ctx ?? mockCtx());
},
/** Fire lifecycle event handlers (turn_start, tool_result, etc.) */
async fireLifecycle(event: string, ...args: any[]) {
for (const h of lifecycleHandlers.get(event) ?? []) {
await h(...args);
}
},
/** Emit an event on pi.events (simulates subagent extension). */
emitEvent(channel: string, data: unknown) {
pi.events.emit(channel, data);
},
};
}
/** Minimal mock ExtensionContext. */
function mockCtx() {
return {
model: { id: "test-model", name: "Test" },
modelRegistry: {},
ui: {
setWidget: vi.fn(),
setStatus: vi.fn(),
notify: vi.fn(),
},
};
}
// ---- Mock subagents extension (RPC responders) ----
/** Simulates the @tintinweb/pi-subagents extension: responds to ping + spawn RPCs and emits ready. */
function installSubagentsMock(pi: { events: MockEventBus }, opts?: { spawnError?: string }) {
let idCounter = 0;
const spawned: Array<{ id: string; type: string; prompt: string; options: any }> = [];
const stopped: string[] = [];
// Respond to ping — reply on scoped channel
const unsubPing = pi.events.on("subagents:rpc:ping", (data: unknown) => {
const { requestId } = data as { requestId: string };
pi.events.emit(`subagents:rpc:ping:reply:${requestId}`, { success: true, data: { version: 2 } });
});
// Respond to spawn — reply on scoped channel
const unsubSpawn = pi.events.on("subagents:rpc:spawn", (data: unknown) => {
const { requestId, type, prompt, options } = data as {
requestId: string; type: string; prompt: string; options?: any;
};
if (opts?.spawnError) {
pi.events.emit(`subagents:rpc:spawn:reply:${requestId}`, { success: false, error: opts.spawnError });
return;
}
const id = `agent-${++idCounter}`;
spawned.push({ id, type, prompt, options });
pi.events.emit(`subagents:rpc:spawn:reply:${requestId}`, { success: true, data: { id } });
});
// Respond to stop — reply on scoped channel
const unsubStop = pi.events.on("subagents:rpc:stop", (data: unknown) => {
const { requestId, agentId } = data as { requestId: string; agentId: string };
const known = spawned.some(s => s.id === agentId);
if (known) {
stopped.push(agentId);
pi.events.emit(`subagents:rpc:stop:reply:${requestId}`, { success: true });
} else {
pi.events.emit(`subagents:rpc:stop:reply:${requestId}`, { success: false, error: "Agent not found" });
}
});
// Broadcast readiness
pi.events.emit("subagents:ready", {});
return {
spawned,
stopped,
unsub() { unsubPing(); unsubSpawn(); unsubStop(); },
};
}
// ---- Tests ----
describe("TaskExecute", () => {
let mock: ReturnType<typeof mockPi>;
let rpc: ReturnType<typeof installSubagentsMock>;
beforeEach(() => {
mock = mockPi();
// Install mock BEFORE init so ping reply is received during extension init
rpc = installSubagentsMock(mock.pi);
initExtension(mock.pi as any);
});
afterEach(() => {
rpc.unsub();
});
it("is registered as a tool", () => {
expect(mock.tools.has("TaskExecute")).toBe(true);
});
it("returns error when subagent extension is not loaded", async () => {
// Re-init without mock to simulate missing extension
const freshMock = mockPi();
initExtension(freshMock.pi as any);
await freshMock.executeTool("TaskCreate", {
subject: "Test task",
description: "Do something",
agentType: "general-purpose",
});
const result = await freshMock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("Subagent execution is currently unavailable");
});
it("rejects non-existent tasks", async () => {
const result = await mock.executeTool("TaskExecute", { task_ids: ["999"] });
expect(result.content[0].text).toContain("#999: not found");
});
it("rejects tasks without agentType", async () => {
await mock.executeTool("TaskCreate", {
subject: "No agent type",
description: "Plain task",
});
const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("#1: no agentType set");
});
it("rejects non-pending tasks", async () => {
await mock.executeTool("TaskCreate", {
subject: "Already started",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskUpdate", { taskId: "1", status: "in_progress" });
const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("#1: not pending");
});
it("rejects tasks with unresolved blockers", async () => {
await mock.executeTool("TaskCreate", {
subject: "Blocker",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskCreate", {
subject: "Blocked",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] });
const result = await mock.executeTool("TaskExecute", { task_ids: ["2"] });
expect(result.content[0].text).toContain("#2: blocked by #1");
});
it("spawns agent for valid task and updates metadata", async () => {
await mock.executeTool("TaskCreate", {
subject: "Run tests",
description: "Run the test suite",
agentType: "general-purpose",
});
const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("Launched 1 agent");
expect(result.content[0].text).toContain("#1 → agent agent-1");
// Verify the RPC responder was called
expect(rpc.spawned).toHaveLength(1);
expect(rpc.spawned[0].type).toBe("general-purpose");
expect(rpc.spawned[0].prompt).toContain("Run the test suite");
expect(rpc.spawned[0].options.isBackground).toBe(true);
});
it("passes additional_context and max_turns to spawned agents", async () => {
await mock.executeTool("TaskCreate", {
subject: "Explore codebase",
description: "Find all API endpoints",
agentType: "Explore",
});
await mock.executeTool("TaskExecute", {
task_ids: ["1"],
additional_context: "Focus on REST endpoints only",
max_turns: 10,
});
expect(rpc.spawned[0].prompt).toContain("Focus on REST endpoints only");
expect(rpc.spawned[0].options.maxTurns).toBe(10);
});
it("allows executing tasks whose blockers are all completed", async () => {
await mock.executeTool("TaskCreate", {
subject: "Blocker",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskCreate", {
subject: "Dependent",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] });
await mock.executeTool("TaskUpdate", { taskId: "1", status: "completed" });
const result = await mock.executeTool("TaskExecute", { task_ids: ["2"] });
expect(result.content[0].text).toContain("Launched 1 agent");
});
it("handles mixed valid and invalid tasks in one call", async () => {
await mock.executeTool("TaskCreate", {
subject: "Valid",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskCreate", {
subject: "No agent type",
description: "Desc",
});
const result = await mock.executeTool("TaskExecute", { task_ids: ["1", "2", "999"] });
const text = result.content[0].text;
expect(text).toContain("Launched 1 agent");
expect(text).toContain("#2: no agentType set");
expect(text).toContain("#999: not found");
});
});
describe("TaskExecute via ready broadcast", () => {
it("detects subagents when ready fires after tasks init", async () => {
// Init tasks WITHOUT the mock — subagents not available yet
const mock = mockPi();
initExtension(mock.pi as any);
// Now install the mock (simulates subagents loading later) and broadcast ready
const rpc = installSubagentsMock(mock.pi);
// Create a task and execute — should work because ready was received
await mock.executeTool("TaskCreate", {
subject: "Late-loaded test",
description: "Desc",
agentType: "general-purpose",
});
const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("Launched 1 agent");
expect(rpc.spawned).toHaveLength(1);
rpc.unsub();
});
});
describe("Completion listener", () => {
let mock: ReturnType<typeof mockPi>;
let rpc: ReturnType<typeof installSubagentsMock>;
beforeEach(() => {
mock = mockPi();
rpc = installSubagentsMock(mock.pi);
initExtension(mock.pi as any);
});
afterEach(() => {
rpc.unsub();
});
it("marks task completed on subagents:completed event", async () => {
await mock.executeTool("TaskCreate", {
subject: "Agent task",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
// Simulate agent completion
mock.emitEvent("subagents:completed", { id: "agent-1" });
const result = await mock.executeTool("TaskGet", { taskId: "1" });
expect(result.content[0].text).toContain("Status: completed");
});
it("reverts task to pending on subagents:failed event", async () => {
await mock.executeTool("TaskCreate", {
subject: "Failing task",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
// Simulate agent failure
mock.emitEvent("subagents:failed", { id: "agent-1", error: "Out of turns", status: "error" });
const result = await mock.executeTool("TaskGet", { taskId: "1" });
expect(result.content[0].text).toContain("Status: pending");
});
it("ignores events for unknown agent IDs", async () => {
await mock.executeTool("TaskCreate", {
subject: "Unrelated",
description: "Desc",
});
// Should not throw or modify anything
mock.emitEvent("subagents:completed", { id: "unknown-agent" });
mock.emitEvent("subagents:failed", { id: "unknown-agent", error: "boom", status: "error" });
const result = await mock.executeTool("TaskGet", { taskId: "1" });
expect(result.content[0].text).toContain("Status: pending");
});
});
describe("Auto-cascade", () => {
let mock: ReturnType<typeof mockPi>;
let rpc: ReturnType<typeof installSubagentsMock>;
beforeEach(() => {
mock = mockPi();
rpc = installSubagentsMock(mock.pi);
initExtension(mock.pi as any);
});
afterEach(() => {
rpc.unsub();
});
it("does NOT cascade when auto-cascade is off (default)", async () => {
// Create A → B chain
await mock.executeTool("TaskCreate", {
subject: "Task A",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskCreate", {
subject: "Task B",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] });
// Execute A
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(rpc.spawned).toHaveLength(1);
// Complete A
mock.emitEvent("subagents:completed", { id: "agent-1" });
// B should NOT have been auto-started
expect(rpc.spawned).toHaveLength(1);
// B should still be pending
const result = await mock.executeTool("TaskGet", { taskId: "2" });
expect(result.content[0].text).toContain("Status: pending");
});
it("does NOT cascade on failure (branch stops)", async () => {
await mock.executeTool("TaskCreate", {
subject: "Task A",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskCreate", {
subject: "Task B",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] });
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
mock.emitEvent("subagents:failed", { id: "agent-1", error: "crashed", status: "error" });
// B should not start
expect(rpc.spawned).toHaveLength(1);
const result = await mock.executeTool("TaskGet", { taskId: "2" });
expect(result.content[0].text).toContain("Status: pending");
});
it("tasks without agentType are not cascaded even if unblocked", async () => {
await mock.executeTool("TaskCreate", {
subject: "Agent task",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskCreate", {
subject: "Manual task",
description: "Desc",
// No agentType — manual
});
await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] });
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
mock.emitEvent("subagents:completed", { id: "agent-1" });
// Manual task should stay pending
expect(rpc.spawned).toHaveLength(1);
});
});
describe("Standalone operation (no subagents extension)", () => {
let mock: ReturnType<typeof mockPi>;
beforeEach(() => {
// Init WITHOUT installSubagentsMock — no subagents extension present
mock = mockPi();
initExtension(mock.pi as any);
});
it("all core task tools are registered", () => {
for (const name of ["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskExecute"]) {
expect(mock.tools.has(name)).toBe(true);
}
});
it("TaskCreate works without subagents", async () => {
const result = await mock.executeTool("TaskCreate", {
subject: "Write tests",
description: "Add unit tests for the parser",
});
expect(result.content[0].text).toContain("Write tests");
});
it("TaskList works without subagents", async () => {
await mock.executeTool("TaskCreate", { subject: "A", description: "desc" });
await mock.executeTool("TaskCreate", { subject: "B", description: "desc" });
const result = await mock.executeTool("TaskList", {});
expect(result.content[0].text).toContain("#1");
expect(result.content[0].text).toContain("#2");
});
it("TaskGet works without subagents", async () => {
await mock.executeTool("TaskCreate", { subject: "Read me", description: "details here" });
const result = await mock.executeTool("TaskGet", { taskId: "1" });
expect(result.content[0].text).toContain("Read me");
expect(result.content[0].text).toContain("details here");
});
it("TaskUpdate works without subagents", async () => {
await mock.executeTool("TaskCreate", { subject: "Update me", description: "desc" });
await mock.executeTool("TaskUpdate", { taskId: "1", status: "in_progress" });
const result = await mock.executeTool("TaskGet", { taskId: "1" });
expect(result.content[0].text).toContain("in_progress");
});
it("TaskExecute gracefully refuses without subagents", async () => {
await mock.executeTool("TaskCreate", {
subject: "Agent task",
description: "desc",
agentType: "general-purpose",
});
const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("Subagent execution is currently unavailable");
});
it("subagents lifecycle events are silently ignored without mapped agents", () => {
// These should not throw even though no subagents extension is loaded
mock.emitEvent("subagents:completed", { id: "ghost-agent", result: "done" });
mock.emitEvent("subagents:failed", { id: "ghost-agent", error: "boom", status: "error" });
// No crash = pass
});
it("task dependencies work without subagents", async () => {
await mock.executeTool("TaskCreate", { subject: "First", description: "desc" });
await mock.executeTool("TaskCreate", { subject: "Second", description: "desc" });
await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] });
const result = await mock.executeTool("TaskGet", { taskId: "2" });
expect(result.content[0].text).toContain("Blocked by");
expect(result.content[0].text).toContain("#1");
});
});
describe("RPC protocol correctness", () => {
it("ping uses scoped reply channel (not shared channel)", () => {
const mock = mockPi();
const emitted: Array<{ channel: string; data: unknown }> = [];
const origEmit = mock.pi.events.emit.bind(mock.pi.events);
mock.pi.events.emit = (channel: string, data: unknown) => {
emitted.push({ channel, data });
origEmit(channel, data);
};
initExtension(mock.pi as any);
// Find the ping emit
const pingEmit = emitted.find(e => e.channel === "subagents:rpc:ping");
expect(pingEmit).toBeDefined();
const pingData = pingEmit!.data as { requestId: string };
expect(pingData.requestId).toBeDefined();
expect(typeof pingData.requestId).toBe("string");
});
it("spawn reply cleans up listener and timer on success", async () => {
const mock = mockPi();
const rpc = installSubagentsMock(mock.pi);
initExtension(mock.pi as any);
await mock.executeTool("TaskCreate", {
subject: "Test",
description: "desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(rpc.spawned).toHaveLength(1);
// Second spawn should get a fresh requestId (not conflict with first)
await mock.executeTool("TaskCreate", {
subject: "Test 2",
description: "desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskExecute", { task_ids: ["2"] });
expect(rpc.spawned).toHaveLength(2);
expect(rpc.spawned[0].id).not.toBe(rpc.spawned[1].id);
rpc.unsub();
});
it("spawn RPC rejects on timeout when no responder exists", async () => {
const mock = mockPi();
// Install ping handler (for version check) but no spawn handler
installVersionedMock(mock.pi, 2);
initExtension(mock.pi as any);
await mock.executeTool("TaskCreate", {
subject: "Timeout test",
description: "desc",
agentType: "general-purpose",
});
// spawnSubagent has a 30s timeout — we'll advance timers
vi.useFakeTimers();
const execPromise = mock.executeTool("TaskExecute", { task_ids: ["1"] });
await vi.advanceTimersByTimeAsync(31000);
const result = await execPromise;
expect(result.content[0].text).toContain("timeout");
vi.useRealTimers();
});
it("ready broadcast sets subagentsAvailable even after init", async () => {
const mock = mockPi();
initExtension(mock.pi as any);
// Initially no subagents
await mock.executeTool("TaskCreate", {
subject: "Test",
description: "desc",
agentType: "general-purpose",
});
let result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("Subagent execution is currently unavailable");
// Reset task status
await mock.executeTool("TaskUpdate", { taskId: "1", status: "pending" });
// Late subagents extension broadcasts ready
const rpc = installSubagentsMock(mock.pi);
result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("Launched 1 agent");
rpc.unsub();
});
it("spawn RPC rejects with error message from server", async () => {
const mock = mockPi();
installSubagentsMock(mock.pi, { spawnError: "No active session" });
initExtension(mock.pi as any);
await mock.executeTool("TaskCreate", {
subject: "Err test",
description: "desc",
agentType: "general-purpose",
});
const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("No active session");
});
it("stop RPC resolves on success", async () => {
const mock = mockPi();
const rpc = installSubagentsMock(mock.pi);
initExtension(mock.pi as any);
// Spawn a task so we have an agent to stop
await mock.executeTool("TaskCreate", {
subject: "Stoppable",
description: "desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(rpc.spawned).toHaveLength(1);
const result = await mock.executeTool("TaskStop", { task_id: "1" });
expect(result.content[0].text).toContain("stopped successfully");
expect(rpc.stopped).toContain("agent-1");
rpc.unsub();
});
it("stop RPC returns false on error (agent not found) without throwing", async () => {
const mock = mockPi();
const rpc = installSubagentsMock(mock.pi);
initExtension(mock.pi as any);
// Create and execute a task, then simulate agent already gone
await mock.executeTool("TaskCreate", {
subject: "Ghost",
description: "desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
// Clear spawned list so the mock's stop handler won't find the agent
rpc.spawned.length = 0;
// TaskStop should still succeed (stopSubagent catches the error)
const result = await mock.executeTool("TaskStop", { task_id: "1" });
expect(result.content[0].text).toContain("stopped successfully");
rpc.unsub();
});
it("stop RPC returns false on timeout without throwing", async () => {
const mock = mockPi();
initExtension(mock.pi as any);
// Mark subagents as available via ready broadcast, but no stop handler installed
mock.pi.events.emit("subagents:ready", {});
await mock.executeTool("TaskCreate", {
subject: "Timeout stop",
description: "desc",
agentType: "general-purpose",
});
// Manually set task as in_progress with an agentId (no spawn handler)
await mock.executeTool("TaskUpdate", {
taskId: "1",
status: "in_progress",
metadata: { agentType: "general-purpose", agentId: "ghost-agent" },
});
vi.useFakeTimers();
const stopPromise = mock.executeTool("TaskStop", { task_id: "1" });
await vi.advanceTimersByTimeAsync(11000);
// Should resolve (not throw) — stopSubagent catches timeout
const result = await stopPromise;
expect(result.content[0].text).toContain("stopped successfully");
vi.useRealTimers();
});
});
/** Install a ping-only mock with a specific protocol version (or no version for v1). */
function installVersionedMock(pi: { events: MockEventBus }, version?: number) {
const unsubPing = pi.events.on("subagents:rpc:ping", (data: unknown) => {
const { requestId } = data as { requestId: string };
if (version !== undefined) {
pi.events.emit(`subagents:rpc:ping:reply:${requestId}`, { success: true, data: { version } });
} else {
// v1 handler — no envelope, no version
pi.events.emit(`subagents:rpc:ping:reply:${requestId}`, {});
}
});
pi.events.emit("subagents:ready", {});
return { unsub() { unsubPing(); } };
}
describe("Protocol version mismatch", () => {
it("matching version — no warning", async () => {
const mock = mockPi();
installVersionedMock(mock.pi, 2);
initExtension(mock.pi as any);
// No warning on before_agent_start
const ctx = mockCtx();
await mock.fireLifecycle("before_agent_start", {}, ctx);
expect(ctx.ui.notify).not.toHaveBeenCalled();
});
it("old handler (no version) — warns about pi-subagents", async () => {
const mock = mockPi();
installVersionedMock(mock.pi); // no version = v1
initExtension(mock.pi as any);
const ctx = mockCtx();
await mock.fireLifecycle("before_agent_start", {}, ctx);
expect(ctx.ui.notify).toHaveBeenCalledWith(
expect.stringContaining("pi-subagents is outdated"),
"warning",
);
});
it("handler ahead (v3) — warns about pi-tasks", async () => {
const mock = mockPi();
installVersionedMock(mock.pi, 3);
initExtension(mock.pi as any);
const ctx = mockCtx();
await mock.fireLifecycle("before_agent_start", {}, ctx);
expect(ctx.ui.notify).toHaveBeenCalledWith(
expect.stringContaining("pi-tasks is outdated"),
"warning",
);
});
it("handler behind (v1) — warns about pi-subagents", async () => {
const mock = mockPi();
installVersionedMock(mock.pi, 1);
initExtension(mock.pi as any);
const ctx = mockCtx();
await mock.fireLifecycle("before_agent_start", {}, ctx);
expect(ctx.ui.notify).toHaveBeenCalledWith(
expect.stringContaining("pi-subagents is outdated"),
"warning",
);
});
it("warning shown only once", async () => {
const mock = mockPi();
installVersionedMock(mock.pi); // v1 — triggers warning
initExtension(mock.pi as any);
const ctx1 = mockCtx();
await mock.fireLifecycle("before_agent_start", {}, ctx1);
expect(ctx1.ui.notify).toHaveBeenCalledOnce();
const ctx2 = mockCtx();
await mock.fireLifecycle("before_agent_start", {}, ctx2);
expect(ctx2.ui.notify).not.toHaveBeenCalled();
});
});
describe("Widget agent ID display", () => {
let store: TaskStore;
let widget: TaskWidget;
let ui: ReturnType<typeof mockUICtx>;
function mockUICtx() {
const state = {
widgets: new Map<string, any>(),
statuses: new Map<string, string | undefined>(),
};
const ctx: UICtx = {
setWidget(key, content, options) { state.widgets.set(key, { content, options }); },
setStatus(key, text) { state.statuses.set(key, text); },
};
return { ctx, state };
}
function mockTheme(): Theme {
return {
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
strikethrough: (text: string) => `~~${text}~~`,
};
}
function renderWidget(state: ReturnType<typeof mockUICtx>["state"]): string[] {
const entry = state.widgets.get("tasks");
if (!entry?.content) return [];
const theme = mockTheme();
const tui = { terminal: { columns: 200 } };
return entry.content(tui, theme).render();
}
beforeEach(() => {
vi.useFakeTimers();
store = new TaskStore();
widget = new TaskWidget(store);
ui = mockUICtx();
widget.setUICtx(ui.ctx);
});
afterEach(() => {
widget.dispose();
vi.useRealTimers();
});
it("shows agent ID for active agent-backed tasks", () => {
store.create("Agent task", "Desc", "Running tests", { agentType: "general-purpose", agentId: "abc1234567890" });
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
const lines = renderWidget(ui.state);
expect(lines[1]).toContain("agent abc12");
expect(lines[1]).toContain("Running tests");
});
it("shows agent ID for non-active in_progress agent-backed tasks", () => {
store.create("Agent task", "Desc", undefined, { agentType: "general-purpose", agentId: "xyz9876543210" });
store.update("1", { status: "in_progress" });
// NOT calling setActiveTask — simulates external agent management
widget.update();
const lines = renderWidget(ui.state);
expect(lines[1]).toContain("agent xyz98");
expect(lines[1]).toContain("Agent task");
});
it("does not show agent ID for tasks without agentId", () => {
store.create("Manual task", "Desc");
store.update("1", { status: "in_progress" });
widget.update();
const lines = renderWidget(ui.state);
expect(lines[1]).not.toContain("agent");
expect(lines[1]).toContain("Manual task");
});
it("does not show agent ID for pending tasks", () => {
store.create("Pending agent task", "Desc", undefined, { agentType: "general-purpose", agentId: "abc12345" });
widget.update();
const lines = renderWidget(ui.state);
expect(lines[1]).not.toContain("agent abc");
});
it("does not show agent ID for completed tasks", () => {
store.create("Done", "Desc", undefined, { agentType: "general-purpose", agentId: "abc12345" });
store.update("1", { status: "completed" });
widget.update();
const lines = renderWidget(ui.state);
expect(lines[1]).not.toContain("agent abc");
});
});
+112 -74
View File
@@ -4,6 +4,13 @@ import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskStore } from "../src/task-store.js";
// Helper: create a task and set pending_approval so complete() works
function createAndApprove(store: TaskStore, subject: string) {
const task = store.create(subject, "Desc", "done criterion");
store.update(task.id, { pending_approval: true });
return task;
}
describe("TaskStore (in-memory)", () => {
let store: TaskStore;
@@ -12,25 +19,27 @@ describe("TaskStore (in-memory)", () => {
});
it("creates tasks with auto-incrementing IDs", () => {
const t1 = store.create("First task", "Description 1");
const t2 = store.create("Second task", "Description 2");
const t1 = store.create("First task", "Description 1", "criterion 1");
const t2 = store.create("Second task", "Description 2", "criterion 2");
expect(t1.id).toBe("1");
expect(t2.id).toBe("2");
expect(t1.status).toBe("pending");
expect(t1.subject).toBe("First task");
expect(t1.description).toBe("Description 1");
expect(t1.done_criterion).toBe("criterion 1");
expect(t1.pending_approval).toBe(false);
});
it("creates tasks with optional fields", () => {
const t = store.create("Task", "Desc", "Running task", { key: "value" });
const t = store.create("Task", "Desc", "done criterion", "Running task", { key: "value" });
expect(t.activeForm).toBe("Running task");
expect(t.metadata).toEqual({ key: "value" });
});
it("gets a task by ID", () => {
store.create("Test", "Desc");
store.create("Test", "Desc", "done");
const task = store.get("1");
expect(task).toBeDefined();
@@ -42,16 +51,16 @@ describe("TaskStore (in-memory)", () => {
});
it("lists all tasks sorted by ID", () => {
store.create("Task 3", "Desc");
store.create("Task 1", "Desc");
store.create("Task 2", "Desc");
store.create("Task 3", "Desc", "done");
store.create("Task 1", "Desc", "done");
store.create("Task 2", "Desc", "done");
const tasks = store.list();
expect(tasks.map(t => t.id)).toEqual(["1", "2", "3"]);
});
it("updates task status", () => {
store.create("Test", "Desc");
store.create("Test", "Desc", "done");
const { task, changedFields } = store.update("1", { status: "in_progress" });
expect(task!.status).toBe("in_progress");
@@ -59,7 +68,7 @@ describe("TaskStore (in-memory)", () => {
});
it("updates multiple fields at once", () => {
store.create("Test", "Desc");
store.create("Test", "Desc", "done");
const { changedFields } = store.update("1", {
subject: "Updated subject",
description: "Updated desc",
@@ -76,7 +85,7 @@ describe("TaskStore (in-memory)", () => {
});
it("deletes a task with status: deleted", () => {
store.create("Test", "Desc");
store.create("Test", "Desc", "done");
const { changedFields } = store.update("1", { status: "deleted" });
expect(changedFields).toEqual(["deleted"]);
@@ -85,16 +94,16 @@ describe("TaskStore (in-memory)", () => {
});
it("preserves ID counter after deletion", () => {
store.create("Task 1", "Desc");
store.create("Task 2", "Desc");
store.create("Task 1", "Desc", "done");
store.create("Task 2", "Desc", "done");
store.update("1", { status: "deleted" });
const t3 = store.create("Task 3", "Desc");
const t3 = store.create("Task 3", "Desc", "done");
expect(t3.id).toBe("3"); // Not "1" — counter continues
});
it("merges metadata with null key deletion", () => {
store.create("Test", "Desc", undefined, { a: 1, b: 2, c: 3 });
store.create("Test", "Desc", "done", undefined, { a: 1, b: 2, c: 3 });
store.update("1", { metadata: { b: null, d: 4 } });
const task = store.get("1")!;
@@ -102,8 +111,8 @@ describe("TaskStore (in-memory)", () => {
});
it("sets up bidirectional blocks via addBlocks", () => {
store.create("Blocker", "Desc");
store.create("Blocked", "Desc");
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
@@ -114,8 +123,8 @@ describe("TaskStore (in-memory)", () => {
});
it("sets up bidirectional blocks via addBlockedBy", () => {
store.create("Blocker", "Desc");
store.create("Blocked", "Desc");
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("2", { addBlockedBy: ["1"] });
@@ -126,8 +135,8 @@ describe("TaskStore (in-memory)", () => {
});
it("does not duplicate dependency edges", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
store.update("1", { addBlocks: ["2"] }); // duplicate
@@ -137,8 +146,8 @@ describe("TaskStore (in-memory)", () => {
});
it("cleans up dependency edges on deletion", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
store.update("1", { status: "deleted" });
@@ -148,9 +157,9 @@ describe("TaskStore (in-memory)", () => {
});
it("clears completed tasks", () => {
store.create("Completed", "Desc");
store.create("Pending", "Desc");
store.update("1", { status: "completed" });
createAndApprove(store, "Completed");
store.create("Pending", "Desc", "done");
store.complete("1");
const count = store.clearCompleted();
@@ -159,21 +168,41 @@ describe("TaskStore (in-memory)", () => {
expect(store.list()[0].id).toBe("2");
});
it("throws on update status=completed (must use /lgtm)", () => {
store.create("Test", "Desc", "done");
expect(() => store.update("1", { status: "completed" as any })).toThrow("Use /lgtm");
});
it("returns not found for update on non-existent task", () => {
const { task, changedFields } = store.update("999", { status: "completed" });
const { task, changedFields } = store.update("999", { status: "in_progress" });
expect(task).toBeUndefined();
expect(changedFields).toEqual([]);
});
it("complete() requires pending_approval", () => {
store.create("Test", "Desc", "done");
expect(() => store.complete("1")).toThrow("lgtm_ask");
});
it("complete() works when pending_approval=true", () => {
createAndApprove(store, "Test");
const task = store.complete("1");
expect(task.status).toBe("completed");
});
it("complete() throws on non-existent task", () => {
expect(() => store.complete("999")).toThrow("not found");
});
it("delete method works", () => {
store.create("Test", "Desc");
store.create("Test", "Desc", "done");
expect(store.delete("1")).toBe(true);
expect(store.delete("1")).toBe(false); // already deleted
expect(store.list()).toHaveLength(0);
});
it("creates tasks with metadata via TaskCreate", () => {
const t = store.create("With meta", "Desc", undefined, { pr: "123", reviewer: "alice" });
const t = store.create("With meta", "Desc", "done", undefined, { pr: "123", reviewer: "alice" });
expect(t.metadata).toEqual({ pr: "123", reviewer: "alice" });
const retrieved = store.get("1")!;
@@ -181,8 +210,8 @@ describe("TaskStore (in-memory)", () => {
});
it("allows circular dependencies with warning", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
const { warnings } = store.update("2", { addBlocks: ["1"] });
@@ -192,57 +221,67 @@ describe("TaskStore (in-memory)", () => {
});
it("allows self-dependency with warning", () => {
store.create("Self", "Desc");
store.create("Self", "Desc", "done");
const { warnings } = store.update("1", { addBlocks: ["1"] });
expect(store.get("1")!.blocks).toContain("1");
expect(warnings).toContain("#1 blocks itself");
});
it("stores dangling edge IDs with warning", () => {
store.create("Real", "Desc");
store.create("Real", "Desc", "done");
const { warnings } = store.update("1", { addBlocks: ["9999"] });
expect(store.get("1")!.blocks).toContain("9999");
expect(warnings).toContain("#9999 does not exist");
});
it("returns no warnings for valid dependencies", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
const { warnings } = store.update("1", { addBlocks: ["2"] });
expect(warnings).toEqual([]);
});
it("accepts whitespace-only subjects (matches Claude Code)", () => {
const t = store.create(" ", "Desc");
const t = store.create(" ", "Desc", "done");
expect(t.subject).toBe(" ");
});
it("updates activeForm field", () => {
store.create("Test", "Desc");
store.create("Test", "Desc", "done");
const { changedFields } = store.update("1", { activeForm: "Running tests" });
expect(changedFields).toContain("activeForm");
expect(store.get("1")!.activeForm).toBe("Running tests");
});
it("updates description field", () => {
store.create("Test", "Original desc");
store.create("Test", "Original desc", "done");
const { changedFields } = store.update("1", { description: "Updated desc" });
expect(changedFields).toContain("description");
expect(store.get("1")!.description).toBe("Updated desc");
});
it("updates done_criterion field", () => {
store.create("Test", "Desc", "original criterion");
const { changedFields } = store.update("1", { done_criterion: "updated criterion" });
expect(changedFields).toContain("done_criterion");
expect(store.get("1")!.done_criterion).toBe("updated criterion");
});
it("returns empty changedFields when updating non-existent task", () => {
const { task, changedFields, warnings } = store.update("999", { status: "completed" });
const { task, changedFields, warnings } = store.update("999", { status: "in_progress" });
expect(task).toBeUndefined();
expect(changedFields).toEqual([]);
expect(warnings).toEqual([]);
});
it("clearCompleted cleans up dependency edges", () => {
store.create("Blocker", "Desc");
store.create("Blocked", "Desc");
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
store.update("1", { status: "completed" });
createAndApprove(store, "dummy"); // need task 1 to have pending_approval
// Actually set pending_approval on task 1
store.update("1", { pending_approval: true });
store.complete("1");
store.clearCompleted();
@@ -251,9 +290,9 @@ describe("TaskStore (in-memory)", () => {
});
it("handles multiple addBlocks in one call", () => {
store.create("Blocker", "Desc");
store.create("B1", "Desc");
store.create("B2", "Desc");
store.create("Blocker", "Desc", "done");
store.create("B1", "Desc", "done");
store.create("B2", "Desc", "done");
store.update("1", { addBlocks: ["2", "3"] });
@@ -263,44 +302,42 @@ describe("TaskStore (in-memory)", () => {
});
it("addBlockedBy warns on self-dependency", () => {
store.create("Self", "Desc");
store.create("Self", "Desc", "done");
const { warnings } = store.update("1", { addBlockedBy: ["1"] });
expect(store.get("1")!.blockedBy).toContain("1");
expect(warnings).toContain("#1 blocks itself");
});
it("addBlockedBy warns on dangling ref", () => {
store.create("Real", "Desc");
store.create("Real", "Desc", "done");
const { warnings } = store.update("1", { addBlockedBy: ["9999"] });
expect(store.get("1")!.blockedBy).toContain("9999");
expect(warnings).toContain("#9999 does not exist");
});
it("addBlockedBy warns on cycle", () => {
store.create("A", "Desc");
store.create("B", "Desc");
store.create("A", "Desc", "done");
store.create("B", "Desc", "done");
store.update("1", { addBlocks: ["2"] });
const { warnings } = store.update("1", { addBlockedBy: ["2"] });
expect(warnings).toContain("cycle: #1 and #2 block each other");
});
it("clearCompleted returns 0 when no completed tasks", () => {
store.create("Pending", "Desc");
store.create("Pending", "Desc", "done");
expect(store.clearCompleted()).toBe(0);
});
it("list sorts pending → in_progress → completed with all three present", () => {
store.create("Pending task", "Desc");
store.create("Completed task", "Desc");
store.create("In-progress task", "Desc");
store.create("Another pending", "Desc");
store.create("Pending task", "Desc", "done");
createAndApprove(store, "Completed task");
store.create("In-progress task", "Desc", "done");
store.create("Another pending", "Desc", "done");
store.update("2", { status: "completed" });
store.complete("2");
store.update("3", { status: "in_progress" });
const tasks = store.list();
// Store returns by ID; TaskList tool sorts by status group
// Here we verify the raw list order (by ID), then test status-grouped sort
const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
const sorted = [...tasks].sort((a, b) => {
const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
@@ -319,7 +356,6 @@ describe("TaskStore (file-backed)", () => {
const filePath = join(tasksDir, `${testListId}.json`);
afterEach(() => {
// Clean up test file
try { rmSync(filePath); } catch { /* */ }
try { rmSync(filePath + ".lock"); } catch { /* */ }
try { rmSync(filePath + ".tmp"); } catch { /* */ }
@@ -327,9 +363,8 @@ describe("TaskStore (file-backed)", () => {
it("persists tasks to disk", () => {
const store1 = new TaskStore(testListId);
store1.create("Persistent task", "Should survive reload");
store1.create("Persistent task", "Should survive reload", "done");
// Create a new store instance pointing to same file
const store2 = new TaskStore(testListId);
const tasks = store2.list();
@@ -339,7 +374,7 @@ describe("TaskStore (file-backed)", () => {
it("persists in_progress updates to disk", () => {
const store1 = new TaskStore(testListId);
store1.create("Task", "Desc");
store1.create("Task", "Desc", "done");
store1.update("1", { status: "in_progress" });
const store2 = new TaskStore(testListId);
@@ -348,9 +383,10 @@ describe("TaskStore (file-backed)", () => {
it("persists completed tasks to disk", () => {
const store1 = new TaskStore(testListId);
store1.create("Done task", "Desc");
store1.create("Pending task", "Desc");
store1.update("1", { status: "completed" });
store1.create("Done task", "Desc", "done");
store1.create("Pending task", "Desc", "done");
store1.update("1", { pending_approval: true });
store1.complete("1");
const store2 = new TaskStore(testListId);
expect(store2.get("1")).toBeDefined();
@@ -361,11 +397,12 @@ describe("TaskStore (file-backed)", () => {
it("restores all tasks across instances", () => {
const store1 = new TaskStore(testListId);
store1.create("Pending", "Desc");
store1.create("In progress", "Desc");
store1.create("Done", "Desc");
store1.create("Pending", "Desc", "done");
store1.create("In progress", "Desc", "done");
store1.create("Done", "Desc", "done");
store1.update("2", { status: "in_progress" });
store1.update("3", { status: "completed" });
store1.update("3", { pending_approval: true });
store1.complete("3");
const store2 = new TaskStore(testListId);
const tasks = store2.list();
@@ -377,11 +414,11 @@ describe("TaskStore (file-backed)", () => {
it("persists ID counter across instances", () => {
const store1 = new TaskStore(testListId);
store1.create("Task 1", "Desc");
store1.create("Task 2", "Desc");
store1.create("Task 1", "Desc", "done");
store1.create("Task 2", "Desc", "done");
const store2 = new TaskStore(testListId);
const t3 = store2.create("Task 3", "Desc");
const t3 = store2.create("Task 3", "Desc", "done");
expect(t3.id).toBe("3");
});
});
@@ -397,7 +434,7 @@ describe("TaskStore (absolute path)", () => {
it("accepts absolute path and persists tasks", () => {
const store1 = new TaskStore(absFilePath);
store1.create("Abs path task", "Desc");
store1.create("Abs path task", "Desc", "done");
const store2 = new TaskStore(absFilePath);
expect(store2.list()).toHaveLength(1);
@@ -406,9 +443,10 @@ describe("TaskStore (absolute path)", () => {
it("persists completed tasks when using absolute path", () => {
const store1 = new TaskStore(absFilePath);
store1.create("Pending", "Desc");
store1.create("Completed", "Desc");
store1.update("2", { status: "completed" });
store1.create("Pending", "Desc", "done");
store1.create("Completed", "Desc", "done");
store1.update("2", { pending_approval: true });
store1.complete("2");
const raw = JSON.parse(readFileSync(absFilePath, "utf-8"));
expect(raw.tasks).toHaveLength(2);
+40 -36
View File
@@ -68,7 +68,7 @@ describe("TaskWidget", () => {
});
it("renders pending tasks with ◻ icon", () => {
store.create("Do something", "Desc");
store.create("Do something", "Desc", "done");
widget.update();
const lines = renderWidget(ui.state);
@@ -80,7 +80,7 @@ describe("TaskWidget", () => {
});
it("renders in-progress tasks with ◼ icon", () => {
store.create("Working on it", "Desc");
store.create("Working on it", "Desc", "done");
store.update("1", { status: "in_progress" });
widget.update();
@@ -90,8 +90,9 @@ describe("TaskWidget", () => {
});
it("renders completed tasks with ✔ icon and strikethrough", () => {
store.create("Done task", "Desc");
store.update("1", { status: "completed" });
store.create("Done task", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
widget.update();
const lines = renderWidget(ui.state);
@@ -100,7 +101,7 @@ describe("TaskWidget", () => {
});
it("renders active tasks with spinner icon", () => {
store.create("Running thing", "Desc", "Processing data");
store.create("Running thing", "Desc", "done criterion", "Processing data");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -112,8 +113,8 @@ describe("TaskWidget", () => {
});
it("shows blocked-by info for pending tasks", () => {
store.create("Blocker", "Desc");
store.create("Blocked", "Desc");
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("2", { addBlockedBy: ["1"] });
widget.update();
@@ -123,10 +124,11 @@ describe("TaskWidget", () => {
});
it("hides completed blockers in blocked-by suffix", () => {
store.create("Blocker", "Desc");
store.create("Blocked", "Desc");
store.create("Blocker", "Desc", "done");
store.create("Blocked", "Desc", "done");
store.update("2", { addBlockedBy: ["1"] });
store.update("1", { status: "completed" });
store.update("1", { pending_approval: true });
store.complete("1");
widget.update();
const lines = renderWidget(ui.state);
@@ -135,10 +137,11 @@ describe("TaskWidget", () => {
});
it("shows status summary in header", () => {
store.create("Task A", "Desc");
store.create("Task B", "Desc");
store.create("Task C", "Desc");
store.update("1", { status: "completed" });
store.create("Task A", "Desc", "done");
store.create("Task B", "Desc", "done");
store.create("Task C", "Desc", "done");
store.update("1", { pending_approval: true });
store.complete("1");
store.update("2", { status: "in_progress" });
widget.update();
@@ -150,7 +153,7 @@ describe("TaskWidget", () => {
});
it("clears widget when all tasks are deleted", () => {
store.create("Task", "Desc");
store.create("Task", "Desc", "done");
widget.update();
expect(ui.state.widgets.get("tasks")?.content).toBeDefined();
@@ -172,7 +175,7 @@ describe("TaskWidget", () => {
});
it("tracks token usage for active tasks", () => {
store.create("Active task", "Desc", "Running");
store.create("Active task", "Desc", "done criterion", "Running");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -186,7 +189,7 @@ describe("TaskWidget", () => {
});
it("deactivates a task with setActiveTask(id, false)", () => {
store.create("Task", "Desc", "Doing work");
store.create("Task", "Desc", "done criterion", "Doing work");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -202,12 +205,13 @@ describe("TaskWidget", () => {
});
it("prunes stale active IDs on update", () => {
store.create("Task", "Desc");
store.create("Task", "Desc", "done");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
// Complete the task externally
store.update("1", { status: "completed" });
store.update("1", { pending_approval: true });
store.complete("1");
widget.update();
// Should render as completed, not active
@@ -217,8 +221,8 @@ describe("TaskWidget", () => {
});
it("supports multiple active tasks simultaneously", () => {
store.create("Task A", "Desc", "Processing A");
store.create("Task B", "Desc", "Processing B");
store.create("Task A", "Desc", "done criterion", "Processing A");
store.create("Task B", "Desc", "done criterion", "Processing B");
store.update("1", { status: "in_progress" });
store.update("2", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -230,8 +234,8 @@ describe("TaskWidget", () => {
});
it("distributes token usage across all active tasks", () => {
store.create("Task A", "Desc", "A");
store.create("Task B", "Desc", "B");
store.create("Task A", "Desc", "done criterion", "A");
store.create("Task B", "Desc", "done criterion", "B");
store.update("1", { status: "in_progress" });
store.update("2", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -246,7 +250,7 @@ describe("TaskWidget", () => {
});
it("dispose clears widget and timer", () => {
store.create("Task", "Desc");
store.create("Task", "Desc", "done");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -255,7 +259,7 @@ describe("TaskWidget", () => {
});
it("uses subject as fallback when no activeForm", () => {
store.create("My Subject", "Desc");
store.create("My Subject", "Desc", "done");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -264,7 +268,7 @@ describe("TaskWidget", () => {
});
it("shows elapsed time but no token arrows when tokens are zero", () => {
store.create("No tokens", "Desc", "Working");
store.create("No tokens", "Desc", "done criterion", "Working");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -280,7 +284,7 @@ describe("TaskWidget", () => {
});
it("cleans up metrics when stale active IDs are pruned", () => {
store.create("Task", "Desc", "Running");
store.create("Task", "Desc", "done criterion", "Running");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
widget.addTokenUsage(100, 50);
@@ -290,7 +294,7 @@ describe("TaskWidget", () => {
widget.update();
// Reactivate with same ID (new task) — should get fresh metrics
store.create("Task 2", "Desc", "Running"); // ID 2
store.create("Task 2", "Desc", "done criterion", "Running"); // ID 2
store.update("2", { status: "in_progress" });
widget.setActiveTask("2", true);
@@ -300,7 +304,7 @@ describe("TaskWidget", () => {
});
it("indents task lines under header", () => {
store.create("Indented task", "Desc");
store.create("Indented task", "Desc", "done");
widget.update();
const lines = renderWidget(ui.state);
@@ -309,7 +313,7 @@ describe("TaskWidget", () => {
});
it("widget is placed aboveEditor", () => {
store.create("Task", "Desc");
store.create("Task", "Desc", "done");
widget.update();
const entry = ui.state.widgets.get("tasks");
@@ -336,7 +340,7 @@ describe("formatDuration (via widget rendering)", () => {
});
it("shows seconds for short durations", () => {
store.create("Quick", "Desc", "Working");
store.create("Quick", "Desc", "done criterion", "Working");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -348,7 +352,7 @@ describe("formatDuration (via widget rendering)", () => {
});
it("shows hours for long durations", () => {
store.create("Long", "Desc", "Working");
store.create("Long", "Desc", "done criterion", "Working");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -360,7 +364,7 @@ describe("formatDuration (via widget rendering)", () => {
});
it("shows exact hours without minutes", () => {
store.create("Exact", "Desc", "Working");
store.create("Exact", "Desc", "done criterion", "Working");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -372,7 +376,7 @@ describe("formatDuration (via widget rendering)", () => {
});
it("shows minutes and seconds", () => {
store.create("Medium", "Desc", "Working");
store.create("Medium", "Desc", "done criterion", "Working");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -384,7 +388,7 @@ describe("formatDuration (via widget rendering)", () => {
});
it("formats small token counts without k suffix", () => {
store.create("Small", "Desc", "Working");
store.create("Small", "Desc", "done criterion", "Working");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);
@@ -397,7 +401,7 @@ describe("formatDuration (via widget rendering)", () => {
});
it("formats token counts with k suffix and removes .0", () => {
store.create("Large", "Desc", "Working");
store.create("Large", "Desc", "done criterion", "Working");
store.update("1", { status: "in_progress" });
widget.setActiveTask("1", true);