/** * fallback-subagent-wiring.test.ts — proves `fallbackSubagent` actually gates * dispatch through the real registered tools (#183), not just that the resolver * returns the right verdict. * * The load-bearing assertion in the rejection tests is that `runAgent` was NEVER * called: the complaint in #183 is that a background call starts executing the * wrong agent before the caller learns anything, so a rejection that still * spawns would be no fix at all. The fallback tests assert the opposite — that * it ran, and which agent it ran. */ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, 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 { getAllTypes, getAvailableTypes, NO_FALLBACK, registerAgents, setFallbackSubagent } from "../src/agent-types.js"; import subagentsExtension from "../src/index.js"; function makePi() { const tools = 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(() => vi.fn()) }, appendEntry: vi.fn(), sendMessage: vi.fn(), } as any; return { pi, tools, lifecycle }; } let cwd: string; let originalCwd: string; let originalAgentDir: string | undefined; let originalHome: string | undefined; /** Real agent files on disk. The Agent tool reloads the registry from * `process.cwd()` on every call, so an in-memory registry would be wiped before * dispatch is resolved — and a "disabled" fixture that never loads would make * the disabled-type test pass merely because the name was unknown. */ function writeAgents(): void { const dir = join(cwd, ".pi", "agents"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "scout.md"), "---\ndescription: Scout\ntools: read\n---\nScout.\n"); writeFileSync(join(dir, "retired.md"), "---\ndescription: Retired\ntools: read\nenabled: false\n---\nRetired.\n"); } function ctx() { return { hasUI: false, ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, cwd, 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("fallbackSubagent gates dispatch through the real Agent tool", () => { beforeEach(() => { originalCwd = process.cwd(); cwd = mkdtempSync(join(tmpdir(), "fallback-wiring-")); writeAgents(); process.chdir(cwd); // A developer's real ~/.pi/subagents.json would otherwise set this very // setting under the tests, and their global agents would pollute the roster. originalAgentDir = process.env.PI_CODING_AGENT_DIR; originalHome = process.env.HOME; process.env.PI_CODING_AGENT_DIR = join(cwd, "agent-dir"); process.env.HOME = cwd; vi.mocked(runAgent).mockReset(); }); afterEach(() => { setFallbackSubagent(undefined); delete (globalThis as any)[Symbol.for("pi-subagents:manager")]; process.chdir(originalCwd); if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = originalAgentDir; if (originalHome == null) delete process.env.HOME; else process.env.HOME = originalHome; registerAgents(new Map()); rmSync(cwd, { recursive: true, force: true }); }); function boot() { const { pi, tools, lifecycle } = makePi(); subagentsExtension(pi); return { pi, tools, lifecycle }; } for (const background of [false, true]) { it(`refuses an unknown type without spawning (run_in_background: ${background})`, async () => { const { tools } = boot(); setFallbackSubagent(NO_FALLBACK); const result = await tools.get("Agent").execute( "tc-1", { prompt: "do it", description: "typo dispatch", subagent_type: "definitely-missing", run_in_background: background, }, undefined, undefined, ctx(), ); expect(textOf(result)).toContain('Unknown or disabled agent type: "definitely-missing"'); expect(textOf(result)).toContain("scout"); // The whole point: nothing ran. expect(runAgent).not.toHaveBeenCalled(); }); } it("still falls back — and says so — when the setting is unset", async () => { const { tools } = boot(); vi.mocked(runAgent).mockResolvedValue({ responseText: "done", session: { dispose: vi.fn() } as any, aborted: false, steered: false, }); const result = await tools.get("Agent").execute( "tc-2", { prompt: "do it", description: "typo dispatch", subagent_type: "definitely-missing" }, undefined, undefined, ctx(), ); expect(textOf(result)).toContain('Note: Unknown agent type "definitely-missing"'); // Not merely "something ran" — a fallback that routed anywhere else would pass that. expect(runAgent).toHaveBeenCalledWith( expect.anything(), "general-purpose", "do it", expect.anything(), ); }); it("carries the fallback note on the background branch too", async () => { // Previously the note was computed after spawnAndWait returned, so only a // foreground caller ever saw it (#183). const { tools } = boot(); vi.mocked(runAgent).mockReturnValue(new Promise(() => {}) as any); const result = await tools.get("Agent").execute( "tc-3", { prompt: "do it", description: "typo dispatch", subagent_type: "definitely-missing", run_in_background: true, }, undefined, undefined, ctx(), ); expect(textOf(result)).toContain('Note: Unknown agent type "definitely-missing"'); expect(textOf(result)).toContain("started in background"); }); it("refuses a disabled type, which used to dispatch with a mixed identity", async () => { const { tools } = boot(); setFallbackSubagent(NO_FALLBACK); // Pin the fixture: without this the test passes identically if retired.md // stopped loading, since "unknown" and "disabled" share one message. expect(getAllTypes()).toContain("retired"); expect(getAvailableTypes()).not.toContain("retired"); const result = await tools.get("Agent").execute( "tc-4", { prompt: "do it", description: "disabled dispatch", subagent_type: "retired" }, undefined, undefined, ctx(), ); expect(textOf(result)).toContain("Unknown or disabled agent type"); expect(runAgent).not.toHaveBeenCalled(); }); it("never persists a blank type into a scheduled job", async () => { // `fellBackFrom` is "" for a blank request, and `??` does not treat "" as // nullish — the job would be stored with an empty type and re-fail forever. const { tools, lifecycle } = boot(); await lifecycle.get("session_start")({}, ctx()); const result = await tools.get("Agent").execute( "tc-5", { prompt: "later", description: "blank type", subagent_type: " ", schedule: "+1h" }, undefined, undefined, ctx(), ); expect(textOf(result)).toContain("Scheduled"); const storeDir = join(cwd, ".pi", "subagent-schedules"); const jobs = readdirSync(storeDir).flatMap((f) => JSON.parse(readFileSync(join(storeDir, f), "utf-8")).jobs ?? [], ); expect(jobs).toHaveLength(1); expect(jobs[0].subagent_type).toBe("general-purpose"); }); it("never blocks resume, which ignores subagent_type entirely", async () => { // resume replays a stored session; the type is required by the schema but // unused. Gating it would make a live agent unresumable the moment its type // is deleted or disabled — the opposite of what strict dispatch is for. const { tools } = boot(); vi.mocked(runAgent).mockResolvedValue({ // `messages` is not optional on a real AgentSession, and a background // resume reads it to anchor transcript streaming. responseText: "first", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false, }); const spawned = await tools.get("Agent").execute( "tc-6", { prompt: "start", description: "live agent", subagent_type: "scout", run_in_background: false }, undefined, undefined, ctx(), ); const id = /Agent ID: (\S+)/.exec(textOf(spawned))?.[1] ?? (spawned as any).details?.agentId; expect(id).toBeTruthy(); setFallbackSubagent(NO_FALLBACK); const resumed = await tools.get("Agent").execute( "tc-7", { resume: id, prompt: "keep going", description: "resume", subagent_type: "deleted-since" }, undefined, undefined, ctx(), ); expect(textOf(resumed)).not.toContain("Unknown or disabled agent type"); }); it("applies the same contract to cross-extension spawns", async () => { // The registry entry is what RPC callers reach; it must not be a way around // the setting. A throw here becomes an error envelope at the RPC boundary. boot(); setFallbackSubagent(NO_FALLBACK); const registry = (globalThis as any)[Symbol.for("pi-subagents:manager")]; expect(() => registry.spawn({}, ctx(), "definitely-missing", "do it", { description: "rpc" })) .toThrow(/Unknown or disabled agent type/); expect(runAgent).not.toHaveBeenCalled(); }); });