mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 16:46:17 +08:00
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:
+81
-56
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user