/** * nested-delegation-e2e.test.ts — regression for opt-in nested delegation, run * through the real stack: real pi loader + real extension + real runAgent + two * real child sessions, on a faux model. * * Everything else that covers nesting stops short of a real session — the tool * unit tests use a fake manager, and the runner tests assert against a mocked * pi. That leaves the load-bearing integration facts unproven: that pi actually * admits the injected `customTools` into a child session's ACTIVE tool set * (they collide with EXCLUDED_TOOL_NAMES by design, so a registry gate could * silently drop them), and that a grandchild's output travels back up two hops. * Those are exactly the things that break quietly, so they are pinned here. * * Deliberately faux, not live: `PI_E2E_LIVE=1` cannot drive a three-level chain * deterministically, and a live model choosing not to delegate would look like * a passing test. Each run therefore pins `live: false` rather than trusting the * env var to leave it alone — the pre-publish smoke sets it globally. */ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { type Context, fauxToolCall } from "@earendil-works/pi-ai"; import { afterEach, describe, expect, it, vi } from "vitest"; import { registerAgents } from "../src/agent-types.js"; import { loadCustomAgents } from "../src/custom-agents.js"; import { encodeCwd } from "../src/output-file.js"; import { agentCall, type FauxReply, type PrintModeRun, runPrintMode, } from "./helpers/print-mode-runner.js"; vi.setConfig({ testTimeout: 30_000 }); /** Marker the deepest agent emits — it must survive two hops back to the parent. */ const WORKER_MARKER = "WORKER-REACHED-THE-TOP"; /** First user message of a session — the only stable way to tell three faux sessions apart. */ function firstUserText(context: Context): string { const first = context.messages.find((m) => m.role === "user"); const content = first?.content; if (typeof content === "string") return content; return ((content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join(""); } /** Tool results in a session context, newest last, with their tool names. */ function toolResultTexts(context: Context): Array<{ name: string; text: string }> { const out: Array<{ name: string; text: string }> = []; for (const m of context.messages) { if (m.role !== "toolResult") continue; const name = (m as { toolName?: string }).toolName ?? ""; const text = ((m.content ?? []) as Array<{ text?: string }>).map((b) => b.text ?? "").join(""); out.push({ name, text }); } return out; } /** Every `.output` transcript beneath a root, at any session/tasks depth. */ function findOutputFiles(root: string): string[] { let entries: string[]; try { entries = readdirSync(root); } catch { return []; } return entries.flatMap((e) => { const full = join(root, e); if (statSync(full).isDirectory()) return findOutputFiles(full); return full.endsWith(".output") ? [full] : []; }); } function writeAgents(cwd: string): void { const dir = join(cwd, ".pi", "agents"); mkdirSync(dir, { recursive: true }); // Opts into nesting, restricted to one type — the allowlist path, not `all`. writeFileSync( join(dir, "orchestrator.md"), "---\ndescription: Delegating orchestrator\ntools: read\nextensions: false\nallowed_subagents: worker\n---\nDelegate to worker.\n", ); writeFileSync( join(dir, "worker.md"), "---\ndescription: Leaf worker\ntools: read\nextensions: false\n---\nDo the work.\n", ); } describe("nested delegation e2e (real pi-mono, faux model)", () => { let run: PrintModeRun | undefined; const tmpDirs: string[] = []; afterEach(async () => { await run?.dispose(); run = undefined; for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); it("a child with allowed_subagents spawns its own child, and the output travels back up", async () => { const cwd = mkdtempSync(join(tmpdir(), "nested-e2e-")); tmpDirs.push(cwd); writeAgents(cwd); /** Tool names each session was actually offered, keyed by who it is. */ const toolsSeen = new Map(); const respond = (context: Context): FauxReply => { const text = firstUserText(context); const names = (context.tools ?? []).map((t) => t.name); // Leaf: no nested tools (it never opted in) — just answer. if (text.includes("Do the leaf work")) { toolsSeen.set("worker", names); return WORKER_MARKER; } // Middle: opted in, so pi must have admitted the injected Agent tool. if (text.includes("Delegate this downward")) { toolsSeen.set("orchestrator", names); const alreadySpawned = context.messages.some( (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent", ); if (alreadySpawned) { const result = [...context.messages] .reverse() .find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent"); const inner = ((result?.content ?? []) as Array<{ text?: string }>) .map((b) => b.text ?? "") .join(""); // Echo the child's own text: if it never arrived, the marker is absent // and the top-level assertion fails rather than passing vacuously. return `orchestrator saw: ${inner}`; } return agentCall({ subagent_type: "worker", description: "leaf work", prompt: "Do the leaf work.", }); } // Top-level parent. toolsSeen.set("parent", names); const spawned = context.messages.some( (m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent", ); if (spawned) { const result = [...context.messages] .reverse() .find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent"); const inner = ((result?.content ?? []) as Array<{ text?: string }>) .map((b) => b.text ?? "") .join(""); return `parent saw: ${inner}`; } return agentCall({ subagent_type: "orchestrator", description: "delegate", prompt: "Delegate this downward.", // Foreground: this test reads the parent's inline Agent tool result. run_in_background: false, }); }; run = await runPrintMode({ prompt: "Delegate the work.", cwd, respond, live: false, beforeRun: () => { registerAgents(loadCustomAgents(cwd)); }, }); // pi admitted the injected nested tools into the opted-in child's active set, // despite their names colliding with the ones stripped from every subagent. expect(toolsSeen.get("orchestrator")).toContain("Agent"); expect(toolsSeen.get("orchestrator")).toContain("get_subagent_result"); expect(toolsSeen.get("orchestrator")).toContain("steer_subagent"); // The leaf never opted in, so it must not have them. expect(toolsSeen.get("worker")).toBeDefined(); expect(toolsSeen.get("worker")).not.toContain("Agent"); // Two hops home: worker → orchestrator → parent. expect(run.responseText).toContain(WORKER_MARKER); }); it("backgrounds a nested child, polls it by id, and streams its transcript", async () => { const cwd = mkdtempSync(join(tmpdir(), "nested-e2e-bg-")); tmpDirs.push(cwd); writeAgents(cwd); const transcriptRoot = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`, encodeCwd(cwd)); rmSync(transcriptRoot, { recursive: true, force: true }); const respond = (context: Context): FauxReply => { const text = firstUserText(context); if (text.includes("Do the leaf work")) return WORKER_MARKER; if (text.includes("Delegate this downward")) { const results = toolResultTexts(context); const spawned = results.find((r) => r.name === "Agent")?.text ?? ""; const polled = results.find((r) => r.name === "get_subagent_result")?.text; // Third turn: the poll came back — echo it so a lost result fails loudly. if (polled !== undefined) return `orchestrator polled: ${polled}`; // Second turn: the spawn returned an id; fetch by exactly that id, which // also exercises the manager's ownership check from inside a child. if (spawned) { const id = /Agent ID:\s*(\S+)/.exec(spawned)?.[1]; expect(id).toBeTruthy(); return fauxToolCall("get_subagent_result", { agent_id: id, wait: true }); } return agentCall({ subagent_type: "worker", description: "leaf work", prompt: "Do the leaf work.", run_in_background: true, }); } if (toolResultTexts(context).some((r) => r.name === "Agent")) return "parent done"; return agentCall({ subagent_type: "orchestrator", description: "delegate", prompt: "Delegate this downward.", // Foreground: this test reads the parent's inline Agent tool result. run_in_background: false, }); }; try { run = await runPrintMode({ prompt: "Delegate the work.", cwd, respond, live: false, beforeRun: () => { registerAgents(loadCustomAgents(cwd)); }, }); // The background child ran and its output came back through the id the // spawn handed out — so it was never queued behind its waiting parent. const orchestratorResult = run.parentSession.messages .filter((m) => m.role === "toolResult") .flatMap((m) => (m.content as Array<{ text?: string }>).map((b) => b.text ?? "")) .join("\n"); expect(orchestratorResult).toContain("orchestrator polled"); expect(orchestratorResult).toContain(WORKER_MARKER); // Only the REAL manager wires onSessionCreated → streamToOutputFile for a // nested spawn, and only real rootSessionId propagation puts the file under // this root. Identify the WORKER's own transcript by the prompt in its // initial entry — matching the marker alone would also match the // orchestrator's transcript, which merely echoes it, and would pass even // with nested transcripts switched off entirely. // Match on the FIRST line — writeInitialEntry seeds each transcript with the // prompt that agent was given. Searching the whole file would also match the // orchestrator's, which records the same string inside its Agent tool-call // arguments, and would pass with nested transcripts switched off entirely. const transcripts = findOutputFiles(transcriptRoot).map((f) => readFileSync(f, "utf-8")); const workerTranscript = transcripts.find((t) => { const first = JSON.parse(t.split("\n")[0]) as { message?: { content?: unknown } }; return first.message?.content === "Do the leaf work."; }); expect(workerTranscript).toBeDefined(); // ...and it streamed the child's own turn, not just the seeded prompt. expect(workerTranscript).toContain(WORKER_MARKER); } finally { rmSync(transcriptRoot, { recursive: true, force: true }); } }); });