diff --git a/CHANGELOG.md b/CHANGELOG.md index 5325c7a..8e5db6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..d8476c6 --- /dev/null +++ b/biome.json @@ -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" + ] + } +} diff --git a/package.json b/package.json index b6bd97a..2e3d48e 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/index.ts b/src/index.ts index fdbbfd7..fafc7e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); - // ── 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 = + | { 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(channel: string, params: Record, timeoutMs: number): Promise { + const requestId = randomUUID(); + debug(`rpc:send ${channel}`, { requestId }); + return new Promise((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; + 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 { - 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 { + return rpcCall("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((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."); diff --git a/test/subagent-integration.test.ts b/test/subagent-integration.test.ts index be7485f..0f85fc7 100644 --- a/test/subagent-integration.test.ts +++ b/test/subagent-integration.test.ts @@ -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", () => {