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
+79 -266
View File
@@ -1,328 +1,141 @@
# @tintinweb/pi-tasks
# @wassname/pi-lgtm
A [pi](https://pi.dev) extension that brings **Claude Code-style task tracking and coordination** to pi. Track multi-step work with structured tasks, dependency management, and a persistent visual widget.
A [pi](https://pi.dev) extension that adds structured human sign-off to task tracking. Fork of [@tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) with a minimal LGTM layer.
> **Status:** Early release.
<img width="600" alt="pi-tasks screenshot" src="https://github.com/tintinweb/pi-tasks/raw/master/media/screenshot.png" />
https://github.com/user-attachments/assets/1d0ee87a-e0a5-4bfa-a9b9-2f9144cb905b
## Features
- **7 LLM-callable tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute` — matching Claude Code's exact tool specs and descriptions
- **Persistent widget** — live task list above the editor with `✔`/`◼`/`◻` status icons, task numbers (`#1`, `#2`, …), strikethrough for completed tasks, star spinner (`✳✽`) for active tasks with elapsed time and token counts
- **System-reminder injection** — periodic `<system-reminder>` nudges appended to tool results when task tools haven't been used recently (matches Claude Code's behavior exactly)
- **Prompt guidelines** — workflow contract encoded in tool descriptions, nudging the LLM at the point of tool use
- **Dependency management** — bidirectional `blocks`/`blockedBy` relationships with warnings for cycles, self-deps, and dangling references
- **Shared task lists** — multiple pi sessions can share a file-backed task list for agent team coordination
- **File locking** — concurrent access is safe when multiple sessions share a task list
- **Background process tracking** — track spawned processes with output buffering, blocking wait, and graceful stop
- **Subagent integration** — tasks with `agentType` can be executed as subagents via `TaskExecute` (requires [@tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents)). Auto-cascade mode flows through the task DAG automatically when enabled.
The core idea: agents cannot mark tasks complete themselves. They must call `lgtm_ask` with auditable evidence and explicit failure-mode analysis, then a human signs off via `/lgtm <id>`.
## Install
```bash
pi install npm:@tintinweb/pi-tasks
pi install npm:@wassname/pi-lgtm
```
Or load directly for development:
Or for development:
```bash
pi -e ./src/index.ts
```
## What is different from pi-tasks
| pi-tasks | pi-lgtm |
|---|---|
| Agent calls `TaskUpdate { status: "completed" }` | Blocked -- throws error |
| No evidence required | `lgtm_ask` requires evidence, 2 failure modes, evidence vs failures |
| Tasks complete immediately | Agent sets `pending_approval`, human runs `/lgtm <id>` |
| No done criterion | `done_criterion` required on create: falsifiable observation |
Stripped: `TaskExecute`, `TaskOutput`, `TaskStop`, `process-tracker.ts`, subagent RPC, settings menu.
## Widget
The extension renders a persistent widget above the editor:
```
4 tasks (1 done, 1 in progress, 2 open)
✔ #1 Design the flux capacitor
✳ #2 Acquiring plutonium… (2m 49s · ↑ 4.1k ↓ 1.2k)
◻ #3 Install flux capacitor in DeLorean blocked by #1
◻ #4 Test time travel at 88 mph blocked by #2, #3
3 tasks (1 done, 1 in progress, 1 open)
✔ #1 Design schema
✳ #2 Implementing cache layer… (2m 49s · ↑ 4.1k ↓ 1.2k)
◻ #3 Load test 👀
```
| Icon | Meaning |
|------|---------|
| `✔` | Completed (strikethrough + dim) |
| `◼` | In-progress (not actively executing) |
| `◻` | Pending |
| `✳`/`✽` | Animated star spinner — actively executing task (shows `activeForm` text, elapsed time, token counts) |
`👀` means the agent called `lgtm_ask` and the task is waiting for human sign-off.
## Tools
### `TaskCreate`
Create a structured task. Used proactively for complex multi-step work.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `subject` | string | yes | Brief imperative title |
| `description` | string | yes | Detailed context and acceptance criteria |
| `activeForm` | string | no | Present continuous form for spinner (e.g., "Running tests") |
| `agentType` | string | no | Agent type for subagent execution (e.g., `"general-purpose"`, `"Explore"`) |
| `metadata` | object | no | Arbitrary key-value pairs |
```
→ Task #1 created successfully: Fix authentication bug
subject, description, done_criterion (required), activeForm (optional)
```
`done_criterion` must be a falsifiable observation: what you expect to see AND what you would see if it is wrong. Example: `"All 92 tests pass. If wrong: type errors in build or failures in task-store.test.ts."`
### `TaskList`
List all tasks with status, owner, and blocked-by info.
```
#1 [pending] Fix authentication bug
#2 [in_progress] Write unit tests (agent-1)
#3 [pending] Update docs [blocked by #1, #2]
```
Sort order: pending first, then in-progress, then completed (each group by ID).
Lists all tasks. `👀` indicates pending sign-off.
### `TaskGet`
Get full details for a specific task.
```
Task #2: Write unit tests
Status: in_progress
Owner: agent-1
Description: Add tests for the auth module
Blocked by: #1
Blocks: #3
```
Shows owner (if set) and open (non-completed) dependency edges. Non-empty metadata is displayed as JSON.
Full task details including `done_criterion` and approval state.
### `TaskUpdate`
Update task fields, status, metadata, and dependencies.
Update status (`pending | in_progress | deleted`), subject, description, done_criterion, dependencies. Cannot set `completed` -- use `/lgtm`.
| Parameter | Type | Description |
|-----------|------|-------------|
| `taskId` | string | Task ID (required) |
| `status` | `pending` / `in_progress` / `completed` / `deleted` | New status |
| `subject` | string | New title |
| `description` | string | New description |
| `activeForm` | string | Spinner text |
| `owner` | string | Agent name |
| `metadata` | object | Shallow merge (null values delete keys) |
| `addBlocks` | string[] | Task IDs this task blocks |
| `addBlockedBy` | string[] | Task IDs that block this task |
### `lgtm_ask`
The epistemic gate. Required fields:
| Field | Description |
|---|---|
| `taskId` | Task to submit |
| `evidence` | Exact command run + output, commit hash, config/seeds, file paths. "I ran X and got Y" not "I wrote X". |
| `failure_mode_1` | Most likely way this is wrong despite evidence |
| `failure_mode_2` | Second most likely failure mode |
| `evidence_vs_failures` | How would evidence look different if FM1 or FM2 were true? |
| `evidence_files` | Optional file paths to inspect (validated: must exist) |
| `remaining_uncertainty` | What is NOT tested, deferred edge cases, known limitations |
After calling this, the task shows `👀` and is only completable via `/lgtm <id>`. Evidence is stored on the task so the human can review it hours later without scrolling back.
The tool result includes a non-blocking self-check prompt asking whether the evidence directly addresses the `done_criterion` and whether a skeptical reviewer would find it convincing.
## Commands
### `/lgtm <id>`
Human-only sign-off. Shows stored evidence, failure modes, and remaining uncertainty for review, then asks for confirmation. Without `<id>`, shows a list of pending-approval tasks.
### `/tasks`
Interactive menu: view tasks, create task, clear completed/all.
## Task lifecycle
```
→ Updated task #1 status
→ Updated task #2 owner, status
→ Updated task #3 blocks
→ Updated task #3 blocks (warning: cycle: #3 and #1 block each other)
→ Updated task #1 deleted
pending -> in_progress -> (lgtm_ask) -> pending_approval 👀 -> (/lgtm) -> completed
-> deleted
```
Setting `status: "deleted"` permanently removes the task.
## Storage
Dependencies are bidirectional: `addBlocks: ["3"]` on task 1 also adds `blockedBy: ["1"]` to task 3.
### `TaskOutput`
Retrieve output from a background task process.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `task_id` | string | — | Task ID or agent ID (required) |
| `block` | boolean | `true` | Wait for completion |
| `timeout` | number | `30000` | Max wait time in ms (max 600000) |
Both task IDs and agent IDs (including partial prefixes) are accepted — agent IDs are resolved via the internal `agentTaskMap`.
### `TaskStop`
Stop a running background task process. Sends SIGTERM, waits 5 seconds, then SIGKILL. For subagent tasks, sends a stop RPC.
| Parameter | Type | Description |
|-----------|------|-------------|
| `task_id` | string | Task ID or agent ID to stop |
### `TaskExecute`
Execute one or more tasks as background subagents. Requires [@tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents).
| Parameter | Type | Description |
|-----------|------|-------------|
| `task_ids` | string[] | Task IDs to execute (required) |
| `additional_context` | string | Extra context appended to each agent's prompt |
| `model` | string | Model override (e.g., `"sonnet"`, `"haiku"`) |
| `max_turns` | number | Max turns per agent |
Tasks must be `pending`, have `agentType` set, and all `blockedBy` dependencies `completed`. Each task spawns as an independent background subagent.
With **auto-cascade** enabled (via `/tasks` → Settings), completed tasks automatically trigger execution of their unblocked dependents — flowing through the DAG like a build system.
## Task Lifecycle
```
pending → in_progress → completed
→ deleted (permanently removed)
```
Tasks are created as `pending`. Mark `in_progress` before starting work, `completed` when done. `deleted` removes entirely — IDs never reset.
## Dependency Management
- **Bidirectional edges:** `addBlocks`/`addBlockedBy` maintain both sides automatically
- **Dependency warnings:** cycles, self-dependencies, and references to non-existent tasks are stored but produce warnings in the tool response
- **Display-time filtering:** `TaskList` only shows non-completed blockers in `[blocked by ...]`
- **Raw data preserved:** `TaskGet` shows ALL edges, including completed blockers
- **Cleanup on deletion:** removing a task cleans up all edges pointing to it
## Task Storage
Task storage is controlled by the `taskScope` setting (`/tasks` → Settings → Task storage):
Controlled by `taskScope` in `.pi/tasks-config.json`:
| Mode | File | Behaviour |
|------|------|-----------|
| `memory` | *(none)* | In-memory only — tasks lost when session ends |
| `session` **(default)** | `<cwd>/.pi/tasks/tasks-<sessionId>.json` | Per-session file — isolated between sessions, survives resume |
| `project` | `<cwd>/.pi/tasks/tasks.json` | Shared across all sessions in the project |
|---|---|---|
| `memory` | none | In-memory, lost on session end |
| `session` (default) | `.pi/tasks/tasks-<sessionId>.json` | Per-session, survives resume |
| `project` | `.pi/tasks/tasks.json` | Shared across all sessions |
On new session start, if all persisted tasks are completed they are auto-cleared for a clean slate. On session resume, all tasks (including completed) are shown so the user can review progress. Empty session files are automatically deleted when all tasks are cleared.
Override via env:
### Auto-clear completed tasks
The `autoClearCompleted` setting controls automatic cleanup of completed tasks:
| Mode | Behaviour |
|------|-----------|
| `never` | Completed tasks stay visible until manually cleared via `/tasks` → Clear completed |
| `on_list_complete` **(default)** | Cleared after all tasks are done and a few idle turns pass |
| `on_task_complete` | Each completed task cleared individually after a few turns |
Both auto-clear modes use a turn-based delay for non-jarring UX — tasks linger briefly so you see the completion before they disappear.
Settings (`taskScope`, `autoCascade`, `autoClearCompleted`) are saved to `<cwd>/.pi/tasks-config.json`.
### Override via environment variables
| Variable | Value | Behaviour |
|----------|-------|-----------|
| `PI_TASKS` | `off` | In-memory only (CI/automation) |
| `PI_TASKS` | `sprint-1` | Named shared list at `~/.pi/tasks/sprint-1.json` |
| `PI_TASKS` | `/abs/path/tasks.json` | Explicit absolute file path |
| `PI_TASKS` | `./tasks.json` | Relative path resolved from cwd |
| *(unset)* | | Uses `taskScope` setting (default: `session`) |
| `PI_TASKS_DEBUG` | `1` | Trace RPC communication (request/reply/timeout) and spawn errors to stderr |
Named and explicit paths use a file-locked store with stale-lock detection — safe for multiple pi sessions coordinating on the same task list.
**CI example** (`.envrc`):
```bash
export PI_TASKS=off
PI_TASKS=off # in-memory (CI)
PI_TASKS=sprint-1 # named shared list at ~/.pi/tasks/sprint-1.json
PI_TASKS=/abs/path # explicit path
PI_TASKS_DEBUG=1 # trace to stderr
```
**Shared team list** (`.envrc`):
```bash
export PI_TASKS=my-project
```
## `/tasks` Command
Interactive menu:
```
Tasks
├─ View all tasks (4)
├─ Create task
├─ Clear completed (1)
├─ Clear all (4)
└─ Settings
```
- **View all tasks** — select a task to see details and take actions (start, complete, delete)
- **Create task** — input prompts for subject and description
- **Clear completed** — remove all completed tasks
- **Clear all** — remove all tasks regardless of status
- **Settings** — configure task storage, auto-cascade, and auto-clear completed tasks (saved to `tasks-config.json`)
## Cross-extension Communication with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents)
[`pi-tasks`](https://github.com/tintinweb/pi-tasks) communicates with [`@tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) via pi's eventbus using a scoped request/reply RPC protocol. No shared global state — just events.
### Presence Detection
Load order doesn't matter. Two handshake paths ensure detection regardless of which extension loads first:
1. **Ping on init** — [`pi-tasks`](https://github.com/tintinweb/pi-tasks) emits `subagents:rpc:ping` with a unique `requestId` and listens for `subagents:rpc:ping:reply:{requestId}`. If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is already loaded, it replies immediately.
2. **Ready broadcast** — [`pi-subagents`](https://github.com/tintinweb/pi-subagents) emits `subagents:ready` when it initializes. If [`pi-tasks`](https://github.com/tintinweb/pi-tasks) loaded first, it picks this up.
```
┌─────────────┐ ┌──────────────────┐
│ pi-tasks │ │ pi-subagents │
└──────┬──────┘ └────────┬─────────┘
│ │
│──── subagents:rpc:ping ───────────▶│
│◀─── subagents:rpc:ping:reply ──────│
│ │
│◀─── subagents:ready ───────────────│ (broadcast on init)
│ │
```
### Spawning Subagents
When `TaskExecute` runs, it sends a spawn RPC with a scoped reply channel:
```
pi-tasks pi-subagents
│ │
│── subagents:rpc:spawn ─────────────────▶│ { requestId, type, prompt, options }
│◀─ subagents:rpc:spawn:reply:{reqId} ───│ { id } (or { error })
│ │
```
The returned `id` is stored in an in-memory `agentTaskMap` (agentId → taskId) for O(1) completion lookup. A 30-second timeout rejects the Promise if no reply arrives.
### Lifecycle Events
[`pi-subagents`](https://github.com/tintinweb/pi-subagents) emits lifecycle events that [`pi-tasks`](https://github.com/tintinweb/pi-tasks) listens to:
| Event | Payload | Action |
|-------|---------|--------|
| `subagents:completed` | `{ id, result? }` | Mark task `completed`, trigger auto-cascade if enabled |
| `subagents:failed` | `{ id, error?, status }` | Revert task to `pending`, store error in metadata |
### Standalone Mode
If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is not installed, everything works except `TaskExecute`, which returns a friendly error message. All core task tools (create, list, get, update, dependencies, widget, system-reminder injection) function independently.
## Architecture
```
src/
├── index.ts # Extension entry: 7 tools + /tasks command + widget + subagent integration
├── types.ts # Task, TaskStatus, BackgroundProcess types
├── task-store.ts # File-backed store with CRUD, dependencies, locking
├── auto-clear.ts # Turn-based auto-clearing of completed tasks (AutoClearManager)
├── tasks-config.ts # Config persistence (taskScope, autoCascade, autoClearCompleted) → .pi/tasks-config.json
├── process-tracker.ts # Background process output buffering and stop
├── index.ts # 5 tools + /tasks + /lgtm commands + widget + event handlers
├── types.ts # Task, TaskStatus types
├── task-store.ts # File-backed store with CRUD, locking, complete() method
├── auto-clear.ts # Turn-based auto-clearing of completed tasks
├── tasks-config.ts # Config persistence -> .pi/tasks-config.json
└── ui/
── task-widget.ts # Persistent widget with status icons and spinner
└── settings-menu.ts # /tasks → Settings panel (SettingsList TUI component)
── task-widget.ts # Widget with status icons, spinner, 👀 indicator
```
## Future Work
- **Background Bash auto-task creation** — Claude Code auto-creates tasks when `Bash` runs with `run_in_background: true`. Pi's bash tool currently lacks a `run_in_background` parameter (only `command` + `timeout`), so there's nothing to hook into. Once pi adds background execution support to its bash tool, we can use the `tool_call` event to detect it and auto-create tasks via `TaskStore`/`ProcessTracker`.
## Development
```bash
npm install
npm run typecheck # TypeScript validation
npm test # Run unit tests (145 tests)
npm run typecheck
npm test # 92 tests
npm run build
```
## License
MIT — [tintinweb](https://github.com/tintinweb)
MIT -- based on [tintinweb/pi-tasks](https://github.com/tintinweb/pi-tasks) (MIT)
+11 -12
View File
@@ -1,25 +1,24 @@
{
"name": "@tintinweb/pi-tasks",
"name": "@wassname/pi-lgtm",
"version": "0.4.2",
"description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.",
"author": "tintinweb",
"description": "A pi extension providing goal tracking with structural sign-off and LGTM workflow.",
"author": "wassname",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/tintinweb/pi-tasks.git"
"url": "https://github.com/wassname/pi-lgtm.git"
},
"homepage": "https://github.com/tintinweb/pi-tasks#readme",
"homepage": "https://github.com/wassname/pi-lgtm#readme",
"bugs": {
"url": "https://github.com/tintinweb/pi-tasks/issues"
"url": "https://github.com/wassname/pi-lgtm/issues"
},
"keywords": [
"pi-package",
"pi",
"pi-extension",
"task",
"tasks",
"todo",
"coordination"
"lgtm",
"sign-off",
"goal-tracking"
],
"dependencies": {
"@mariozechner/pi-coding-agent": "^0.62.0",
@@ -45,7 +44,7 @@
"extensions": [
"./src/index.ts"
],
"video": "https://github.com/tintinweb/pi-tasks/raw/master/media/demo.mp4",
"image": "https://github.com/tintinweb/pi-tasks/raw/master/media/screenshot.png"
"video": "",
"image": ""
}
}
+226 -709
View File
File diff suppressed because it is too large Load Diff
-140
View File
@@ -1,140 +0,0 @@
/**
* process-tracker.ts — Background process management for tasks.
*
* Tracks spawned child processes, buffers their output, and supports
* blocking wait and graceful stop (SIGTERM → 5s → SIGKILL).
*/
import type { ChildProcess } from "node:child_process";
import type { BackgroundProcess } from "./types.js";
export interface ProcessOutput {
output: string;
status: BackgroundProcess["status"];
exitCode?: number;
startedAt: number;
completedAt?: number;
command?: string;
}
export class ProcessTracker {
private processes = new Map<string, BackgroundProcess>();
/** Register a spawned process for a task. */
track(taskId: string, proc: ChildProcess, command?: string): void {
const bp: BackgroundProcess = {
taskId,
pid: proc.pid!,
command,
output: [],
status: "running",
startedAt: Date.now(),
proc,
abortController: new AbortController(),
waiters: [],
};
// Buffer stdout
proc.stdout?.on("data", (data: Buffer) => {
bp.output.push(data.toString());
});
// Buffer stderr
proc.stderr?.on("data", (data: Buffer) => {
bp.output.push(data.toString());
});
// Handle process exit
proc.on("close", (code, _signal) => {
if (bp.status === "running") {
bp.status = code === 0 ? "completed" : "error";
}
bp.exitCode = code ?? undefined;
bp.completedAt = Date.now();
// Notify all waiters
for (const resolve of bp.waiters) resolve();
bp.waiters = [];
});
proc.on("error", (err) => {
if (bp.status === "running") {
bp.status = "error";
bp.output.push(`Process error: ${err.message}`);
bp.completedAt = Date.now();
for (const resolve of bp.waiters) resolve();
bp.waiters = [];
}
});
this.processes.set(taskId, bp);
}
/** Get current output and status for a task's process. */
getOutput(taskId: string): ProcessOutput | undefined {
const bp = this.processes.get(taskId);
if (!bp) return undefined;
return {
output: bp.output.join(""),
status: bp.status,
exitCode: bp.exitCode,
startedAt: bp.startedAt,
completedAt: bp.completedAt,
command: bp.command,
};
}
/** Wait for a task's process to complete, with timeout. */
waitForCompletion(taskId: string, timeout: number, signal?: AbortSignal): Promise<ProcessOutput | undefined> {
const bp = this.processes.get(taskId);
if (!bp) return Promise.resolve(undefined);
if (bp.status !== "running") return Promise.resolve(this.getOutput(taskId));
return new Promise<ProcessOutput | undefined>((resolve) => {
let settled = false;
const timer = setTimeout(finish, timeout);
function finish() {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(self.getOutput(taskId));
}
const self = this;
bp.waiters.push(finish);
signal?.addEventListener("abort", finish, { once: true });
});
}
/** Stop a task's background process. SIGTERM → 5s → SIGKILL. */
async stop(taskId: string): Promise<boolean> {
const bp = this.processes.get(taskId);
if (!bp || bp.status !== "running") return false;
bp.status = "stopped";
bp.proc.kill("SIGTERM");
// Wait up to 5s for graceful exit
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
try { bp.proc.kill("SIGKILL"); } catch { /* already dead */ }
resolve();
}, 5000);
bp.proc.on("close", () => {
clearTimeout(timer);
resolve();
});
});
bp.completedAt = Date.now();
for (const resolve of bp.waiters) resolve();
bp.waiters = [];
return true;
}
/** Get the process record for a task. */
getProcess(taskId: string): BackgroundProcess | undefined {
return this.processes.get(taskId);
}
}
+55 -114
View File
@@ -14,24 +14,17 @@ const TASKS_DIR = join(homedir(), ".pi", "tasks");
const LOCK_RETRY_MS = 50;
const LOCK_MAX_RETRIES = 100; // 5s max
/** Simple file-based locking. */
function acquireLock(lockPath: string): void {
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
try {
// O_EXCL: fail if file exists
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
return;
} catch (e: any) {
if (e.code === "EEXIST") {
// Check for stale lock (process no longer running)
try {
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
if (pid && !isProcessRunning(pid)) {
unlinkSync(lockPath);
continue;
}
} catch { /* ignore read errors */ }
// Wait and retry
if (pid && !isProcessRunning(pid)) { unlinkSync(lockPath); continue; }
} catch { /* ignore */ }
const start = Date.now();
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
continue;
@@ -53,8 +46,6 @@ function isProcessRunning(pid: number): boolean {
export class TaskStore {
private filePath: string | undefined;
private lockPath: string | undefined;
// In-memory state (always kept in sync)
private nextId = 1;
private tasks = new Map<string, Task>();
@@ -68,61 +59,42 @@ export class TaskStore {
this.load();
}
/** Read store from disk (file-backed mode only). */
private load(): void {
if (!this.filePath) return;
if (!existsSync(this.filePath)) return;
if (!this.filePath || !existsSync(this.filePath)) return;
try {
const data: TaskStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
this.nextId = data.nextId;
this.tasks.clear();
for (const t of data.tasks) {
this.tasks.set(t.id, t);
}
for (const t of data.tasks) this.tasks.set(t.id, t);
} catch { /* corrupt file — start fresh */ }
}
/** Write store to disk atomically (file-backed mode only). */
private save(): void {
if (!this.filePath) return;
const data: TaskStoreData = {
nextId: this.nextId,
tasks: Array.from(this.tasks.values()),
};
const tmpPath = this.filePath + ".tmp";
writeFileSync(tmpPath, JSON.stringify(data, null, 2));
writeFileSync(tmpPath, JSON.stringify({ nextId: this.nextId, tasks: Array.from(this.tasks.values()) }, null, 2));
renameSync(tmpPath, this.filePath);
}
/** Execute a mutation with file locking (if file-backed). */
private withLock<T>(fn: () => T): T {
if (!this.lockPath) return fn();
acquireLock(this.lockPath);
try {
this.load(); // Re-read latest state
const result = fn();
this.save();
return result;
} finally {
releaseLock(this.lockPath);
}
try { this.load(); const result = fn(); this.save(); return result; }
finally { releaseLock(this.lockPath); }
}
create(subject: string, description: string, activeForm?: string, metadata?: Record<string, any>): Task {
create(subject: string, description: string, done_criterion: string, activeForm?: string, metadata?: Record<string, any>): Task {
return this.withLock(() => {
const now = Date.now();
const task: Task = {
id: String(this.nextId++),
subject,
description,
subject, description, done_criterion,
pending_approval: false,
status: "pending",
activeForm,
owner: undefined,
activeForm, owner: undefined,
metadata: metadata ?? {},
blocks: [],
blockedBy: [],
createdAt: now,
updatedAt: now,
blocks: [], blockedBy: [],
createdAt: now, updatedAt: now,
};
this.tasks.set(task.id, task);
return task;
@@ -134,16 +106,17 @@ export class TaskStore {
return this.tasks.get(id);
}
/** List all tasks sorted by ID ascending. */
list(): Task[] {
if (this.filePath) this.load();
return Array.from(this.tasks.values()).sort((a, b) => Number(a.id) - Number(b.id));
}
update(id: string, fields: {
status?: TaskStatus | "deleted";
status?: Exclude<TaskStatus, "completed"> | "deleted";
subject?: string;
description?: string;
done_criterion?: string;
pending_approval?: boolean;
activeForm?: string;
owner?: string;
metadata?: Record<string, any>;
@@ -157,10 +130,12 @@ export class TaskStore {
const changedFields: string[] = [];
const warnings: string[] = [];
// Handle deletion
if ((fields.status as string) === "completed") {
throw new Error(`Use /lgtm ${id} to complete tasks. Call lgtm_ask first to submit evidence.`);
}
if (fields.status === "deleted") {
this.tasks.delete(id);
// Clean up dependency edges pointing to this task
for (const t of this.tasks.values()) {
t.blocks = t.blocks.filter(bid => bid !== id);
t.blockedBy = t.blockedBy.filter(bid => bid !== id);
@@ -168,80 +143,42 @@ export class TaskStore {
return { task: undefined, changedFields: ["deleted"], warnings: [] };
}
if (fields.status !== undefined) {
task.status = fields.status;
changedFields.push("status");
}
if (fields.subject !== undefined) {
task.subject = fields.subject;
changedFields.push("subject");
}
if (fields.description !== undefined) {
task.description = fields.description;
changedFields.push("description");
}
if (fields.activeForm !== undefined) {
task.activeForm = fields.activeForm;
changedFields.push("activeForm");
}
if (fields.owner !== undefined) {
task.owner = fields.owner;
changedFields.push("owner");
}
if (fields.status !== undefined) { task.status = fields.status as TaskStatus; changedFields.push("status"); }
if (fields.subject !== undefined) { task.subject = fields.subject; changedFields.push("subject"); }
if (fields.description !== undefined) { task.description = fields.description; changedFields.push("description"); }
if (fields.done_criterion !== undefined) { task.done_criterion = fields.done_criterion; changedFields.push("done_criterion"); }
if (fields.pending_approval !== undefined) { task.pending_approval = fields.pending_approval; changedFields.push("pending_approval"); }
if (fields.activeForm !== undefined) { task.activeForm = fields.activeForm; changedFields.push("activeForm"); }
if (fields.owner !== undefined) { task.owner = fields.owner; changedFields.push("owner"); }
// Metadata: shallow merge, null deletes keys
if (fields.metadata !== undefined) {
for (const [key, value] of Object.entries(fields.metadata)) {
if (value === null) {
delete task.metadata[key];
} else {
task.metadata[key] = value;
}
if (value === null) delete task.metadata[key];
else task.metadata[key] = value;
}
changedFields.push("metadata");
}
// Bidirectional dependency edges
if (fields.addBlocks && fields.addBlocks.length > 0) {
if (fields.addBlocks?.length) {
for (const targetId of fields.addBlocks) {
if (!task.blocks.includes(targetId)) {
task.blocks.push(targetId);
}
if (!task.blocks.includes(targetId)) task.blocks.push(targetId);
const target = this.tasks.get(targetId);
if (target && !target.blockedBy.includes(id)) {
target.blockedBy.push(id);
target.updatedAt = Date.now();
}
// Warnings for problematic edges
if (targetId === id) {
warnings.push(`#${id} blocks itself`);
} else if (!target) {
warnings.push(`#${targetId} does not exist`);
} else if (target.blocks.includes(id)) {
warnings.push(`cycle: #${id} and #${targetId} block each other`);
}
if (target && !target.blockedBy.includes(id)) { target.blockedBy.push(id); target.updatedAt = Date.now(); }
if (targetId === id) warnings.push(`#${id} blocks itself`);
else if (!target) warnings.push(`#${targetId} does not exist`);
else if (target.blocks.includes(id)) warnings.push(`cycle: #${id} and #${targetId} block each other`);
}
changedFields.push("blocks");
}
if (fields.addBlockedBy && fields.addBlockedBy.length > 0) {
if (fields.addBlockedBy?.length) {
for (const targetId of fields.addBlockedBy) {
if (!task.blockedBy.includes(targetId)) {
task.blockedBy.push(targetId);
}
if (!task.blockedBy.includes(targetId)) task.blockedBy.push(targetId);
const target = this.tasks.get(targetId);
if (target && !target.blocks.includes(id)) {
target.blocks.push(id);
target.updatedAt = Date.now();
}
// Warnings for problematic edges
if (targetId === id) {
warnings.push(`#${id} blocks itself`);
} else if (!target) {
warnings.push(`#${targetId} does not exist`);
} else if (task.blocks.includes(targetId)) {
warnings.push(`cycle: #${id} and #${targetId} block each other`);
}
if (target && !target.blocks.includes(id)) { target.blocks.push(id); target.updatedAt = Date.now(); }
if (targetId === id) warnings.push(`#${id} blocks itself`);
else if (!target) warnings.push(`#${targetId} does not exist`);
else if (task.blocks.includes(targetId)) warnings.push(`cycle: #${id} and #${targetId} block each other`);
}
changedFields.push("blockedBy");
}
@@ -251,12 +188,23 @@ export class TaskStore {
});
}
/** Delete a task by ID. Returns true if deleted. */
/** Complete a task. Called only by /lgtm -- requires pending_approval=true. */
complete(id: string): Task {
return this.withLock(() => {
const task = this.tasks.get(id);
if (!task) throw new Error(`Task #${id} not found`);
if (task.status === "completed") throw new Error(`Task #${id} already completed`);
if (!task.pending_approval) throw new Error(`Task #${id} not ready. Agent must call lgtm_ask first.`);
task.status = "completed";
task.updatedAt = Date.now();
return task;
});
}
delete(id: string): boolean {
return this.withLock(() => {
if (!this.tasks.has(id)) return false;
this.tasks.delete(id);
// Clean up dependency edges
for (const t of this.tasks.values()) {
t.blocks = t.blocks.filter(bid => bid !== id);
t.blockedBy = t.blockedBy.filter(bid => bid !== id);
@@ -265,7 +213,6 @@ export class TaskStore {
});
}
/** Remove all tasks. */
clearAll(): number {
return this.withLock(() => {
const count = this.tasks.size;
@@ -274,24 +221,18 @@ export class TaskStore {
});
}
/** Delete the backing file (if file-backed and empty). */
deleteFileIfEmpty(): boolean {
if (!this.filePath || this.tasks.size > 0) return false;
try { unlinkSync(this.filePath); } catch { /* ignore */ }
return true;
}
/** Remove all completed tasks. */
clearCompleted(): number {
return this.withLock(() => {
let count = 0;
for (const [id, task] of this.tasks) {
if (task.status === "completed") {
this.tasks.delete(id);
count++;
if (task.status === "completed") { this.tasks.delete(id); count++; }
}
}
// Clean up dependency edges for deleted tasks
if (count > 0) {
const validIds = new Set(this.tasks.keys());
for (const t of this.tasks.values()) {
+2 -15
View File
@@ -8,6 +8,8 @@ export interface Task {
id: string;
subject: string;
description: string;
done_criterion: string; // required: what "done" looks like
pending_approval: boolean; // set by lgtm_ask, required before /lgtm
status: TaskStatus;
activeForm?: string;
owner?: string;
@@ -23,18 +25,3 @@ export interface TaskStoreData {
nextId: number;
tasks: Task[];
}
/** Background process associated with a task. */
export interface BackgroundProcess {
taskId: string;
pid: number;
command?: string;
output: string[];
status: "running" | "completed" | "error" | "stopped";
exitCode?: number;
startedAt: number;
completedAt?: number;
proc: import("node:child_process").ChildProcess;
abortController: AbortController;
waiters: Array<() => void>;
}
-100
View File
@@ -1,100 +0,0 @@
/**
* settings-menu.ts — Polished settings panel for /tasks → Settings.
*
* Uses ui.custom() + SettingsList for native TUI rendering with keyboard
* navigation, live toggle, and per-row descriptions — matching pi-coding-agent's
* own settings panel style.
*/
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
import { Container, type SettingItem, SettingsList, Spacer, Text } from "@mariozechner/pi-tui";
import { saveTasksConfig, type TasksConfig } from "../tasks-config.js";
// ── Types ───────────────────────────────────────────────────────────────────
export type SettingsUI = {
custom<T>(
factory: (tui: any, theme: any, keybindings: any, done: (result: T) => void) => any,
options?: { overlay?: boolean; overlayOptions?: any },
): Promise<T>;
};
// ── Settings panel ──────────────────────────────────────────────────────────
export async function openSettingsMenu(
ui: SettingsUI,
cfg: TasksConfig,
onBack: () => Promise<void>,
clearDelayTurns: number,
): Promise<void> {
await ui.custom((_tui, theme, _kb, done) => {
const items: SettingItem[] = [
{
id: "taskScope",
label: "Task storage",
description:
"memory: tasks live only in memory, lost when session ends. " +
"session: persisted per session (tasks-<sessionId>.json), survives resume. " +
"project: shared across all sessions (tasks.json). " +
"Takes effect on next session start.",
currentValue: cfg.taskScope ?? "session",
values: ["memory", "session", "project"],
},
{
id: "autoCascade",
label: "Auto-execute with agents",
description:
"When ON: pending agent tasks start automatically once their dependencies complete. " +
"When OFF: use TaskExecute to launch them manually.",
currentValue: (cfg.autoCascade ?? false) ? "on" : "off",
values: ["on", "off"],
},
{
id: "autoClearCompleted",
label: "Auto-clear completed tasks",
description:
"never: completed tasks stay visible until manually cleared. " +
"on_list_complete: cleared automatically after all tasks are done. " +
"on_task_complete: each task cleared shortly after it completes. " +
`Clearing lags ~${clearDelayTurns} turns.`,
currentValue: cfg.autoClearCompleted ?? "on_list_complete",
values: ["never", "on_list_complete", "on_task_complete"],
},
];
const list = new SettingsList(
items,
/* maxVisible */ 10,
getSettingsListTheme(),
/* onChange */ (id, newValue) => {
if (id === "autoCascade") {
cfg.autoCascade = newValue === "on";
saveTasksConfig(cfg);
}
if (id === "taskScope") {
cfg.taskScope = newValue as "memory" | "session" | "project";
saveTasksConfig(cfg);
}
if (id === "autoClearCompleted") {
cfg.autoClearCompleted = newValue as TasksConfig["autoClearCompleted"];
saveTasksConfig(cfg);
}
},
/* onCancel */ () => done(undefined),
);
// Container doesn't forward handleInput to children — subclass to fix.
class SettingsPanel extends Container {
handleInput(data: string) { list.handleInput(data); }
}
const root = new SettingsPanel();
root.addChild(new Text(theme.bold(theme.fg("accent", "⚙ Task Settings")), 0, 0));
root.addChild(new Spacer(1));
root.addChild(list);
return root;
});
return onBack();
}
+2 -1
View File
@@ -187,7 +187,8 @@ export class TaskWidget {
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
: "";
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}`;
const approvalSuffix = (task as any).pending_approval ? " 👀" : "";
text = ` ${icon} ${theme.fg("dim", "#" + task.id)} ${task.subject}${agentSuffix}${approvalSuffix}`;
}
lines.push(truncate(text + suffix));
+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);