mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 16:46:17 +08:00
v0.3.0 - @tintinweb/pi-tasks ♥️ @tintinweb/pi-subagents
This commit is contained in:
+14
-3
@@ -5,10 +5,20 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.3.0] - 2026-03-14
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Eventbus RPC for subagent communication** — replaced the `Symbol.for` global registry bridge with a proper eventbus RPC protocol. [`pi-tasks`](https://github.com/tintinweb/pi-tasks) now communicates with `@tintinweb/pi-subagents` via scoped request/reply channels (`subagents:rpc:spawn`, `subagents:rpc:ping`), eliminating shared mutable global state and enabling reliable cross-extension coordination regardless of load order.
|
||||||
|
- **Presence detection** — two-path handshake: (1) ping RPC on init with scoped reply channel, (2) `subagents:ready` broadcast listener. Works whether [`pi-subagents`](https://github.com/tintinweb/pi-subagents) loads before or after [`pi-tasks`](https://github.com/tintinweb/pi-tasks).
|
||||||
|
- **Agent-task mapping** — in-memory `agentTaskMap` (agentId → taskId) replaces linear `store.list().find()` scans for O(1) completion event lookup.
|
||||||
|
- **Spawn error handling** — `spawnSubagent()` returns a Promise with 30s timeout. Failed spawns revert tasks to `pending` with error in metadata instead of silently failing.
|
||||||
|
- **Removed `SubagentBridge` type** — the `types.ts` interface for the global registry bridge is no longer needed.
|
||||||
|
- **Widget icon colors** — completed tasks show green `✔`, in-progress tasks show accent-colored `◼` (matching Claude Code's UI).
|
||||||
|
|
||||||
## [0.2.0] - 2026-03-12
|
## [0.2.0] - 2026-03-12
|
||||||
|
|
||||||
### Added
|
### 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.
|
- **`TaskExecute` tool** — execute tasks as background subagents via @tintinweb/pi-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.
|
- **`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.
|
- **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.
|
- **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.
|
||||||
@@ -18,8 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **`SubagentBridge` type** — typed interface for the cross-extension Symbol.for bridge.
|
- **`SubagentBridge` type** — typed interface for the cross-extension Symbol.for bridge.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- `pi-chonky-subagents` global registry now exposes `spawn()` and `getRecord()` in addition to `waitForAll()` and `hasRunning()`.
|
- `@tintinweb/pi-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`.
|
- `@tintinweb/pi-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).
|
- `AgentManager` accepts an optional `onStart` callback, fired when an agent transitions to running (including from queue).
|
||||||
|
|
||||||
## [0.1.0] - 2026-03-12
|
## [0.1.0] - 2026-03-12
|
||||||
@@ -41,5 +51,6 @@ 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).
|
- **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.
|
- **78 unit tests** — task store CRUD, dependencies, warnings, file persistence; widget rendering, icons, spinners, token/duration formatting; process tracker lifecycle.
|
||||||
|
|
||||||
|
[0.3.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.3.0
|
||||||
[0.2.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.2.0
|
[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
|
[0.1.0]: https://github.com/tintinweb/pi-tasks/releases/tag/v0.1.0
|
||||||
|
|||||||
@@ -21,7 +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
|
- **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
|
- **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
|
- **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.
|
- **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.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ Stop a running background task process. Sends SIGTERM, waits 5 seconds, then SIG
|
|||||||
|
|
||||||
### `TaskExecute`
|
### `TaskExecute`
|
||||||
|
|
||||||
Execute one or more tasks as background subagents. Requires [pi-chonky-subagents](https://github.com/tintinweb/pi-subagents).
|
Execute one or more tasks as background subagents. Requires [@tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents).
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
|-----------|------|-------------|
|
|-----------|------|-------------|
|
||||||
@@ -206,6 +206,56 @@ Tasks
|
|||||||
- **Settings** — toggle auto-cascade (auto-execute unblocked agent tasks on completion)
|
- **Settings** — toggle auto-cascade (auto-execute unblocked agent tasks on completion)
|
||||||
- **Clear completed** — remove all completed tasks
|
- **Clear completed** — remove all completed tasks
|
||||||
|
|
||||||
|
## 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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@tintinweb/pi-tasks",
|
"name": "@tintinweb/pi-tasks",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@tintinweb/pi-tasks",
|
"name": "@tintinweb/pi-tasks",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-coding-agent": "^0.57.1",
|
"@mariozechner/pi-coding-agent": "^0.57.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@tintinweb/pi-tasks",
|
"name": "@tintinweb/pi-tasks",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.",
|
"description": "A pi extension that brings Claude Code-style task tracking and coordination to pi.",
|
||||||
"author": "tintinweb",
|
"author": "tintinweb",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+87
-46
@@ -8,7 +8,7 @@
|
|||||||
* TaskUpdate — Update task fields, status, dependencies
|
* TaskUpdate — Update task fields, status, dependencies
|
||||||
* TaskOutput — Get output from a background task process
|
* TaskOutput — Get output from a background task process
|
||||||
* TaskStop — Stop a running background task process
|
* TaskStop — Stop a running background task process
|
||||||
* TaskExecute — Execute tasks as subagents (requires pi-chonky-subagents)
|
* TaskExecute — Execute tasks as subagents (requires @tintinweb/pi-subagents)
|
||||||
*
|
*
|
||||||
* Commands:
|
* Commands:
|
||||||
* /tasks — Interactive task management menu
|
* /tasks — Interactive task management menu
|
||||||
@@ -19,7 +19,7 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import { TaskStore } from "./task-store.js";
|
import { TaskStore } from "./task-store.js";
|
||||||
import { ProcessTracker } from "./process-tracker.js";
|
import { ProcessTracker } from "./process-tracker.js";
|
||||||
import { TaskWidget, type UICtx } from "./ui/task-widget.js";
|
import { TaskWidget, type UICtx } from "./ui/task-widget.js";
|
||||||
import type { SubagentBridge } from "./types.js";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
@@ -50,11 +50,41 @@ export default function (pi: ExtensionAPI) {
|
|||||||
let latestCtx: ExtensionContext | undefined;
|
let latestCtx: ExtensionContext | undefined;
|
||||||
/** Cascade config — set by TaskExecute, consumed by completion listener. */
|
/** Cascade config — set by TaskExecute, consumed by completion listener. */
|
||||||
let cascadeConfig: { additionalContext?: string; model?: string; maxTurns?: number } | undefined;
|
let cascadeConfig: { additionalContext?: string; model?: string; maxTurns?: number } | undefined;
|
||||||
|
/** Maps agent IDs to task IDs for O(1) completion lookup. */
|
||||||
|
const agentTaskMap = new Map<string, string>();
|
||||||
|
|
||||||
/** Get the subagent bridge from the global registry (returns undefined if pi-chonky-subagents not loaded). */
|
// ── Subagent extension presence detection ──
|
||||||
function getSubagentBridge(): SubagentBridge | undefined {
|
// Two paths: (1) listen for ready broadcast (subagents loads first),
|
||||||
const key = Symbol.for("pi-subagents:manager");
|
// (2) send ping on our init (tasks loads first).
|
||||||
return (globalThis as any)[key] as SubagentBridge | undefined;
|
let subagentsAvailable = false;
|
||||||
|
|
||||||
|
// Ping subagents extension — scoped reply channel, no filtering needed
|
||||||
|
const pingId = randomUUID();
|
||||||
|
const unsubPing = pi.events.on(`subagents:rpc:ping:reply:${pingId}`, () => {
|
||||||
|
subagentsAvailable = true;
|
||||||
|
unsubPing();
|
||||||
|
});
|
||||||
|
pi.events.emit("subagents:rpc:ping", { requestId: pingId });
|
||||||
|
|
||||||
|
// Also listen for ready broadcast (covers: subagents loads after us)
|
||||||
|
pi.events.on("subagents:ready", () => {
|
||||||
|
subagentsAvailable = true;
|
||||||
|
unsubPing(); // clean up ping listener if still pending
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Spawn a subagent via pi.events RPC (requires @tintinweb/pi-subagents extension). */
|
||||||
|
function spawnSubagent(type: string, prompt: string, options?: any): Promise<string> {
|
||||||
|
const requestId = randomUUID();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => { unsub(); reject(new Error("subagents:rpc:spawn timeout")); }, 30000);
|
||||||
|
const unsub = pi.events.on(`subagents:rpc:spawn:reply:${requestId}`, (p: unknown) => {
|
||||||
|
const { id, error } = p as { id?: string; error?: string };
|
||||||
|
unsub(); clearTimeout(timer);
|
||||||
|
if (error) reject(new Error(error));
|
||||||
|
else resolve(id!);
|
||||||
|
});
|
||||||
|
pi.events.emit("subagents:rpc:spawn", { requestId, type, prompt, options });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a prompt for a task being executed by a subagent. */
|
/** Build a prompt for a task being executed by a subagent. */
|
||||||
@@ -69,35 +99,39 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// Listens for subagent lifecycle events to update task status and optionally cascade.
|
// Listens for subagent lifecycle events to update task status and optionally cascade.
|
||||||
|
|
||||||
// Success → mark task completed, cascade if enabled
|
// Success → mark task completed, cascade if enabled
|
||||||
pi.events.on("subagents:completed", (data) => {
|
pi.events.on("subagents:completed", async (data) => {
|
||||||
const { id } = data as { id: string };
|
const { id, result } = data as { id: string; result?: string };
|
||||||
const task = store.list().find(t => t.metadata?.agentId === id);
|
const taskId = agentTaskMap.get(id);
|
||||||
|
if (!taskId) return;
|
||||||
|
agentTaskMap.delete(id);
|
||||||
|
const task = store.get(taskId);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
store.update(task.id, { status: "completed" });
|
store.update(task.id, { status: "completed", metadata: { ...task.metadata, result } });
|
||||||
widget.setActiveTask(task.id, false);
|
widget.setActiveTask(task.id, false);
|
||||||
|
|
||||||
// Auto-cascade: find unblocked dependents with agentType
|
// Auto-cascade: find unblocked dependents with agentType
|
||||||
if (autoCascadeEnabled && cascadeConfig && latestCtx) {
|
if (autoCascadeEnabled && cascadeConfig && latestCtx) {
|
||||||
const bridge = getSubagentBridge();
|
const unblocked = store.list().filter(t =>
|
||||||
if (bridge) {
|
t.status === "pending" &&
|
||||||
const unblocked = store.list().filter(t =>
|
t.metadata?.agentType &&
|
||||||
t.status === "pending" &&
|
t.blockedBy.includes(task.id) &&
|
||||||
t.metadata?.agentType &&
|
t.blockedBy.every(depId => store.get(depId)?.status === "completed")
|
||||||
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" });
|
||||||
for (const next of unblocked) {
|
const prompt = buildTaskPrompt(next, cascadeConfig.additionalContext);
|
||||||
store.update(next.id, { status: "in_progress" });
|
try {
|
||||||
const prompt = buildTaskPrompt(next, cascadeConfig.additionalContext);
|
const agentId = await spawnSubagent(next.metadata.agentType, prompt, {
|
||||||
const agentId = bridge.spawn(pi, latestCtx,
|
description: next.subject,
|
||||||
next.metadata.agentType, prompt, {
|
isBackground: true,
|
||||||
description: next.subject,
|
maxTurns: cascadeConfig.maxTurns,
|
||||||
isBackground: true,
|
});
|
||||||
maxTurns: cascadeConfig.maxTurns,
|
agentTaskMap.set(agentId, next.id);
|
||||||
});
|
|
||||||
store.update(next.id, { owner: agentId, metadata: { ...next.metadata, agentId } });
|
store.update(next.id, { owner: agentId, metadata: { ...next.metadata, agentId } });
|
||||||
widget.setActiveTask(next.id);
|
widget.setActiveTask(next.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
store.update(next.id, { status: "pending", metadata: { ...next.metadata, lastError: err.message } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +141,10 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// Failure → store error, revert to pending, don't cascade (branch stops)
|
// Failure → store error, revert to pending, don't cascade (branch stops)
|
||||||
pi.events.on("subagents:failed", (data) => {
|
pi.events.on("subagents:failed", (data) => {
|
||||||
const { id, error, status } = data as { id: string; error?: string; status: string };
|
const { id, error, status } = data as { id: string; error?: string; status: string };
|
||||||
const task = store.list().find(t => t.metadata?.agentId === id);
|
const taskId = agentTaskMap.get(id);
|
||||||
|
if (!taskId) return;
|
||||||
|
agentTaskMap.delete(id);
|
||||||
|
const task = store.get(taskId);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
store.update(task.id, {
|
store.update(task.id, {
|
||||||
status: "pending",
|
status: "pending",
|
||||||
@@ -608,7 +645,7 @@ Set up task dependencies:
|
|||||||
pi.registerTool({
|
pi.registerTool({
|
||||||
name: "TaskExecute",
|
name: "TaskExecute",
|
||||||
label: "TaskExecute",
|
label: "TaskExecute",
|
||||||
description: `Execute one or more tasks as subagents. Requires pi-chonky-subagents extension.
|
description: `Execute one or more tasks as subagents. Requires @tintinweb/pi-subagents extension.
|
||||||
|
|
||||||
## When to Use This Tool
|
## When to Use This Tool
|
||||||
|
|
||||||
@@ -629,13 +666,12 @@ Set up task dependencies:
|
|||||||
max_turns: Type.Optional(Type.Number({ description: "Max turns per agent", minimum: 1 })),
|
max_turns: Type.Optional(Type.Number({ description: "Max turns per agent", minimum: 1 })),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||||
const bridge = getSubagentBridge();
|
if (!subagentsAvailable) {
|
||||||
if (!bridge) {
|
return textResult(
|
||||||
return Promise.resolve(textResult(
|
"TaskExecute requires the @tintinweb/pi-subagents extension to be loaded. " +
|
||||||
"TaskExecute requires the pi-chonky-subagents extension to be loaded. " +
|
|
||||||
"Install and enable it, then try again."
|
"Install and enable it, then try again."
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
@@ -666,18 +702,23 @@ Set up task dependencies:
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark in_progress and spawn agent
|
// Mark in_progress and spawn agent via RPC
|
||||||
store.update(taskId, { status: "in_progress" });
|
store.update(taskId, { status: "in_progress" });
|
||||||
const prompt = buildTaskPrompt(task, params.additional_context);
|
const prompt = buildTaskPrompt(task, params.additional_context);
|
||||||
const agentId = bridge.spawn(pi, ctx, task.metadata.agentType, prompt, {
|
try {
|
||||||
description: task.subject,
|
const agentId = await spawnSubagent(task.metadata.agentType, prompt, {
|
||||||
isBackground: true,
|
description: task.subject,
|
||||||
maxTurns: params.max_turns,
|
isBackground: true,
|
||||||
});
|
maxTurns: params.max_turns,
|
||||||
|
});
|
||||||
store.update(taskId, { owner: agentId, metadata: { ...task.metadata, agentId } });
|
agentTaskMap.set(agentId, taskId);
|
||||||
widget.setActiveTask(taskId);
|
store.update(taskId, { owner: agentId, metadata: { ...task.metadata, agentId } });
|
||||||
launched.push(`#${taskId} → agent ${agentId}`);
|
widget.setActiveTask(taskId);
|
||||||
|
launched.push(`#${taskId} → agent ${agentId}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
store.update(taskId, { status: "pending" });
|
||||||
|
results.push(`#${taskId}: spawn failed — ${err.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save cascade config for the completion listener
|
// Save cascade config for the completion listener
|
||||||
@@ -694,7 +735,7 @@ Set up task dependencies:
|
|||||||
if (results.length > 0) lines.push(`Skipped:\n${results.join("\n")}`);
|
if (results.length > 0) lines.push(`Skipped:\n${results.join("\n")}`);
|
||||||
if (lines.length === 0) lines.push("No tasks to execute.");
|
if (lines.length === 0) lines.push("No tasks to execute.");
|
||||||
|
|
||||||
return Promise.resolve(textResult(lines.join("\n\n")));
|
return textResult(lines.join("\n\n"));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,6 @@ export interface TaskStoreData {
|
|||||||
tasks: Task[];
|
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. */
|
/** Background process associated with a task. */
|
||||||
export interface BackgroundProcess {
|
export interface BackgroundProcess {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
|
|||||||
@@ -169,9 +169,9 @@ export class TaskWidget {
|
|||||||
if (isActive) {
|
if (isActive) {
|
||||||
icon = theme.fg("accent", spinnerChar);
|
icon = theme.fg("accent", spinnerChar);
|
||||||
} else if (task.status === "completed") {
|
} else if (task.status === "completed") {
|
||||||
icon = "✔";
|
icon = theme.fg("green", "✔");
|
||||||
} else if (task.status === "in_progress") {
|
} else if (task.status === "in_progress") {
|
||||||
icon = "◼";
|
icon = theme.fg("accent", "◼");
|
||||||
} else {
|
} else {
|
||||||
icon = "◻";
|
icon = "◻";
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ export class TaskWidget {
|
|||||||
}
|
}
|
||||||
text = ` ${icon} ${theme.fg("accent", form + agentLabel + "…")}${stats}`;
|
text = ` ${icon} ${theme.fg("accent", form + agentLabel + "…")}${stats}`;
|
||||||
} else if (task.status === "completed") {
|
} else if (task.status === "completed") {
|
||||||
text = ` ${theme.fg("dim", icon)} ${theme.fg("dim", theme.strikethrough(task.subject))}`;
|
text = ` ${icon} ${theme.fg("dim", theme.strikethrough(task.subject))}`;
|
||||||
} else {
|
} else {
|
||||||
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
|
const agentSuffix = task.status === "in_progress" && task.metadata?.agentId
|
||||||
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
|
? theme.fg("dim", ` (agent ${task.metadata.agentId.slice(0, 5)})`)
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { TaskStore } from "../src/task-store.js";
|
import { TaskStore } from "../src/task-store.js";
|
||||||
import { TaskWidget, type UICtx, type Theme } from "../src/ui/task-widget.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";
|
import initExtension from "../src/index.js";
|
||||||
|
|
||||||
// ---- Mock pi ----
|
// ---- Mock pi ----
|
||||||
@@ -76,67 +75,72 @@ function mockCtx() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mock subagent bridge ----
|
// ---- Mock subagents extension (RPC responders) ----
|
||||||
|
|
||||||
function mockBridge(): SubagentBridge & { spawned: Array<{ type: string; prompt: string; options: any }> } {
|
/** Simulates the @tintinweb/pi-subagents extension: responds to ping + spawn RPCs and emits ready. */
|
||||||
|
function installSubagentsMock(pi: { events: { on: Function; emit: Function } }) {
|
||||||
let idCounter = 0;
|
let idCounter = 0;
|
||||||
const spawned: Array<{ id: string; type: string; prompt: string; options: any }> = [];
|
const spawned: Array<{ id: string; type: string; prompt: string; options: any }> = [];
|
||||||
|
|
||||||
|
// 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}`, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
const id = `agent-${++idCounter}`;
|
||||||
|
spawned.push({ id, type, prompt, options });
|
||||||
|
pi.events.emit(`subagents:rpc:spawn:reply:${requestId}`, { id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast readiness
|
||||||
|
pi.events.emit("subagents:ready", {});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
spawned,
|
spawned,
|
||||||
waitForAll: async () => {},
|
unsub() { unsubPing(); unsubSpawn(); },
|
||||||
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 ----
|
// ---- Tests ----
|
||||||
|
|
||||||
describe("TaskExecute", () => {
|
describe("TaskExecute", () => {
|
||||||
let mock: ReturnType<typeof mockPi>;
|
let mock: ReturnType<typeof mockPi>;
|
||||||
let bridge: ReturnType<typeof mockBridge>;
|
let rpc: ReturnType<typeof installSubagentsMock>;
|
||||||
let removeBridge: () => void;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock = mockPi();
|
mock = mockPi();
|
||||||
|
// Install mock BEFORE init so ping reply is received during extension init
|
||||||
|
rpc = installSubagentsMock(mock.pi);
|
||||||
initExtension(mock.pi as any);
|
initExtension(mock.pi as any);
|
||||||
bridge = mockBridge();
|
|
||||||
removeBridge = installBridge(bridge);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
removeBridge();
|
rpc.unsub();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is registered as a tool", () => {
|
it("is registered as a tool", () => {
|
||||||
expect(mock.tools.has("TaskExecute")).toBe(true);
|
expect(mock.tools.has("TaskExecute")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns error when subagent bridge is not loaded", async () => {
|
it("returns error when subagent extension is not loaded", async () => {
|
||||||
removeBridge();
|
// Re-init without mock to simulate missing extension
|
||||||
// Create a task with agentType
|
const freshMock = mockPi();
|
||||||
await mock.executeTool("TaskCreate", {
|
initExtension(freshMock.pi as any);
|
||||||
|
|
||||||
|
await freshMock.executeTool("TaskCreate", {
|
||||||
subject: "Test task",
|
subject: "Test task",
|
||||||
description: "Do something",
|
description: "Do something",
|
||||||
agentType: "general-purpose",
|
agentType: "general-purpose",
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await mock.executeTool("TaskExecute", { task_ids: ["1"] });
|
const result = await freshMock.executeTool("TaskExecute", { task_ids: ["1"] });
|
||||||
expect(result.content[0].text).toContain("requires the pi-chonky-subagents extension");
|
expect(result.content[0].text).toContain("requires the @tintinweb/pi-subagents extension");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects non-existent tasks", async () => {
|
it("rejects non-existent tasks", async () => {
|
||||||
@@ -194,11 +198,11 @@ describe("TaskExecute", () => {
|
|||||||
expect(result.content[0].text).toContain("Launched 1 agent");
|
expect(result.content[0].text).toContain("Launched 1 agent");
|
||||||
expect(result.content[0].text).toContain("#1 → agent agent-1");
|
expect(result.content[0].text).toContain("#1 → agent agent-1");
|
||||||
|
|
||||||
// Verify the bridge was called
|
// Verify the RPC responder was called
|
||||||
expect(bridge.spawned).toHaveLength(1);
|
expect(rpc.spawned).toHaveLength(1);
|
||||||
expect(bridge.spawned[0].type).toBe("general-purpose");
|
expect(rpc.spawned[0].type).toBe("general-purpose");
|
||||||
expect(bridge.spawned[0].prompt).toContain("Run the test suite");
|
expect(rpc.spawned[0].prompt).toContain("Run the test suite");
|
||||||
expect(bridge.spawned[0].options.isBackground).toBe(true);
|
expect(rpc.spawned[0].options.isBackground).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes additional_context and max_turns to spawned agents", async () => {
|
it("passes additional_context and max_turns to spawned agents", async () => {
|
||||||
@@ -214,8 +218,8 @@ describe("TaskExecute", () => {
|
|||||||
max_turns: 10,
|
max_turns: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(bridge.spawned[0].prompt).toContain("Focus on REST endpoints only");
|
expect(rpc.spawned[0].prompt).toContain("Focus on REST endpoints only");
|
||||||
expect(bridge.spawned[0].options.maxTurns).toBe(10);
|
expect(rpc.spawned[0].options.maxTurns).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows executing tasks whose blockers are all completed", async () => {
|
it("allows executing tasks whose blockers are all completed", async () => {
|
||||||
@@ -255,20 +259,42 @@ describe("TaskExecute", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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", () => {
|
describe("Completion listener", () => {
|
||||||
let mock: ReturnType<typeof mockPi>;
|
let mock: ReturnType<typeof mockPi>;
|
||||||
let bridge: ReturnType<typeof mockBridge>;
|
let rpc: ReturnType<typeof installSubagentsMock>;
|
||||||
let removeBridge: () => void;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock = mockPi();
|
mock = mockPi();
|
||||||
|
rpc = installSubagentsMock(mock.pi);
|
||||||
initExtension(mock.pi as any);
|
initExtension(mock.pi as any);
|
||||||
bridge = mockBridge();
|
|
||||||
removeBridge = installBridge(bridge);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
removeBridge();
|
rpc.unsub();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks task completed on subagents:completed event", async () => {
|
it("marks task completed on subagents:completed event", async () => {
|
||||||
@@ -318,29 +344,18 @@ describe("Completion listener", () => {
|
|||||||
|
|
||||||
describe("Auto-cascade", () => {
|
describe("Auto-cascade", () => {
|
||||||
let mock: ReturnType<typeof mockPi>;
|
let mock: ReturnType<typeof mockPi>;
|
||||||
let bridge: ReturnType<typeof mockBridge>;
|
let rpc: ReturnType<typeof installSubagentsMock>;
|
||||||
let removeBridge: () => void;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mock = mockPi();
|
mock = mockPi();
|
||||||
|
rpc = installSubagentsMock(mock.pi);
|
||||||
initExtension(mock.pi as any);
|
initExtension(mock.pi as any);
|
||||||
bridge = mockBridge();
|
|
||||||
removeBridge = installBridge(bridge);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
removeBridge();
|
rpc.unsub();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 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 () => {
|
it("does NOT cascade when auto-cascade is off (default)", async () => {
|
||||||
// Create A → B chain
|
// Create A → B chain
|
||||||
await mock.executeTool("TaskCreate", {
|
await mock.executeTool("TaskCreate", {
|
||||||
@@ -357,13 +372,13 @@ describe("Auto-cascade", () => {
|
|||||||
|
|
||||||
// Execute A
|
// Execute A
|
||||||
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
|
await mock.executeTool("TaskExecute", { task_ids: ["1"] });
|
||||||
expect(bridge.spawned).toHaveLength(1);
|
expect(rpc.spawned).toHaveLength(1);
|
||||||
|
|
||||||
// Complete A
|
// Complete A
|
||||||
mock.emitEvent("subagents:completed", { id: "agent-1" });
|
mock.emitEvent("subagents:completed", { id: "agent-1" });
|
||||||
|
|
||||||
// B should NOT have been auto-started
|
// B should NOT have been auto-started
|
||||||
expect(bridge.spawned).toHaveLength(1);
|
expect(rpc.spawned).toHaveLength(1);
|
||||||
|
|
||||||
// B should still be pending
|
// B should still be pending
|
||||||
const result = await mock.executeTool("TaskGet", { taskId: "2" });
|
const result = await mock.executeTool("TaskGet", { taskId: "2" });
|
||||||
@@ -387,7 +402,7 @@ describe("Auto-cascade", () => {
|
|||||||
mock.emitEvent("subagents:failed", { id: "agent-1", error: "crashed", status: "error" });
|
mock.emitEvent("subagents:failed", { id: "agent-1", error: "crashed", status: "error" });
|
||||||
|
|
||||||
// B should not start
|
// B should not start
|
||||||
expect(bridge.spawned).toHaveLength(1);
|
expect(rpc.spawned).toHaveLength(1);
|
||||||
const result = await mock.executeTool("TaskGet", { taskId: "2" });
|
const result = await mock.executeTool("TaskGet", { taskId: "2" });
|
||||||
expect(result.content[0].text).toContain("Status: pending");
|
expect(result.content[0].text).toContain("Status: pending");
|
||||||
});
|
});
|
||||||
@@ -409,7 +424,7 @@ describe("Auto-cascade", () => {
|
|||||||
mock.emitEvent("subagents:completed", { id: "agent-1" });
|
mock.emitEvent("subagents:completed", { id: "agent-1" });
|
||||||
|
|
||||||
// Manual task should stay pending
|
// Manual task should stay pending
|
||||||
expect(bridge.spawned).toHaveLength(1);
|
expect(rpc.spawned).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -488,6 +503,177 @@ describe("System prompt READY tags", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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("requires the @tintinweb/pi-subagents extension");
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
initExtension(mock.pi as any);
|
||||||
|
|
||||||
|
// Emit ready AFTER init so the listener is registered — marks subagents
|
||||||
|
// as available, but there's no spawn handler installed
|
||||||
|
mock.pi.events.emit("subagents:ready", {});
|
||||||
|
|
||||||
|
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("requires the @tintinweb/pi-subagents extension");
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Widget agent ID display", () => {
|
describe("Widget agent ID display", () => {
|
||||||
let store: TaskStore;
|
let store: TaskStore;
|
||||||
let widget: TaskWidget;
|
let widget: TaskWidget;
|
||||||
|
|||||||
Reference in New Issue
Block a user