/** * status-note-wiring.test.ts — proves the status note actually reaches the * PARENT through the real tool handlers, not just that getStatusNote() returns * a string. Drives the registered `Agent` / `get_subagent_result` tools and * inspects the text delivered back, for a turn-limit abort and a user stop. */ import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("../src/agent-runner.js", async () => { const actual = await vi.importActual("../src/agent-runner.js"); return { ...actual, runAgent: vi.fn() }; }); import { runAgent } from "../src/agent-runner.js"; import subagentsExtension from "../src/index.js"; function makePi() { const tools = new Map(); const eventHandlers = new Map(); const lifecycle = new Map(); const pi = { registerMessageRenderer: vi.fn(), registerTool: vi.fn((t: any) => tools.set(t.name, t)), registerCommand: vi.fn(), on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), events: { emit: vi.fn(), on: vi.fn((event: string, handler: any) => { eventHandlers.set(event, handler); return vi.fn(); }), }, appendEntry: vi.fn(), sendMessage: vi.fn(), } as any; return { pi, tools, eventHandlers, lifecycle }; } // The RPC channels are registered on the first bound session_start (#142), so a // test that drives them must fire it first — as a real session always does. A // sessionId-less ctx makes startScheduler short-circuit (no filesystem touch). async function bind(lifecycle: Map) { const bindCtx = ctx(); bindCtx.sessionManager.getSessionId = vi.fn(() => undefined); await lifecycle.get("session_start")({}, bindCtx); } function ctx() { return { hasUI: false, ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, cwd: "/tmp", model: undefined, modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, getSystemPrompt: vi.fn(() => "parent"), } as any; } const textOf = (r: any): string => r.content[0].text; describe("status note reaches the parent through the real handlers", () => { afterEach(() => { delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; vi.restoreAllMocks(); }); it("foreground turn-limit abort → the Agent result flags an incomplete outcome", async () => { vi.mocked(runAgent).mockResolvedValue({ responseText: "partial work so far", session: { dispose: vi.fn() } as any, aborted: true, // hard turn-limit abort steered: false, }); const { pi, tools } = makePi(); subagentsExtension(pi); const res = await tools.get("Agent").execute( "tc1", { prompt: "go", description: "d", subagent_type: "general-purpose", run_in_background: false }, undefined, undefined, ctx(), ); const out = textOf(res); // Exact lead clause, not just "turn limit": a steered/aborted mix-up would // otherwise slip through, and they are different outcomes. expect(out).toContain("aborted at the turn limit"); expect(out).toContain("partial work so far"); // partial result still delivered expect(out).not.toContain("STOPPED BY THE USER"); // not mislabelled as a user stop // The two answers a foreground parent needs: is this all of it, and is the // task done. The first is what #174 turned on — the parent has no agent id, // so it must not read "partial" as "go fetch the rest". expect(out).toContain("everything the agent produced is above"); expect(out).toContain("the task is unfinished"); // State only, never an instruction to act (see getForegroundOutcomeNote): // advising a fresh run to save one wasted tool call is a bet nothing here // can measure. And naming the tool we steer away from only raises its salience. expect(out).not.toContain("re-spawn"); expect(out).not.toContain("get_subagent_result"); }); it("foreground user-stop → tells the parent NOT to restart it unasked", async () => { // Pi delivers a user ESC as an abort on the tool's signal; the manager wires // that to abort(id) (#44), landing the record on "stopped" — deliberately // distinct from a turn-limit "aborted", because the correct next action is // the opposite one. let finish: (v: any) => void = () => {}; vi.mocked(runAgent).mockReturnValue(new Promise((r) => { finish = r; }) as any); const { pi, tools } = makePi(); subagentsExtension(pi); const parent = new AbortController(); const call = tools.get("Agent").execute( "tc-stop", { prompt: "go", description: "d", subagent_type: "general-purpose", run_in_background: false }, parent.signal, undefined, ctx(), ); // The manager only wires addEventListener("abort", …) and never checks // signal.aborted upfront (agent-manager.ts:240-243), so aborting before the // listener is attached would silently land on "completed" instead. Flush // first rather than relying on spawn() happening in execute()'s synchronous // prefix, which any future await in that path would quietly break. await new Promise((r) => setImmediate(r)); parent.abort(); // the user hits ESC finish({ responseText: "partial work so far", session: { dispose: vi.fn() }, aborted: false, steered: false }); const out = textOf(await call); expect(out).toContain("STOPPED BY THE USER"); expect(out).toContain("everything the agent produced is above"); // Same claim, same confidence, same words as the aborted case — only the // lead clause distinguishes them. expect(out).toContain("the task is unfinished"); // State only, here most of all. Advice to re-spawn would re-run work a human // deliberately killed; advice to ask first presumes someone is there to ask, // which is false under `pi -p`, scheduled jobs, and background-driven runs. expect(out).not.toContain("re-spawn"); expect(out).not.toContain("ask before"); }); it("hides nested records from top-level tools, registry, transcripts, and lifecycle", async () => { vi.mocked(runAgent).mockResolvedValue({ responseText: "nested result", session: { dispose: vi.fn() } as any, aborted: false, steered: false, }); const { pi, tools } = makePi(); subagentsExtension(pi); const registry = (globalThis as any)[Symbol.for("pi-subagents:manager")]; // External registry/RPC callers cannot mint internal ownership metadata. const topId = registry.spawn(pi, ctx(), "general-purpose", "top", { description: "top-level owner", isBackground: false, parentAgentId: "forged-parent", depth: 99, maxSubagentDepth: 99, configCwd: "/untrusted/config", }); await new Promise(resolve => setTimeout(resolve, 0)); expect(registry.getRecord(topId)).toEqual(expect.objectContaining({ parentAgentId: undefined, depth: 1, })); // Internal scoped tools receive the raw owning manager through nestedRuntime. const rawManager = vi.mocked(runAgent).mock.calls[0][3].nestedRuntime.manager; pi.events.emit.mockClear(); pi.appendEntry.mockClear(); pi.sendMessage.mockClear(); const id = rawManager.spawn(pi, ctx(), "general-purpose", "nested", { description: "nested child", isBackground: true, parentAgentId: topId, depth: 2, maxSubagentDepth: 2, }); await new Promise(resolve => setTimeout(resolve, 0)); expect(registry.getRecord(id)).toBeUndefined(); for (const [name, params] of [ ["get_subagent_result", { agent_id: id }], ["steer_subagent", { agent_id: id, message: "stop" }], ["Agent", { resume: id, prompt: "continue", description: "resume", subagent_type: "general-purpose" }], ] as const) { const result = await tools.get(name).execute("tc-nested", params, undefined, undefined, ctx()); expect(textOf(result)).toContain("Agent not found"); } expect(pi.events.emit).not.toHaveBeenCalledWith("subagents:started", expect.objectContaining({ id })); expect(pi.events.emit).not.toHaveBeenCalledWith("subagents:completed", expect.objectContaining({ id })); expect(pi.events.emit).not.toHaveBeenCalledWith("subagents:failed", expect.objectContaining({ id })); expect(pi.appendEntry).not.toHaveBeenCalledWith("subagents:record", expect.objectContaining({ id })); expect(pi.sendMessage).not.toHaveBeenCalled(); }); it("background user-stop → get_subagent_result flags STOPPED BY THE USER (not completed)", async () => { // A background agent that never settles on its own — only a stop ends it. vi.mocked(runAgent).mockReturnValue(new Promise(() => {}) as any); const { pi, tools, eventHandlers, lifecycle } = makePi(); subagentsExtension(pi); await bind(lifecycle); // register RPC channels via session_start (#142) const spawn = await tools.get("Agent").execute( "tc2", { prompt: "go", description: "d", subagent_type: "general-purpose", run_in_background: true }, undefined, undefined, ctx(), ); const id = textOf(spawn).match(/Agent ID: (\S+)/)?.[1]; expect(id, "background spawn should surface an agent id").toBeTruthy(); // The user stops it — same path the viewer's stop key uses (manager.abort). eventHandlers.get("subagents:rpc:stop")?.({ requestId: "r1", agentId: id }); const res = await tools.get("get_subagent_result").execute( "tc3", { agent_id: id }, undefined, undefined, ctx(), ); const out = textOf(res); expect(out).toContain("STOPPED BY THE USER"); expect(out).toContain("the task was NOT finished"); expect(out).not.toContain("Done"); // not surfaced as a normal completion // The background/retrieval path keeps getStatusNote, NOT the foreground // note. Its caller holds a 500-char preview and a real agent id, so // "everything the agent produced is above" would be a lie here. Folding the // two functions back together is exactly the regression this guards. expect(out).not.toContain("everything the agent produced is above"); }); }); // `subagents:compacted` is a documented cross-extension contract (README:495) // with an exact payload. Nothing inside this process reads it, so a dropped or // renamed field is completely silent here and only breaks a consumer. Its // sibling `subagents:started` is already covered — including the negative case // for nested children — so this is the twin that was missed. describe("subagents:compacted", () => { /** Run an agent and fire one compaction from inside runAgent's callbacks. */ function runWithCompaction(info: { reason: string; tokensBefore: number }) { vi.mocked(runAgent).mockImplementation(async (_ctx: any, _type: any, _prompt: any, opts: any) => { opts.onCompaction?.(info); return { responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false }; }); } it("emits the documented payload when a top-level agent's session compacts", async () => { runWithCompaction({ reason: "threshold", tokensBefore: 12345 }); const { pi, tools } = makePi(); subagentsExtension(pi); await tools.get("Agent").execute( "tc-compact", { prompt: "go", description: "compacting agent", subagent_type: "general-purpose" }, undefined, undefined, ctx(), ); expect(pi.events.emit).toHaveBeenCalledWith("subagents:compacted", expect.objectContaining({ id: expect.any(String), type: "general-purpose", description: "compacting agent", reason: "threshold", tokensBefore: 12345, compactionCount: 1, })); }); it("counts repeated compactions on the same agent", async () => { vi.mocked(runAgent).mockImplementation(async (_ctx: any, _type: any, _prompt: any, opts: any) => { opts.onCompaction?.({ reason: "overflow", tokensBefore: 1 }); opts.onCompaction?.({ reason: "overflow", tokensBefore: 2 }); return { responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false }; }); const { pi, tools } = makePi(); subagentsExtension(pi); await tools.get("Agent").execute( "tc-compact2", { prompt: "go", description: "twice", subagent_type: "general-purpose" }, undefined, undefined, ctx(), ); const counts = pi.events.emit.mock.calls .filter((c: any[]) => c[0] === "subagents:compacted") .map((c: any[]) => c[1].compactionCount); expect(counts).toEqual([1, 2]); }); it("stays silent for a nested child, like every other lifecycle event", async () => { // Nested records are internal to their parent; leaking their compactions // would spam the parent session's bus with ids no consumer can resolve. runWithCompaction({ reason: "threshold", tokensBefore: 999 }); const { pi, tools } = makePi(); subagentsExtension(pi); await tools.get("Agent").execute( "tc-parent", { prompt: "go", description: "parent", subagent_type: "general-purpose" }, undefined, undefined, ctx(), ); const rawManager = vi.mocked(runAgent).mock.calls[0][3].nestedRuntime.manager; const parentId = vi.mocked(runAgent).mock.calls[0][3].nestedRuntime.parentAgentId; pi.events.emit.mockClear(); rawManager.spawn(pi, ctx(), "general-purpose", "nested", { description: "nested child", isBackground: true, parentAgentId: parentId, depth: 2, maxSubagentDepth: 2, }); await new Promise(resolve => setTimeout(resolve, 0)); expect(pi.events.emit).not.toHaveBeenCalledWith("subagents:compacted", expect.anything()); }); });