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