Files
my-pi/pi-subagents/test/subagents-print-mode-e2e.test.ts

389 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* subagents-print-mode-e2e.test.ts — REAL end-to-end subagent runs through the
* headless print-mode host (`test/helpers/print-mode-runner.ts`).
*
* Unlike agent-runner-e2e / ext-templates-e2e (which assert on the gated tool
* set captured at construction and never drive a turn), these tests drive a real
* parent turn that calls the `Agent` tool, lets the extension spawn a real child
* session via the real `runAgent`, and waits for it through the real subagent
* hold condition — then asserts on what actually flowed back.
*
* Deterministic by default: a scripted faux model drives both parent and child
* (no network). The same runner also drives a real LLM when PI_E2E_LIVE=1 — the
* `live` describe below is a smoke test for that opt-in path.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Context } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
agentCall,
agentToolCalls,
agentToolResults,
conversationText,
invokedToolNames,
type PrintModeRun,
routeBySession,
runPrintMode,
} from "./helpers/print-mode-runner.js";
// Real pi-mono (loader + dynamic extension import + two live sessions) — a cold
// run under full-suite CPU contention can exceed vitest's 5s default.
vi.setConfig({ testTimeout: 30_000 });
const LIVE = /^(1|true|yes)$/i.test(process.env.PI_E2E_LIVE ?? "");
describe.skipIf(LIVE)("subagents print-mode e2e (scripted faux, real pi-mono)", () => {
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("spawns a FOREGROUND subagent and routes its real output back to the parent", async () => {
run = await runPrintMode({
prompt: "Delegate the greeting to a subagent.",
respond: routeBySession({
parentInitial: agentCall({
subagent_type: "general-purpose",
description: "greet",
prompt: "Say hello.",
run_in_background: false,
}),
// NON-circular: the parent's final answer echoes whatever the child's
// result actually was in context. If the child output didn't reach the
// parent, this returns CHILD_MISSING and the responseText assertion fails.
parentFinal: (ctx: Context) => {
const childOut = [...ctx.messages]
.reverse()
.find((m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent");
const text = ((childOut?.content ?? []) as Array<{ text?: string }>)
.map((b) => b.text ?? "")
.join("");
return `Parent relays: ${text.includes("CHILD_GREETING_OK") ? "CHILD_GREETING_OK" : "CHILD_MISSING"}`;
},
subagent: "CHILD_GREETING_OK",
}),
});
// The child actually ran: its output reached the parent via the Agent tool
// result (real record.result), and the parent's final answer was derived
// from that result — not a value the test hard-coded into the parent.
const toolResults = agentToolResults(run.parentSession);
expect(toolResults.length).toBe(1);
expect(toolResults[0]).toContain("CHILD_GREETING_OK");
expect(run.responseText).toContain("CHILD_GREETING_OK");
expect(run.responseText).not.toContain("CHILD_MISSING");
// Parent t1 (Agent call) + child t1 (reply) + parent t2 (final) = 3 calls.
expect(run.modelCalls).toBeGreaterThanOrEqual(3);
});
it("the hold condition is load-bearing: it keeps a BACKGROUND child alive (vs abandoned without it)", async () => {
// The child takes a beat to "think" (a real delay in its faux turn). That
// delay is what makes the contrast causal and deterministic:
// - WITHOUT the hold, the parent's turn ends and the runner tears down
// before the child ever streams → the child is abandoned (2 model calls:
// parent's tool-call turn + its summary turn; the child never runs).
// - WITH the hold, the parent loop blocks in waitForAll() until the child
// finishes → the child's own model turn actually runs (≥3 calls).
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const respond = async (ctx: Context) => {
const isParent = (ctx.tools ?? []).some((t) => t.name === "Agent");
if (!isParent) {
await sleep(80); // child takes long enough that a non-held parent exits first
return "CHILD_BG_RAN";
}
const spawned = ctx.messages.some(
(m) => m.role === "toolResult" && (m as { toolName?: string }).toolName === "Agent",
);
return spawned
? "summarized"
: agentCall({ description: "bg work", prompt: "Do background work.", run_in_background: true });
};
// Control: no hold → the child hasn't run by the time the parent turn ends.
// `modelCalls` is snapshotted at that moment (it's a plain number on the
// result), so draining afterwards to tear down cleanly doesn't change it.
const noHold = await runPrintMode({ prompt: "go", hold: false, respond });
const abandonedCalls = noHold.modelCalls;
await noHold.manager?.waitForAll(); // let the orphan finish before dispose (avoids stale-ctx)
await noHold.dispose();
// Subject: hold on → child runs to completion before the parent finishes.
run = await runPrintMode({ prompt: "go", hold: true, respond });
// Background spawn returns its envelope synchronously either way.
expect(agentToolResults(run.parentSession)[0]).toMatch(/background/i);
// The hold is load-bearing: only with it does the child's turn actually run.
expect(abandonedCalls).toBe(2); // parent tool-call + summary; child never streamed
expect(run.modelCalls).toBeGreaterThan(abandonedCalls);
expect(run.modelCalls).toBeGreaterThanOrEqual(3);
});
it("spawns a FRONTMATTER-defined (.pi/agents/*.md) agent and its prompt reaches the child", async () => {
// A project agent whose body is a distinctive system prompt. Proving the
// child SAW it proves the full chain: the extension discovers the .md from
// process.cwd(), parses its frontmatter, and runAgent's buildAgentPrompt
// feeds the body into the real child session.
const MARKER = "SPYMARKER_FRONTMATTER_REACHED_CHILD";
const cwd = mkdtempSync(join(tmpdir(), "subagents-fm-"));
tmpDirs.push(cwd);
mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
writeFileSync(
join(cwd, ".pi", "agents", "echo-spy.md"),
`---\ndescription: "Echoes a marker proving its frontmatter prompt reached the child."\n---\n${MARKER}\n`,
);
run = await runPrintMode({
prompt: "Delegate to the echo-spy agent.",
cwd, // runner chdir's here so the extension discovers echo-spy.md
respond: routeBySession({
parentInitial: agentCall({
subagent_type: "echo-spy",
description: "echo",
prompt: "Report what you were told.",
run_in_background: false,
}),
parentFinal: "Reported.",
// The child reflects whether the frontmatter body reached its own prompt.
subagent: (ctx: Context) =>
`child saw: ${ctx.systemPrompt?.includes(MARKER) ? MARKER : "MISSING"}`,
}),
});
const toolResults = agentToolResults(run.parentSession);
expect(toolResults.length).toBe(1);
expect(toolResults[0]).toContain(MARKER);
expect(toolResults[0]).not.toContain("MISSING");
// The custom type resolved — it did NOT silently fall back to general-purpose.
expect(toolResults[0]).not.toMatch(/Unknown agent type/i);
});
it("spawns a FRONTMATTER-defined (.agents/agents/*.md) agent and its prompt reaches the child", async () => {
const MARKER = "SPYMARKER_AGENTS_FRONTMATTER_REACHED_CHILD";
const cwd = mkdtempSync(join(tmpdir(), "subagents-agents-fm-"));
tmpDirs.push(cwd);
mkdirSync(join(cwd, ".agents", "agents"), { recursive: true });
writeFileSync(
join(cwd, ".agents", "agents", "agents-spy.md"),
`---\ndescription: "Echoes a marker from the .agents/agents workspace dir."\n---\n${MARKER}\n`,
);
run = await runPrintMode({
prompt: "Delegate to the agents-spy agent.",
cwd,
respond: routeBySession({
parentInitial: agentCall({
subagent_type: "agents-spy",
description: "echo workspace",
prompt: "Report what you were told.",
run_in_background: false,
}),
parentFinal: "Reported.",
subagent: (ctx: Context) =>
`child saw: ${ctx.systemPrompt?.includes(MARKER) ? MARKER : "MISSING"}`,
}),
});
const toolResults = agentToolResults(run.parentSession);
expect(toolResults.length).toBe(1);
expect(toolResults[0]).toContain(MARKER);
expect(toolResults[0]).not.toContain("MISSING");
expect(toolResults[0]).not.toMatch(/Unknown agent type/i);
});
it("a colored agent's name badge never reaches print-mode text", async () => {
// Badges are a TUI concern: print mode renders no tool components, and the text the
// model and `pi -p` see is built from plain display names. An escape sequence here
// would mean color leaking into transcripts, headless output and the parent prompt.
const cwd = mkdtempSync(join(tmpdir(), "subagents-color-"));
tmpDirs.push(cwd);
mkdirSync(join(cwd, ".pi", "agents"), { recursive: true });
writeFileSync(
join(cwd, ".pi", "agents", "painted.md"),
'---\nname: Painted Agent\ncolor: purple\ndescription: "A colored agent."\n---\nBe brief.\n',
);
run = await runPrintMode({
prompt: "Delegate to the painted agent.",
cwd,
respond: routeBySession({
parentInitial: agentCall({
subagent_type: "painted",
description: "paint",
prompt: "Report in.",
run_in_background: false,
}),
parentFinal: "Done.",
subagent: "Painted Agent reporting in.",
}),
});
const result = agentToolResults(run.parentSession)[0];
expect(result).toContain("Painted Agent reporting in."); // the escape check below is not vacuous
expect(result).not.toContain("\u001b");
expect(conversationText(run.parentSession)).not.toContain("\u001b");
});
it("errors clearly when faux mode is given no script", async () => {
await expect(runPrintMode({ prompt: "x" })).rejects.toThrow(/provide `respond` or `steps`/);
});
it("times out with the runner's own descriptive error and restores the environment", async () => {
const prevCwd = process.cwd();
// A responder that never resolves — the turn stalls until the wall-clock guard fires.
await expect(
runPrintMode({ prompt: "stall", respond: () => new Promise(() => {}), timeoutMs: 300 }),
).rejects.toThrow(/print-mode runner timed out after 300ms/);
// The failure path ran dispose(): cwd and global isolation were restored even
// though the caller never received a dispose handle.
expect(process.cwd()).toBe(prevCwd);
expect((globalThis as Record<symbol, unknown>)[Symbol.for("pi-subagents:manager")]).toBeUndefined();
});
});
// Opt-in real-LLM smoke tests — exercise the SAME runner against a live model
// (auto-resolved from the local `pi` login). Skipped unless PI_E2E_LIVE=1.
//
// These are SMOKE tests, not strict assertions: a live model decides whether and
// how to call the tool, so we cover the subset it can be reliably steered into
// (foreground spawn, background spawn + get_subagent_result, an Explore spawn)
// and assert robust invariants (a real spawn happened and produced output).
// Per-feature determinism lives in the faux suite above, which scripts exact calls.
const LIVE_TIMEOUT = 150_000;
// SELF-SMOKE chains three live spawns in one session; passing runs land ~145s,
// but live variance (slow turns, provider retries, extra polling) has blown past
// 2× that — give it 4× so the smoke doesn't flake on latency alone.
const SELF_SMOKE_TIMEOUT = 600_000;
// The vitest per-test timer starts before runPrintMode and should not fire
// first: the runner's own timeoutMs guard produces a descriptive error and
// aborts the live session + subagents, while a vitest timeout is generic and
// leaks them. The slack covers live setup/teardown outside the runner's guard.
const VITEST_SLACK = 30_000;
const LIVE_VITEST_TIMEOUT = LIVE_TIMEOUT + VITEST_SLACK;
const SELF_SMOKE_VITEST_TIMEOUT = SELF_SMOKE_TIMEOUT + VITEST_SLACK;
describe.runIf(LIVE)("subagents print-mode e2e (live LLM, opt-in)", () => {
let run: PrintModeRun | undefined;
afterEach(async () => {
await run?.dispose();
run = undefined;
});
it(
"FOREGROUND spawn — real model spawns a subagent and reports its output",
async () => {
run = await runPrintMode({
prompt:
"Use the Agent tool to spawn a general-purpose subagent (run_in_background: false) " +
"whose only task is to reply with the exact word PONG, then tell me what it replied.",
timeoutMs: LIVE_TIMEOUT,
});
expect(run.modelCalls).toBe(0); // live mode doesn't use the faux counter
expect(invokedToolNames(run.parentSession)).toContain("Agent");
// The child actually ran and its output came back through the tool result.
expect(agentToolResults(run.parentSession).join("\n")).toMatch(/PONG/i);
expect(run.responseText).toMatch(/PONG/i);
},
LIVE_VITEST_TIMEOUT,
);
it(
"BACKGROUND spawn + get_subagent_result — model backgrounds work then retrieves it",
async () => {
run = await runPrintMode({
prompt:
"Spawn a general-purpose subagent IN THE BACKGROUND (run_in_background: true) whose " +
"only task is to reply with the exact word BGPONG. After it finishes, use the " +
"get_subagent_result tool to fetch its result, then tell me exactly what it said.",
timeoutMs: LIVE_TIMEOUT,
});
const calls = agentToolCalls(run.parentSession);
// The model used the background feature…
expect(calls.some((c) => c.run_in_background === true)).toBe(true);
// …and the spawn returned the "started in background" envelope…
expect(agentToolResults(run.parentSession).join("\n")).toMatch(/background/i);
// …and the background child genuinely ran (its result surfaced somewhere:
// via get_subagent_result and/or the held final answer).
expect(run.responseText).toMatch(/BGPONG/i);
},
LIVE_VITEST_TIMEOUT,
);
it(
"Explore subagent_type — model dispatches a non-default agent type",
async () => {
run = await runPrintMode({
prompt:
"Use the Agent tool with subagent_type 'Explore' to look at the current working " +
"directory and report a one-line summary of what's there.",
timeoutMs: LIVE_TIMEOUT,
});
const calls = agentToolCalls(run.parentSession);
// The non-default type was actually selected (case-insensitive per README).
expect(
calls.some((c) => String(c.subagent_type ?? "").toLowerCase() === "explore"),
).toBe(true);
expect(run.responseText.length).toBeGreaterThan(0);
},
LIVE_VITEST_TIMEOUT,
);
it(
"SELF-SMOKE — the agent drives a multi-feature smoke of its own Agent toolset",
async () => {
// Agent-driven (not puppeted): one prompt, the model itself exercises three
// Agent capabilities in a single session and self-reports. We then assert it
// genuinely invoked each feature (not just that it claimed to in prose).
run = await runPrintMode({
prompt: [
"You are smoke-testing your own Agent toolset. Do these steps IN ORDER, then print a",
"final report with one PASS/FAIL line per step:",
"1) FOREGROUND: spawn a general-purpose subagent (run_in_background: false) whose only",
" task is to reply with the exact token FG_OK. Confirm you got FG_OK back.",
"2) BACKGROUND: spawn a general-purpose subagent with run_in_background: true whose only",
" task is to reply with the exact token BG_OK. After it finishes, call get_subagent_result",
" to retrieve its output. Confirm you got BG_OK.",
"3) EXPLORE: spawn a subagent with subagent_type 'Explore' to summarize the current",
" working directory in one line.",
"Finish with: 'SELF-SMOKE COMPLETE' followed by the PASS/FAIL lines.",
].join("\n"),
timeoutMs: SELF_SMOKE_TIMEOUT,
});
const calls = agentToolCalls(run.parentSession);
const tools = invokedToolNames(run.parentSession);
// Each capability was actually exercised at the tool layer (not just narrated):
// — a foreground spawn (run_in_background not true on at least one Agent call)
expect(calls.some((c) => c.run_in_background !== true)).toBe(true);
// — a background spawn
expect(calls.some((c) => c.run_in_background === true)).toBe(true);
// — the result-retrieval tool was called
expect(tools).toContain("get_subagent_result");
// — the Explore type was dispatched
expect(calls.some((c) => String(c.subagent_type ?? "").toLowerCase() === "explore")).toBe(true);
// — and the real child outputs materialized in the conversation (the
// foreground tool result + the get_subagent_result result). We check the
// whole transcript, not the final message: the agent's closing report
// tends to summarize ("Step 1 PASS") rather than re-echo the raw tokens.
const transcript = conversationText(run.parentSession);
expect(transcript).toMatch(/FG_OK/i);
expect(transcript).toMatch(/BG_OK/i);
// The agent ran the whole script to completion and self-reported. Checked
// against the transcript, not `responseText`: step 2's background agent
// completes asynchronously, so its completion nudge can land AFTER the
// final report and draw one more turn out of the model ("Acknowledged, all
// three steps PASS"). The report is then the second-to-last message and a
// last-message assertion fails a run that did everything right.
expect(transcript).toMatch(/SELF-SMOKE COMPLETE/i);
expect(run.responseText.length).toBeGreaterThan(0);
},
SELF_SMOKE_VITEST_TIMEOUT,
);
});