Merge pull request #1 from tintinweb/eventbus_task_to_subagent_conversion

Eventbus 🥳 + task to subagent conversion
This commit is contained in:
tintinweb
2026-03-13 21:20:12 +01:00
committed by GitHub
7 changed files with 846 additions and 10 deletions
+18
View File
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] - 2026-03-12
### Added
- **`TaskExecute` tool** — execute tasks as background subagents via pi-chonky-subagents. Tasks with `agentType` metadata are spawned as independent agents; validates status, dependencies, and agent type before launching.
- **`agentType` parameter on `TaskCreate`** — opt-in field (e.g., `"general-purpose"`, `"Explore"`) that marks tasks for subagent execution.
- **Auto-cascade** — when enabled via `/tasks` → Settings, completed agent tasks automatically trigger execution of their unblocked dependents, flowing through the task DAG like a build system. Off by default.
- **Subagent completion listener** — listens to `subagents:completed` and `subagents:failed` events to automatically update task status. Failed tasks revert to `pending` with error stored in metadata.
- **READY tags in system prompt** — pending tasks with `agentType` and all dependencies completed are marked `[READY — use TaskExecute to start]` in the system prompt.
- **Agent ID in widget** — in-progress tasks backed by subagents show the agent ID (e.g., `✳ Writing tests (agent abc12)…`).
- **Settings menu** — `/tasks` → Settings → toggle "Auto-execute tasks with agents".
- **`SubagentBridge` type** — typed interface for the cross-extension Symbol.for bridge.
### Changed
- `pi-chonky-subagents` global registry now exposes `spawn()` and `getRecord()` in addition to `waitForAll()` and `hasRunning()`.
- `pi-chonky-subagents` emits lifecycle events on `pi.events`: `subagents:created`, `subagents:started`, `subagents:completed`, `subagents:failed`, `subagents:steered`.
- `AgentManager` accepts an optional `onStart` callback, fired when an agent transitions to running (including from queue).
## [0.1.0] - 2026-03-12
Initial release — Claude Code-style task tracking and coordination for pi.
@@ -24,4 +41,5 @@ Initial release — Claude Code-style task tracking and coordination for pi.
- **Background process tracker** — output buffering (stdout + stderr), waiter notification, graceful stop with timeout escalation (SIGTERM → 5s → SIGKILL).
- **78 unit tests** — task store CRUD, dependencies, warnings, file persistence; widget rendering, icons, spinners, token/duration formatting; process tracker lifecycle.
[0.2.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.2.0
[0.1.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.1.0
+22 -3
View File
@@ -12,7 +12,7 @@ https://github.com/user-attachments/assets/86b09bd1-6882-4b0c-be20-ea866dd44b6a
## Features
- **6 LLM-callable tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop` — matching Claude Code's exact tool specs and descriptions
- **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, 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)
- **Task state persistence** — current task state injected into system prompt on every agent loop, surviving context compaction
@@ -21,6 +21,7 @@ https://github.com/user-attachments/assets/86b09bd1-6882-4b0c-be20-ea866dd44b6a
- **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 [pi-chonky-subagents](https://github.com/tintinweb/pi-subagents)). Auto-cascade mode flows through the task DAG automatically when enabled.
## Install
@@ -64,6 +65,7 @@ Create a structured task. Used proactively for complex multi-step work.
| `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 |
```
@@ -143,6 +145,21 @@ Stop a running background task process. Sends SIGTERM, waits 5 seconds, then SIG
|-----------|------|-------------|
| `task_id` | string | Task ID to stop |
### `TaskExecute`
Execute one or more tasks as background subagents. Requires [pi-chonky-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
```
@@ -180,19 +197,21 @@ Interactive menu:
Tasks
├─ View all tasks (4)
├─ Create task
├─ Settings
└─ Clear completed (1)
```
- **View all tasks** — select a task to see details and take actions (start, complete, delete)
- **Create task** — input prompts for subject and description
- **Settings** — toggle auto-cascade (auto-execute unblocked agent tasks on completion)
- **Clear completed** — remove all completed tasks
## Architecture
```
src/
├── index.ts # Extension entry: 6 tools + /tasks command + widget
├── types.ts # Task, TaskStatus, BackgroundProcess types
├── index.ts # Extension entry: 7 tools + /tasks command + widget + subagent integration
├── types.ts # Task, TaskStatus, BackgroundProcess, SubagentBridge types
├── task-store.ts # File-backed store with CRUD, dependencies, locking
├── process-tracker.ts # Background process output buffering and stop
└── ui/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@tintinweb/pi-tasks",
"version": "0.1.0",
"version": "0.2.0",
"description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.",
"author": "tintinweb",
"license": "MIT",
+206 -4
View File
@@ -8,6 +8,7 @@
* TaskUpdate — Update task fields, status, dependencies
* TaskOutput — Get output from a background task process
* TaskStop — Stop a running background task process
* TaskExecute — Execute tasks as subagents (requires pi-chonky-subagents)
*
* Commands:
* /tasks — Interactive task management menu
@@ -18,6 +19,7 @@ import { Type } from "@sinclair/typebox";
import { TaskStore } from "./task-store.js";
import { ProcessTracker } from "./process-tracker.js";
import { TaskWidget, type UICtx } from "./ui/task-widget.js";
import type { SubagentBridge } from "./types.js";
// ---- Helpers ----
@@ -26,7 +28,7 @@ function textResult(msg: string) {
}
/** Task tool names — used to detect task tool usage for reminder suppression. */
const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop"]);
const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop", "TaskExecute"]);
/** How many turns without task tool usage before injecting a reminder. */
const REMINDER_INTERVAL = 4;
@@ -42,6 +44,79 @@ export default function (pi: ExtensionAPI) {
const tracker = new ProcessTracker();
const widget = new TaskWidget(store);
// ── Subagent integration state ──
let autoCascadeEnabled = false;
/** Latest ExtensionContext — refreshed on every tool execution so cascade always has a valid one. */
let latestCtx: ExtensionContext | undefined;
/** Cascade config — set by TaskExecute, consumed by completion listener. */
let cascadeConfig: { additionalContext?: string; model?: string; maxTurns?: number } | undefined;
/** Get the subagent bridge from the global registry (returns undefined if pi-chonky-subagents not loaded). */
function getSubagentBridge(): SubagentBridge | undefined {
const key = Symbol.for("pi-subagents:manager");
return (globalThis as any)[key] as SubagentBridge | undefined;
}
/** Build a prompt for a task being executed by a subagent. */
function buildTaskPrompt(task: { id: string; subject: string; description: string }, additionalContext?: string): string {
let prompt = `You are executing task #${task.id}: "${task.subject}"\n\n${task.description}`;
if (additionalContext) prompt += `\n\n${additionalContext}`;
prompt += `\n\nComplete this task fully. Do not attempt to manage tasks yourself.`;
return prompt;
}
// ── Subagent completion listener ──
// Listens for subagent lifecycle events to update task status and optionally cascade.
// Success → mark task completed, cascade if enabled
pi.events.on("subagents:completed", (data) => {
const { id } = data as { id: string };
const task = store.list().find(t => t.metadata?.agentId === id);
if (!task) return;
store.update(task.id, { status: "completed" });
widget.setActiveTask(task.id, false);
// Auto-cascade: find unblocked dependents with agentType
if (autoCascadeEnabled && cascadeConfig && latestCtx) {
const bridge = getSubagentBridge();
if (bridge) {
const unblocked = store.list().filter(t =>
t.status === "pending" &&
t.metadata?.agentType &&
t.blockedBy.includes(task.id) &&
t.blockedBy.every(depId => store.get(depId)?.status === "completed")
);
for (const next of unblocked) {
store.update(next.id, { status: "in_progress" });
const prompt = buildTaskPrompt(next, cascadeConfig.additionalContext);
const agentId = bridge.spawn(pi, latestCtx,
next.metadata.agentType, prompt, {
description: next.subject,
isBackground: true,
maxTurns: cascadeConfig.maxTurns,
});
store.update(next.id, { owner: agentId, metadata: { ...next.metadata, agentId } });
widget.setActiveTask(next.id);
}
}
}
widget.update();
});
// Failure → store error, revert to pending, don't cascade (branch stops)
pi.events.on("subagents:failed", (data) => {
const { id, error, status } = data as { id: string; error?: string; status: string };
const task = store.list().find(t => t.metadata?.agentId === id);
if (!task) return;
store.update(task.id, {
status: "pending",
metadata: { ...task.metadata, lastError: error || status },
});
widget.setActiveTask(task.id, false);
widget.update();
});
// ── Turn tracking for system-reminder injection ──
let currentTurn = 0;
let lastTaskToolUseTurn = 0;
@@ -107,6 +182,16 @@ export default function (pi: ExtensionAPI) {
line += ` [blocked by ${openBlockers.map(id => "#" + id).join(", ")}]`;
}
}
// Mark agent-typed pending tasks with all deps completed as READY
if (t.status === "pending" && t.metadata?.agentType) {
const allDepsCompleted = t.blockedBy.length === 0 || t.blockedBy.every(bid => {
const blocker = store.get(bid);
return blocker && blocker.status === "completed";
});
if (allDepsCompleted) {
line += ` [READY — use TaskExecute to start]`;
}
}
return line;
}).join("\n");
@@ -115,8 +200,9 @@ export default function (pi: ExtensionAPI) {
};
});
// Grab UI context from first tool execution
// Grab UI + extension context from every tool execution
pi.on("tool_execution_start", async (_event, ctx) => {
latestCtx = ctx;
widget.setUICtx(ctx.ui as UICtx);
widget.update();
});
@@ -167,7 +253,8 @@ All tasks are created with status \`pending\`.
- Create tasks with clear, specific subjects that describe the outcome
- Include enough detail in the description for another agent to understand and complete the task
- After creating tasks, use TaskUpdate to set up dependencies (blocks/blockedBy) if needed
- Check TaskList first to avoid creating duplicate tasks`,
- Check TaskList first to avoid creating duplicate tasks
- Include \`agentType\` (e.g., "general-purpose", "Explore") to mark tasks for subagent execution via TaskExecute`,
promptGuidelines: [
"When working on complex multi-step tasks, use TaskCreate to track progress and TaskUpdate to update status.",
"Mark tasks as in_progress before starting work and completed when done.",
@@ -177,11 +264,14 @@ All tasks are created with status \`pending\`.
subject: Type.String({ description: "A brief title for the task" }),
description: Type.String({ description: "A detailed description of what needs to be done" }),
activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress (e.g., 'Running tests')" })),
agentType: Type.Optional(Type.String({ description: "Agent type for subagent execution (e.g., 'general-purpose', 'Explore'). Tasks with agentType can be started via TaskExecute." })),
metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Arbitrary metadata to attach to the task" })),
}),
execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const task = store.create(params.subject, params.description, params.activeForm, params.metadata);
const meta = params.metadata ?? {};
if (params.agentType) meta.agentType = params.agentType;
const task = store.create(params.subject, params.description, params.activeForm, Object.keys(meta).length > 0 ? meta : undefined);
widget.update();
return Promise.resolve(textResult(`Task #${task.id} created successfully: ${task.subject}`));
},
@@ -511,6 +601,103 @@ Set up task dependencies:
},
});
// ──────────────────────────────────────────────────
// Tool 7: TaskExecute
// ──────────────────────────────────────────────────
pi.registerTool({
name: "TaskExecute",
label: "TaskExecute",
description: `Execute one or more tasks as subagents. Requires pi-chonky-subagents extension.
## When to Use This Tool
- To start execution of tasks that have \`agentType\` set (created via TaskCreate with agentType parameter)
- Tasks must be \`pending\` with all blockedBy dependencies \`completed\`
- Each task runs as an independent background subagent
## Parameters
- **task_ids**: Array of task IDs to execute
- **additional_context**: Extra context appended to each agent's prompt
- **model**: Model override for agents (e.g., "sonnet", "haiku")
- **max_turns**: Maximum turns per agent`,
parameters: Type.Object({
task_ids: Type.Array(Type.String(), { description: "Task IDs to execute as subagents" }),
additional_context: Type.Optional(Type.String({ description: "Extra context for agent prompts" })),
model: Type.Optional(Type.String({ description: "Model override for agents" })),
max_turns: Type.Optional(Type.Number({ description: "Max turns per agent", minimum: 1 })),
}),
execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const bridge = getSubagentBridge();
if (!bridge) {
return Promise.resolve(textResult(
"TaskExecute requires the pi-chonky-subagents extension to be loaded. " +
"Install and enable it, then try again."
));
}
const results: string[] = [];
const launched: string[] = [];
for (const taskId of params.task_ids) {
const task = store.get(taskId);
if (!task) {
results.push(`#${taskId}: not found`);
continue;
}
if (task.status !== "pending") {
results.push(`#${taskId}: not pending (status: ${task.status})`);
continue;
}
if (!task.metadata?.agentType) {
results.push(`#${taskId}: no agentType set — create with agentType parameter or update metadata`);
continue;
}
// Check all blockers are completed
const openBlockers = task.blockedBy.filter(bid => {
const blocker = store.get(bid);
return !blocker || blocker.status !== "completed";
});
if (openBlockers.length > 0) {
results.push(`#${taskId}: blocked by ${openBlockers.map(id => "#" + id).join(", ")}`);
continue;
}
// Mark in_progress and spawn agent
store.update(taskId, { status: "in_progress" });
const prompt = buildTaskPrompt(task, params.additional_context);
const agentId = bridge.spawn(pi, ctx, task.metadata.agentType, prompt, {
description: task.subject,
isBackground: true,
maxTurns: params.max_turns,
});
store.update(taskId, { owner: agentId, metadata: { ...task.metadata, agentId } });
widget.setActiveTask(taskId);
launched.push(`#${taskId} → agent ${agentId}`);
}
// Save cascade config for the completion listener
cascadeConfig = {
additionalContext: params.additional_context,
model: params.model,
maxTurns: params.max_turns,
};
widget.update();
const lines: string[] = [];
if (launched.length > 0) lines.push(`Launched ${launched.length} agent(s):\n${launched.join("\n")}`);
if (results.length > 0) lines.push(`Skipped:\n${results.join("\n")}`);
if (lines.length === 0) lines.push("No tasks to execute.");
return Promise.resolve(textResult(lines.join("\n\n")));
},
});
// ──────────────────────────────────────────────────
// /tasks command
// ──────────────────────────────────────────────────
@@ -528,6 +715,7 @@ Set up task dependencies:
const choices: string[] = [
`View all tasks (${taskCount})`,
"Create task",
"Settings",
];
if (completedCount > 0) choices.push(`Clear completed (${completedCount})`);
@@ -538,6 +726,8 @@ Set up task dependencies:
await viewTasks();
} else if (choice === "Create task") {
await createTask();
} else if (choice === "Settings") {
await settingsMenu();
} else if (choice.startsWith("Clear")) {
store.clearCompleted();
widget.update();
@@ -611,6 +801,18 @@ Set up task dependencies:
return viewTasks();
};
const settingsMenu = async (): Promise<void> => {
const cascadeLabel = `Auto-execute tasks with agents: ${autoCascadeEnabled ? "ON" : "OFF"}`;
const choices = [cascadeLabel, "← Back"];
const selected = await ui.select("Settings", choices);
if (!selected || selected === "← Back") return mainMenu();
if (selected === cascadeLabel) {
autoCascadeEnabled = !autoCascadeEnabled;
return settingsMenu();
}
return mainMenu();
};
const createTask = async (): Promise<void> => {
const subject = await ui.input("Task subject");
if (!subject) return mainMenu();
+8
View File
@@ -24,6 +24,14 @@ export interface TaskStoreData {
tasks: Task[];
}
/** Bridge to the pi-chonky-subagents extension via Symbol.for global registry. */
export interface SubagentBridge {
waitForAll(): Promise<void>;
hasRunning(): boolean;
spawn(pi: any, ctx: any, type: string, prompt: string, options: any): string;
getRecord(id: string): any | undefined;
}
/** Background process associated with a task. */
export interface BackgroundProcess {
taskId: string;
+7 -2
View File
@@ -191,6 +191,8 @@ export class TaskWidget {
let text: string;
if (isActive) {
const form = task.activeForm || task.subject;
const agentId = task.metadata?.agentId;
const agentLabel = agentId ? ` (agent ${agentId.slice(0, 5)})` : "";
const m = this.metrics.get(task.id);
let stats = "";
if (m) {
@@ -202,11 +204,14 @@ export class TaskWidget {
? ` ${theme.fg("dim", `(${elapsed} · ${tokenParts.join(" ")})`)}`
: ` ${theme.fg("dim", `(${elapsed})`)}`;
}
text = ` ${icon} ${theme.fg("accent", form + "…")}${stats}`;
text = ` ${icon} ${theme.fg("accent", form + agentLabel + "…")}${stats}`;
} else if (task.status === "completed") {
text = ` ${theme.fg("dim", icon)} ${theme.fg("dim", theme.strikethrough(task.subject))}`;
} else {
text = ` ${icon} ${task.subject}`;
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
: "";
text = ` ${icon} ${task.subject}${agentSuffix}`;
}
lines.push(truncate(text + suffix));
+584
View File
@@ -0,0 +1,584 @@
/**
* Tests for task-subagent integration: TaskExecute tool, completion listener,
* auto-cascade, and widget agent ID display.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { TaskStore } from "../src/task-store.js";
import { TaskWidget, type UICtx, type Theme } from "../src/ui/task-widget.js";
import type { SubagentBridge } from "../src/types.js";
import initExtension from "../src/index.js";
// ---- Mock pi ----
/** 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(),
},
};
}
// ---- Mock subagent bridge ----
function mockBridge(): SubagentBridge & { spawned: Array<{ type: string; prompt: string; options: any }> } {
let idCounter = 0;
const spawned: Array<{ id: string; type: string; prompt: string; options: any }> = [];
return {
spawned,
waitForAll: async () => {},
hasRunning: () => false,
spawn(_pi: any, _ctx: any, type: string, prompt: string, options: any) {
const id = `agent-${++idCounter}`;
spawned.push({ id, type, prompt, options });
return id;
},
getRecord(id: string) {
return spawned.find(s => s.id === id) ? { id, status: "running" } : undefined;
},
};
}
/** Install/remove a mock bridge on the global registry. */
function installBridge(bridge: SubagentBridge) {
const key = Symbol.for("pi-subagents:manager");
(globalThis as any)[key] = bridge;
return () => { delete (globalThis as any)[key]; };
}
// ---- Tests ----
describe("TaskExecute", () => {
let mock: ReturnType<typeof mockPi>;
let bridge: ReturnType<typeof mockBridge>;
let removeBridge: () => void;
beforeEach(() => {
mock = mockPi();
initExtension(mock.pi as any);
bridge = mockBridge();
removeBridge = installBridge(bridge);
});
afterEach(() => {
removeBridge();
});
it("is registered as a tool", () => {
expect(mock.tools.has("TaskExecute")).toBe(true);
});
it("returns error when subagent bridge is not loaded", async () => {
removeBridge();
// Create a task with agentType
await mock.executeTool("TaskCreate", {
subject: "Test task",
description: "Do something",
agentType: "general-purpose",
});
const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
expect(result.content[0].text).toContain("requires the pi-chonky-subagents extension");
});
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 bridge was called
expect(bridge.spawned).toHaveLength(1);
expect(bridge.spawned[0].type).toBe("general-purpose");
expect(bridge.spawned[0].prompt).toContain("Run the test suite");
expect(bridge.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(bridge.spawned[0].prompt).toContain("Focus on REST endpoints only");
expect(bridge.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("Completion listener", () => {
let mock: ReturnType<typeof mockPi>;
let bridge: ReturnType<typeof mockBridge>;
let removeBridge: () => void;
beforeEach(() => {
mock = mockPi();
initExtension(mock.pi as any);
bridge = mockBridge();
removeBridge = installBridge(bridge);
});
afterEach(() => {
removeBridge();
});
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 bridge: ReturnType<typeof mockBridge>;
let removeBridge: () => void;
beforeEach(() => {
mock = mockPi();
initExtension(mock.pi as any);
bridge = mockBridge();
removeBridge = installBridge(bridge);
});
afterEach(() => {
removeBridge();
});
/** Enable auto-cascade by toggling the setting via the /tasks command mock. */
function enableAutoCascade() {
// Auto-cascade is toggled via module-level state. Since we can't access it
// directly, we test that WITHOUT enabling it, cascade doesn't happen,
// and test the cascade logic indirectly via event flow.
// For a proper toggle test we'd need to invoke the /tasks command handler,
// but that requires a full UI mock. Instead we test the default (off) behavior.
}
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(bridge.spawned).toHaveLength(1);
// Complete A
mock.emitEvent("subagents:completed", { id: "agent-1" });
// B should NOT have been auto-started
expect(bridge.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(bridge.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(bridge.spawned).toHaveLength(1);
});
});
describe("System prompt READY tags", () => {
it("marks unblocked agent-typed pending tasks as READY", async () => {
// Capture return values from lifecycle handlers
const lifecycleHandlers: Array<(...args: any[]) => any> = [];
const mock = mockPi();
const origOn = mock.pi.on.bind(mock.pi);
mock.pi.on = ((event: string, handler: any) => {
origOn(event, handler);
if (event === "before_agent_start") lifecycleHandlers.push(handler);
}) as any;
initExtension(mock.pi as any);
await mock.executeTool("TaskCreate", {
subject: "Ready task",
description: "Desc",
agentType: "general-purpose",
});
// Call the before_agent_start handler directly to get its return value
const result = await lifecycleHandlers[0]({ systemPrompt: "base" });
expect(result.systemPrompt).toContain("[READY — use TaskExecute to start]");
expect(result.systemPrompt).toContain("Ready task");
});
it("does not mark blocked agent tasks as READY", async () => {
const lifecycleHandlers: Array<(...args: any[]) => any> = [];
const mock = mockPi();
const origOn = mock.pi.on.bind(mock.pi);
mock.pi.on = ((event: string, handler: any) => {
origOn(event, handler);
if (event === "before_agent_start") lifecycleHandlers.push(handler);
}) as any;
initExtension(mock.pi as any);
await mock.executeTool("TaskCreate", {
subject: "Blocker",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskCreate", {
subject: "Blocked task",
description: "Desc",
agentType: "general-purpose",
});
await mock.executeTool("TaskUpdate", { taskId: "2", addBlockedBy: ["1"] });
const result = await lifecycleHandlers[0]({ systemPrompt: "base" });
// Task 1 should be READY, task 2 should NOT
expect(result.systemPrompt).toContain("#1 [pending] Blocker [READY");
expect(result.systemPrompt).not.toContain("#2 [pending] Blocked task [READY");
});
it("does not mark tasks without agentType as READY", async () => {
const lifecycleHandlers: Array<(...args: any[]) => any> = [];
const mock = mockPi();
const origOn = mock.pi.on.bind(mock.pi);
mock.pi.on = ((event: string, handler: any) => {
origOn(event, handler);
if (event === "before_agent_start") lifecycleHandlers.push(handler);
}) as any;
initExtension(mock.pi as any);
await mock.executeTool("TaskCreate", {
subject: "Manual task",
description: "Desc",
});
const result = await lifecycleHandlers[0]({ systemPrompt: "base" });
expect(result.systemPrompt).not.toContain("READY");
});
});
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");
});
});