mirror of
https://github.com/wassname/pi-lgtm.git
synced 2026-06-27 17:01:35 +08:00
Merge pull request #4 from tintinweb/feat/stop-subagents
feat: subagents-api (eventbus) subagent-stop; switch to new API envs; fix bugs
This commit is contained in:
@@ -5,6 +5,25 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **RPC-based subagent spawning** — `TaskExecute` now communicates with `@tintinweb/pi-subagents` via a standardized RPC envelope (`rpcCall` helper) with protocol version negotiation and timeout handling.
|
||||
- **RPC-based subagent stopping** — `stopSubagent` sends stop requests via `subagents:rpc:stop` event bus RPC.
|
||||
- **TaskOutput supports subagent tasks** — can wait for subagent completion with blocking/timeout, using `subagents:completed` and `subagents:failed` events.
|
||||
- **TaskStop supports subagent tasks** — stops running subagents via RPC and marks the task as completed.
|
||||
- **Debug logging** — set `PI_TASKS_DEBUG=1` to trace RPC communication (request/reply/timeout) and spawn errors to stderr.
|
||||
- **TaskExecute prompt guidelines** — agents are instructed not to use the Agent tool for tasks already launched via TaskExecute.
|
||||
- **Biome linter** — added [Biome](https://biomejs.dev/) for correctness linting.
|
||||
|
||||
### Changed
|
||||
- **TaskOutput/TaskStop accept agent IDs** — both tools now resolve agent IDs (including partial prefixes) to task IDs via `agentTaskMap`, fixing the mismatch where TaskExecute returns agent IDs but TaskOutput/TaskStop only accepted task IDs.
|
||||
- **TaskGet shows metadata** — non-empty metadata is now displayed in TaskGet output as JSON.
|
||||
- **TaskGet filters completed blockers** — consistent with TaskList, TaskGet now only shows open (non-completed) blockers instead of all dependency edges.
|
||||
- **TaskExecute success message** — now includes guidance to use TaskOutput for progress and not spawn duplicate agents.
|
||||
- **Softened TaskExecute description** — removed "Requires @tintinweb/pi-subagents extension" from the tool description to prevent agents from refusing to use it when the extension is loaded.
|
||||
- **Stopped subagents handled gracefully** — `subagents:failed` listener now distinguishes intentional stops (status `"stopped"` → mark completed, preserve partial result) from actual errors (revert to pending).
|
||||
|
||||
## [0.3.3] - 2026-03-17
|
||||
|
||||
### Added
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.8/schema.json",
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"recommended": false
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noControlCharactersInRegex": "off",
|
||||
"noEmptyInterface": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": false
|
||||
},
|
||||
"files": {
|
||||
"includes": [
|
||||
"src/**/*.ts",
|
||||
"test/**/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -27,13 +27,18 @@
|
||||
"@sinclair/typebox": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"prepublishOnly": "npm run lint && npm run typecheck && npm run test && npm run build",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "biome check src/ test/",
|
||||
"lint:fix": "biome check --fix src/ test/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"@biomejs/biome": "^2.3.5",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"pi": {
|
||||
|
||||
+163
-37
@@ -24,6 +24,13 @@ import { openSettingsMenu } from "./ui/settings-menu.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// ---- Debug ----
|
||||
|
||||
const DEBUG = !!process.env.PI_TASKS_DEBUG;
|
||||
function debug(...args: unknown[]) {
|
||||
if (DEBUG) console.error("[pi-tasks]", ...args);
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function textResult(msg: string) {
|
||||
@@ -74,40 +81,80 @@ export default function (pi: ExtensionAPI) {
|
||||
/** Maps agent IDs to task IDs for O(1) completion lookup. */
|
||||
const agentTaskMap = new Map<string, string>();
|
||||
|
||||
// ── Subagent extension presence detection ──
|
||||
// Two paths: (1) listen for ready broadcast (subagents loads first),
|
||||
// (2) send ping on our init (tasks loads first).
|
||||
let subagentsAvailable = false;
|
||||
// ── Subagent RPC helpers ──
|
||||
|
||||
// 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 });
|
||||
/** RPC reply envelope — matches pi-mono's RpcResponse shape. */
|
||||
type RpcReply<T = void> =
|
||||
| { success: true; data?: T }
|
||||
| { success: false; error: string };
|
||||
|
||||
// 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
|
||||
});
|
||||
/** Call a subagents RPC method: emit request, wait for scoped reply, unwrap envelope. */
|
||||
function rpcCall<T>(channel: string, params: Record<string, unknown>, timeoutMs: number): Promise<T> {
|
||||
const requestId = randomUUID();
|
||||
debug(`rpc:send ${channel}`, { requestId });
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
unsub();
|
||||
debug(`rpc:timeout ${channel}`, { requestId });
|
||||
reject(new Error(`${channel} timeout`));
|
||||
}, timeoutMs);
|
||||
const unsub = pi.events.on(`${channel}:reply:${requestId}`, (raw: unknown) => {
|
||||
unsub(); clearTimeout(timer);
|
||||
debug(`rpc:reply ${channel}`, { requestId, raw });
|
||||
const reply = raw as RpcReply<T>;
|
||||
if (reply.success) resolve(reply.data as T);
|
||||
else reject(new Error(reply.error));
|
||||
});
|
||||
pi.events.emit(channel, { requestId, ...params });
|
||||
debug(`rpc:emitted ${channel}`, { requestId });
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 });
|
||||
});
|
||||
debug("spawn:call", { type, options: { ...options, prompt: undefined } });
|
||||
return rpcCall<{ id: string }>("subagents:rpc:spawn", { type, prompt, options }, 30_000)
|
||||
.then(d => { debug("spawn:ok", d); return d.id; });
|
||||
}
|
||||
|
||||
/** Stop a subagent via pi.events RPC (requires @tintinweb/pi-subagents extension). */
|
||||
function stopSubagent(agentId: string): Promise<void> {
|
||||
return rpcCall<void>("subagents:rpc:stop", { agentId }, 10_000).catch(() => {});
|
||||
}
|
||||
|
||||
// ── Subagent extension presence & version detection ──
|
||||
const PROTOCOL_VERSION = 2;
|
||||
let subagentsAvailable = false;
|
||||
let pendingWarning: string | undefined;
|
||||
|
||||
/** Ping subagents and check protocol version. Works with any handler version. */
|
||||
function checkSubagentsVersion() {
|
||||
const requestId = randomUUID();
|
||||
const timer = setTimeout(() => { unsub(); }, 5_000);
|
||||
const unsub = pi.events.on(`subagents:rpc:ping:reply:${requestId}`, (raw: unknown) => {
|
||||
unsub(); clearTimeout(timer);
|
||||
const remoteVersion = (raw as any)?.data?.version as number | undefined;
|
||||
if (remoteVersion === undefined) {
|
||||
pendingWarning =
|
||||
"@tintinweb/pi-subagents is outdated — please update for task execution support.";
|
||||
} else if (remoteVersion > PROTOCOL_VERSION) {
|
||||
pendingWarning =
|
||||
`@tintinweb/pi-tasks is outdated (protocol v${PROTOCOL_VERSION}, ` +
|
||||
`pi-subagents has v${remoteVersion}) — please update for task execution support.`;
|
||||
} else if (remoteVersion < PROTOCOL_VERSION) {
|
||||
pendingWarning =
|
||||
`@tintinweb/pi-subagents is outdated (protocol v${remoteVersion}, ` +
|
||||
`pi-tasks has v${PROTOCOL_VERSION}) — please update for task execution support.`;
|
||||
} else {
|
||||
subagentsAvailable = true;
|
||||
}
|
||||
});
|
||||
pi.events.emit("subagents:rpc:ping", { requestId });
|
||||
}
|
||||
|
||||
checkSubagentsVersion();
|
||||
pi.events.on("subagents:ready", () => checkSubagentsVersion());
|
||||
|
||||
/** 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}`;
|
||||
@@ -160,17 +207,22 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
|
||||
// Failure → store error, revert to pending, don't cascade (branch stops)
|
||||
// Intentional stop (status === "stopped") → mark completed, preserve partial result
|
||||
pi.events.on("subagents:failed", (data) => {
|
||||
const { id, error, status } = data as { id: string; error?: string; status: string };
|
||||
const { id, error, result, status } = data as { id: string; error?: string; result?: string; status: string };
|
||||
const taskId = agentTaskMap.get(id);
|
||||
if (!taskId) return;
|
||||
agentTaskMap.delete(id);
|
||||
const task = store.get(taskId);
|
||||
if (!task) return;
|
||||
store.update(task.id, {
|
||||
status: "pending",
|
||||
metadata: { ...task.metadata, lastError: error || status },
|
||||
});
|
||||
|
||||
if (status === "stopped") {
|
||||
// Intentional stop — mark completed, preserve partial result
|
||||
store.update(task.id, { status: "completed", metadata: { ...task.metadata, result: result || task.metadata?.result } });
|
||||
} else {
|
||||
// Actual error — revert to pending
|
||||
store.update(task.id, { status: "pending", metadata: { ...task.metadata, lastError: error || status } });
|
||||
}
|
||||
widget.setActiveTask(task.id, false);
|
||||
widget.update();
|
||||
});
|
||||
@@ -265,6 +317,10 @@ export default function (pi: ExtensionAPI) {
|
||||
widget.setUICtx(ctx.ui as UICtx);
|
||||
upgradeStoreIfNeeded(ctx);
|
||||
showPersistedTasks();
|
||||
if (pendingWarning) {
|
||||
ctx.ui.notify(pendingWarning, "warning");
|
||||
pendingWarning = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
// session_switch fires on resume (reason: "resume") — reload persisted tasks.
|
||||
@@ -468,12 +524,24 @@ Returns full task details:
|
||||
lines.push(`Description: ${desc}`);
|
||||
|
||||
if (task.blockedBy.length > 0) {
|
||||
lines.push(`Blocked by: ${task.blockedBy.map(id => "#" + id).join(", ")}`);
|
||||
const openBlockers = task.blockedBy.filter(bid => {
|
||||
const blocker = store.get(bid);
|
||||
return blocker && blocker.status !== "completed";
|
||||
});
|
||||
if (openBlockers.length > 0) {
|
||||
lines.push(`Blocked by: ${openBlockers.map(id => "#" + id).join(", ")}`);
|
||||
}
|
||||
}
|
||||
if (task.blocks.length > 0) {
|
||||
lines.push(`Blocks: ${task.blocks.map(id => "#" + id).join(", ")}`);
|
||||
}
|
||||
|
||||
// Show metadata if non-empty
|
||||
const metaKeys = Object.keys(task.metadata);
|
||||
if (metaKeys.length > 0) {
|
||||
lines.push(`Metadata: ${JSON.stringify(task.metadata)}`);
|
||||
}
|
||||
|
||||
return Promise.resolve(textResult(lines.join("\n")));
|
||||
},
|
||||
});
|
||||
@@ -630,6 +698,39 @@ Set up task dependencies:
|
||||
|
||||
const processOutput = tracker.getOutput(task_id);
|
||||
if (!processOutput) {
|
||||
// No shell process — check if this is a subagent task
|
||||
// Support both task IDs and agent IDs (resolve agent ID → task ID)
|
||||
let resolvedId = task_id;
|
||||
if (!store.get(resolvedId)) {
|
||||
// Check if this is an agent ID mapped to a task
|
||||
for (const [agentId, taskId] of agentTaskMap) {
|
||||
if (agentId === task_id || agentId.startsWith(task_id)) { resolvedId = taskId; break; }
|
||||
}
|
||||
}
|
||||
const task = store.get(resolvedId);
|
||||
if (!task) throw new Error(`No task found with ID ${task_id}`);
|
||||
|
||||
if (task.metadata?.agentId) {
|
||||
// Subagent task — wait for completion if blocking
|
||||
if (block && task.status === "in_progress") {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => { unsubOk(); unsubFail(); resolve(); }, timeout ?? 30000);
|
||||
const cleanup = () => { clearTimeout(timer); resolve(); };
|
||||
const unsubOk = pi.events.on("subagents:completed", (d: unknown) => {
|
||||
if ((d as any).id === task.metadata?.agentId) { unsubOk(); unsubFail(); cleanup(); }
|
||||
});
|
||||
const unsubFail = pi.events.on("subagents:failed", (d: unknown) => {
|
||||
if ((d as any).id === task.metadata?.agentId) { unsubOk(); unsubFail(); cleanup(); }
|
||||
});
|
||||
// Re-check in case status changed between the outer check and listener registration
|
||||
const current = store.get(task_id);
|
||||
if (current && current.status !== "in_progress") { unsubOk(); unsubFail(); cleanup(); }
|
||||
signal?.addEventListener("abort", () => { unsubOk(); unsubFail(); cleanup(); }, { once: true });
|
||||
});
|
||||
}
|
||||
const updated = store.get(task_id) ?? task;
|
||||
return textResult(`Task #${task_id} [${updated.status}] — subagent ${task.metadata.agentId}`);
|
||||
}
|
||||
throw new Error(`No background process for task ${task_id}`);
|
||||
}
|
||||
|
||||
@@ -671,6 +772,22 @@ Set up task dependencies:
|
||||
|
||||
const stopped = await tracker.stop(taskId);
|
||||
if (!stopped) {
|
||||
// No shell process — check if this is a subagent task
|
||||
// Support both task IDs and agent IDs
|
||||
let resolvedId = taskId;
|
||||
if (!store.get(resolvedId)) {
|
||||
for (const [agentId, tId] of agentTaskMap) {
|
||||
if (agentId === taskId || agentId.startsWith(taskId)) { resolvedId = tId; break; }
|
||||
}
|
||||
}
|
||||
const task = store.get(resolvedId);
|
||||
if (task?.metadata?.agentId && task.status === "in_progress") {
|
||||
store.update(taskId, { status: "completed" });
|
||||
await stopSubagent(task.metadata.agentId);
|
||||
widget.setActiveTask(taskId, false);
|
||||
widget.update();
|
||||
return textResult(`Task #${taskId} stopped successfully`);
|
||||
}
|
||||
throw new Error(`No running background process for task ${taskId}`);
|
||||
}
|
||||
|
||||
@@ -688,7 +805,7 @@ Set up task dependencies:
|
||||
pi.registerTool({
|
||||
name: "TaskExecute",
|
||||
label: "TaskExecute",
|
||||
description: `Execute one or more tasks as subagents. Requires @tintinweb/pi-subagents extension.
|
||||
description: `Execute one or more tasks as subagents.
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
@@ -702,6 +819,9 @@ Set up task dependencies:
|
||||
- **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`,
|
||||
promptGuidelines: [
|
||||
"Never use the Agent tool for tasks launched via TaskExecute — agents are already running.",
|
||||
],
|
||||
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" })),
|
||||
@@ -712,8 +832,8 @@ Set up task dependencies:
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
if (!subagentsAvailable) {
|
||||
return textResult(
|
||||
"TaskExecute requires the @tintinweb/pi-subagents extension to be loaded. " +
|
||||
"Install and enable it, then try again."
|
||||
"Subagent execution is currently unavailable. " +
|
||||
"Ensure the @tintinweb/pi-subagents extension is loaded and try again."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -759,6 +879,7 @@ Set up task dependencies:
|
||||
widget.setActiveTask(taskId);
|
||||
launched.push(`#${taskId} → agent ${agentId}`);
|
||||
} catch (err: any) {
|
||||
debug(`spawn:error task=#${taskId}`, err);
|
||||
store.update(taskId, { status: "pending" });
|
||||
results.push(`#${taskId}: spawn failed — ${err.message}`);
|
||||
}
|
||||
@@ -774,7 +895,12 @@ Set up task dependencies:
|
||||
widget.update();
|
||||
|
||||
const lines: string[] = [];
|
||||
if (launched.length > 0) lines.push(`Launched ${launched.length} agent(s):\n${launched.join("\n")}`);
|
||||
if (launched.length > 0) {
|
||||
lines.push(
|
||||
`Launched ${launched.length} agent(s):\n${launched.join("\n")}\n` +
|
||||
`Use TaskOutput to check progress. Do not spawn additional agents for these tasks.`
|
||||
);
|
||||
}
|
||||
if (results.length > 0) lines.push(`Skipped:\n${results.join("\n")}`);
|
||||
if (lines.length === 0) lines.push("No tasks to execute.");
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ function mockCtx() {
|
||||
ui: {
|
||||
setWidget: vi.fn(),
|
||||
setStatus: vi.fn(),
|
||||
notify: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -83,14 +84,15 @@ function mockCtx() {
|
||||
// ---- Mock subagents extension (RPC responders) ----
|
||||
|
||||
/** Simulates the @tintinweb/pi-subagents extension: responds to ping + spawn RPCs and emits ready. */
|
||||
function installSubagentsMock(pi: { events: { on: Function; emit: Function } }) {
|
||||
function installSubagentsMock(pi: { events: { on: Function; emit: Function } }, 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}`, {});
|
||||
pi.events.emit(`subagents:rpc:ping:reply:${requestId}`, { success: true, data: { version: 2 } });
|
||||
});
|
||||
|
||||
// Respond to spawn — reply on scoped channel
|
||||
@@ -98,9 +100,25 @@ function installSubagentsMock(pi: { events: { on: Function; emit: Function } })
|
||||
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}`, { id });
|
||||
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
|
||||
@@ -108,7 +126,8 @@ function installSubagentsMock(pi: { events: { on: Function; emit: Function } })
|
||||
|
||||
return {
|
||||
spawned,
|
||||
unsub() { unsubPing(); unsubSpawn(); },
|
||||
stopped,
|
||||
unsub() { unsubPing(); unsubSpawn(); unsubStop(); },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -556,12 +575,10 @@ describe("RPC protocol correctness", () => {
|
||||
|
||||
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);
|
||||
|
||||
// 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",
|
||||
@@ -603,6 +620,176 @@ describe("RPC protocol correctness", () => {
|
||||
|
||||
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: { on: Function; emit: Function } }, 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", () => {
|
||||
|
||||
Reference in New Issue
Block a user