Files
my-pi/pi-subagents/test/nested-tools.test.ts

544 lines
19 KiB
TypeScript

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAvailableTypes, registerAgents, setFallbackSubagent } from "../src/agent-types.js";
import { loadCustomAgents } from "../src/custom-agents.js";
import { setScopeModelsEnabled } from "../src/model-scope.js";
import { createNestedSubagentTools, getMaxSubagentDepth, type NestedAgentManager, setMaxSubagentDepth } from "../src/nested-tools.js";
import { encodeCwd } from "../src/output-file.js";
let cwd: string;
let manager: NestedAgentManager;
let records: Map<string, any>;
let spawn: ReturnType<typeof vi.fn>;
let spawnAndWait: ReturnType<typeof vi.fn>;
function writeAgent(name: string, extra = "") {
const dir = join(cwd, ".pi", "agents");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${name}.md`), `---\ndescription: ${name}\ntools: read\n${extra}---\n${name}\n`);
}
const MODELS = [
{ id: "allowed", name: "Allowed", provider: "anthropic" },
{ id: "blocked", name: "Blocked", provider: "anthropic" },
];
function ctx(executionCwd = cwd) {
return {
cwd: executionCwd,
model: undefined,
modelRegistry: {
find: (provider: string, id: string) => ({ provider, id }),
getAvailable: () => MODELS,
getAll: () => MODELS,
},
} as any;
}
function tools(
allowedSubagents: "all" | string[] = "all",
depth = 1,
maxSubagentDepth = 2,
configCwd = cwd,
) {
return createNestedSubagentTools({
manager,
pi: {} as any,
parentAgentId: "parent-1",
depth,
maxSubagentDepth,
allowedSubagents,
configCwd,
});
}
async function execute(tool: any, params: Record<string, unknown>, executionCwd = cwd) {
return tool.execute("call-1", params, undefined, undefined, ctx(executionCwd));
}
beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), "nested-tools-test-"));
writeAgent("scout");
writeAgent("reviewer");
registerAgents(loadCustomAgents(cwd));
records = new Map();
spawn = vi.fn((_pi, _ctx, type, _prompt, options) => {
const id = `child-${records.size + 1}`;
records.set(id, { id, type, status: "running", parentAgentId: options.parentAgentId });
return id;
});
spawnAndWait = vi.fn(async (_pi, _ctx, type, _prompt, options) => {
const id = `child-${records.size + 1}`;
const record = { id, type, status: "completed", result: "done", parentAgentId: options.parentAgentId };
records.set(id, record);
return { id, record };
});
manager = {
spawn,
spawnAndWait,
getRecord: (id: string) => records.get(id),
resume: vi.fn(),
} as any;
});
afterEach(() => {
setScopeModelsEnabled(false);
rmSync(cwd, { recursive: true, force: true });
});
describe("child-safe nested Agent tools", () => {
it("allows any enabled agent when allowed_subagents is omitted", async () => {
const [agent] = tools();
const result = await execute(agent, {
subagent_type: "reviewer",
description: "review evidence",
prompt: "Review it",
});
expect(result.isError).toBe(false);
expect(spawnAndWait).toHaveBeenCalledWith(
expect.anything(), expect.anything(), "reviewer", "Review it",
expect.objectContaining({
depth: 2,
parentAgentId: "parent-1",
maxSubagentDepth: 2,
configCwd: cwd,
}),
expect.any(Function), // onSpawned — attaches the child's transcript
);
});
it("keeps agent discovery rooted in inherited config, not the working directory", async () => {
const workCwd = mkdtempSync(join(tmpdir(), "nested-tools-work-"));
const workAgentDir = join(workCwd, ".pi", "agents");
mkdirSync(workAgentDir, { recursive: true });
writeFileSync(join(workAgentDir, "intruder.md"), "---\ndescription: intruder\n---\nintruder\n");
try {
const [agent] = tools();
const result = await execute(agent, {
subagent_type: "intruder",
description: "untrusted agent",
prompt: "Do work",
}, workCwd);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown or disabled");
expect(spawnAndWait).not.toHaveBeenCalled();
} finally {
rmSync(workCwd, { recursive: true, force: true });
}
});
it("enforces a narrow allowlist", async () => {
const [limited] = tools(["scout"]);
const denied = await execute(limited, {
subagent_type: "reviewer",
description: "review evidence",
prompt: "Review it",
});
expect(denied.isError).toBe(true);
expect(denied.content[0].text).toContain("not allowed");
expect(spawnAndWait).not.toHaveBeenCalled();
const allowed = await execute(limited, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
});
expect(allowed.isError).toBe(false);
expect(spawnAndWait).toHaveBeenCalledTimes(1);
});
it("resolves nested types without touching the process-global registry", async () => {
// A worktree-isolated parent hands its own config root down. Resolving from
// it must not swap the registry the main session and every other agent read.
const otherCwd = mkdtempSync(join(tmpdir(), "nested-tools-config-"));
const otherAgentDir = join(otherCwd, ".pi", "agents");
mkdirSync(otherAgentDir, { recursive: true });
writeFileSync(join(otherAgentDir, "branch-only.md"), "---\ndescription: branch-only\n---\nbranch-only\n");
const before = getAvailableTypes();
try {
const [agent] = tools("all", 1, 2, otherCwd);
const result = await execute(agent, {
subagent_type: "branch-only",
description: "branch agent",
prompt: "Do work",
});
// Resolved from the inherited root...
expect(result.isError).toBe(false);
// ...without leaking it into the shared registry.
expect(getAvailableTypes()).toEqual(before);
expect(getAvailableTypes()).not.toContain("branch-only");
} finally {
rmSync(otherCwd, { recursive: true, force: true });
}
});
it("applies the scopeModels allowlist to a caller-supplied model", async () => {
writeFileSync(
join(cwd, ".pi", "settings.json"),
JSON.stringify({ enabledModels: ["anthropic/allowed"] }),
);
setScopeModelsEnabled(true);
const [agent] = tools();
const blocked = await execute(agent, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
model: "anthropic/blocked",
});
expect(blocked.isError).toBe(true);
expect(blocked.content[0].text).toContain("Model not in scope");
expect(spawnAndWait).not.toHaveBeenCalled();
const inScope = await execute(agent, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
model: "anthropic/allowed",
});
expect(inScope.isError).toBe(false);
});
it("queues a steer for an owned child whose session is not ready yet", async () => {
const [, , steer] = tools();
const record: Record<string, unknown> = {
id: "child-1",
status: "running",
parentAgentId: "parent-1",
};
records.set("child-1", record);
const result = await execute(steer, { agent_id: "child-1", message: "focus on tests" });
expect(result.isError).toBe(false);
expect(record.pendingSteers).toEqual(["focus on tests"]);
});
it("blocks delegation at the inherited depth cap", async () => {
const [agent] = tools("all", 2, 2);
const result = await execute(agent, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("depth=2, max=2");
expect(spawnAndWait).not.toHaveBeenCalled();
});
it("rejects unknown or disabled nested agent types instead of falling back", async () => {
writeAgent("disabled", "enabled: false\n");
registerAgents(loadCustomAgents(cwd));
const [agent] = tools();
for (const subagentType of ["missing", "disabled"]) {
const result = await execute(agent, {
subagent_type: subagentType,
description: "invalid agent",
prompt: "Do work",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown or disabled");
}
});
it("supports background launches and ownership-scopes result, resume, and steer", async () => {
const [agent, getResult, steer] = tools(["scout"]);
const launched = await execute(agent, {
subagent_type: "scout",
description: "find files",
prompt: "Find them",
run_in_background: true,
});
expect(launched.content[0].text).toContain("child-1");
expect(spawn).toHaveBeenCalledWith(
expect.anything(), expect.anything(), "scout", "Find them",
expect.objectContaining({ isBackground: true, depth: 2, parentAgentId: "parent-1" }),
);
const own = await execute(getResult, { agent_id: "child-1" });
expect(own.isError).toBe(false);
records.set("foreign", {
id: "foreign",
status: "running",
result: "secret",
parentAgentId: "other",
session: { steer: vi.fn() },
});
expect((await execute(getResult, { agent_id: "foreign" })).isError).toBe(true);
expect((await execute(steer, { agent_id: "foreign", message: "stop" })).isError).toBe(true);
expect((await execute(agent, {
resume: "foreign",
subagent_type: "scout",
description: "resume foreign",
prompt: "Continue",
})).isError).toBe(true);
expect(manager.resume).not.toHaveBeenCalled();
});
it("waits for a queued owned child to start and settle", async () => {
const [, getResult] = tools();
const record = {
id: "queued-child",
status: "queued",
parentAgentId: "parent-1",
promise: undefined as Promise<unknown> | undefined,
result: undefined as string | undefined,
};
records.set(record.id, record);
setTimeout(() => {
record.status = "running";
record.promise = Promise.resolve().then(() => {
record.status = "completed";
record.result = "queued done";
});
}, 10);
const result = await execute(getResult, { agent_id: record.id, wait: true });
expect(result.content[0].text).toBe("queued done");
});
it("aborts a nested result wait without aborting the owned child", async () => {
const [, getResult] = tools();
let settleChild: (() => void) | undefined;
const record = {
id: "running-child",
status: "running",
parentAgentId: "parent-1",
promise: new Promise<void>(resolve => { settleChild = resolve; }),
};
records.set(record.id, record);
const controller = new AbortController();
const outcome = getResult
.execute("call-abort", { agent_id: record.id, wait: true }, controller.signal, undefined, ctx())
.then(() => "resolved", (e: unknown) => (e instanceof Error ? e.name : String(e)));
controller.abort();
const settled = await Promise.race([
outcome,
new Promise(r => setTimeout(() => r("timed-out"), 100)),
]);
expect(settled).toBe("AbortError");
// The wait was cancelled but the child was never aborted or consumed.
expect(record.status).toBe("running");
settleChild?.();
});
it("still rejects unknown types when the project configures a fallback", async () => {
// The contract nested delegation documents is "rejected rather than falling
// back" — a top-level fallback must not hand a nested caller an agent its
// allowlist never named.
setFallbackSubagent("scout");
try {
const [agent] = tools(["scout"]);
const result = await execute(agent, {
subagent_type: "definitely-missing",
description: "typo",
prompt: "Do work",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Unknown or disabled nested agent type");
expect(spawnAndWait).not.toHaveBeenCalled();
} finally {
setFallbackSubagent(undefined);
}
});
it("hands the branch cap down to the child it spawns", async () => {
const [agent] = tools("all", 1, 3);
const result = await execute(agent, {
subagent_type: "scout",
description: "child",
prompt: "Do work",
});
expect(result.isError).toBe(false);
expect(spawnAndWait).toHaveBeenCalledWith(
expect.anything(), expect.anything(), "scout", "Do work",
expect.objectContaining({ depth: 2, maxSubagentDepth: 3 }),
expect.any(Function),
);
});
it("flags a truncated child run instead of passing partial output off as complete", async () => {
spawnAndWait.mockImplementation(async () => ({
id: "child-1",
record: { id: "child-1", status: "steered", result: "half an answer", parentAgentId: "parent-1" },
}));
const [agent] = tools();
const result = await execute(agent, {
subagent_type: "scout",
description: "truncated",
prompt: "Do work",
});
expect(result.isError).toBe(false);
// Foreground: the whole output is inline and no id came back, so the note
// must not invite a get_subagent_result call the parent cannot make (#174).
expect(result.content[0].text).toContain("everything the agent produced is above");
expect(result.content[0].text).not.toContain("output is partial");
// The warning leads, so it can't read as part of the child's own answer.
expect(result.content[0].text.indexOf("half an answer")).toBeGreaterThan(0);
});
it("uses the fetchable wording when the parent polls a background child by id", async () => {
records.set("child-1", {
id: "child-1", status: "aborted", result: "partial work", parentAgentId: "parent-1",
});
const [, getResult] = tools();
const result = await execute(getResult, { agent_id: "child-1" });
// Here the parent does hold a valid id, so the background wording applies.
expect(result.content[0].text).toContain("output may be incomplete");
expect(result.content[0].text).not.toContain("everything the agent produced is above");
});
it("keeps a failed child's partial output alongside the error", async () => {
spawnAndWait.mockImplementation(async () => ({
id: "child-1",
record: {
id: "child-1", status: "error", error: "provider exploded",
result: "got this far", parentAgentId: "parent-1",
},
}));
const [agent] = tools();
const result = await execute(agent, {
subagent_type: "scout",
description: "failing",
prompt: "Do work",
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("provider exploded");
expect(result.content[0].text).toContain("got this far");
});
it("attributes a nested child's token spend to the owning parent", async () => {
const parent = { id: "parent-1", status: "running", lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 } };
records.set("parent-1", parent);
spawn.mockImplementation((_pi, _ctx, _type, _prompt, options) => {
options.onAssistantUsage?.({ input: 100, output: 20, cacheWrite: 5 });
return "child-1";
});
const [agent] = tools();
await execute(agent, {
subagent_type: "scout",
description: "spender",
prompt: "Do work",
run_in_background: true,
});
expect(parent.lifetimeUsage).toEqual({ input: 100, output: 20, cacheWrite: 5 });
});
it("attributes spend up the whole ancestor chain, not just one level", async () => {
// A spawn callback fires only for that child's own turns, so a deeper
// descendant would otherwise never reach the one record anyone can see.
const top = { id: "top", status: "running", lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 } };
const middle = {
id: "parent-1", status: "running", parentAgentId: "top",
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
};
records.set("top", top);
records.set("parent-1", middle);
spawn.mockImplementation((_pi, _ctx, _type, _prompt, options) => {
options.onAssistantUsage?.({ input: 7, output: 3, cacheWrite: 1 });
return "child-1";
});
const [agent] = tools();
await execute(agent, {
subagent_type: "scout",
description: "deep spender",
prompt: "Do work",
run_in_background: true,
});
expect(middle.lifetimeUsage).toEqual({ input: 7, output: 3, cacheWrite: 1 });
expect(top.lifetimeUsage).toEqual({ input: 7, output: 3, cacheWrite: 1 });
});
it("files a nested transcript under the root session, honoring output_transcript", async () => {
records.set("parent-1", { id: "parent-1", status: "running", rootSessionId: "root-session" });
spawnAndWait.mockImplementation(async (_pi, _ctx, type, _prompt, options, onSpawned) => {
const record = { id: "child-1", type, status: "completed", result: "done", parentAgentId: options.parentAgentId };
records.set("child-1", record);
onSpawned?.("child-1");
return { id: "child-1", record };
});
// Real path construction (not mocked here), so clean up what it writes.
const transcriptRoot = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`, encodeCwd(cwd));
try {
const [agent] = tools();
await execute(agent, { subagent_type: "scout", description: "traced", prompt: "Do work" });
expect(records.get("child-1").outputFile).toContain(join("root-session", "tasks", "child-1.output"));
// The child's own frontmatter still wins.
writeAgent("quiet", "output_transcript: false\n");
registerAgents(loadCustomAgents(cwd));
records.delete("child-1");
await execute(agent, { subagent_type: "quiet", description: "untraced", prompt: "Do work" });
expect(records.get("child-1").outputFile).toBeUndefined();
} finally {
rmSync(transcriptRoot, { recursive: true, force: true });
}
});
it("forwards the execution context to the manager unmodified", async () => {
// Each AgentSession builds its own ExtensionRunner, so the ctx handed to
// execute is the CHILD's — capturing one at tool-build time instead would
// silently misroute the grandchild's cwd, conversation, and model.
const [agent] = tools();
const executionCtx = ctx();
await agent.execute("call-1", {
subagent_type: "scout",
description: "ctx check",
prompt: "Do work",
} as any, undefined, undefined, executionCtx);
expect(spawnAndWait.mock.calls[0][1]).toBe(executionCtx);
});
});
// setMaxSubagentDepth clamps its input, but test/settings.test.ts only asserts
// the applier SPY was called — the real Math.max(0, Math.floor(n)) never runs
// there. A hand-edited subagents.json reaches this unfiltered, and losing the
// clamp is silent in the worst direction: a negative depth makes the
// `depth >= maxSubagentDepth` check true everywhere, disabling nested
// delegation project-wide with no error.
describe("setMaxSubagentDepth clamping", () => {
let previous: number;
beforeEach(() => { previous = getMaxSubagentDepth(); });
afterEach(() => { setMaxSubagentDepth(previous); });
it("floors a negative depth at 0 rather than storing it", () => {
setMaxSubagentDepth(-1);
expect(getMaxSubagentDepth()).toBe(0);
});
it("truncates a fractional depth toward zero", () => {
setMaxSubagentDepth(2.9);
expect(getMaxSubagentDepth()).toBe(2);
});
it("stores a valid depth unchanged", () => {
setMaxSubagentDepth(3);
expect(getMaxSubagentDepth()).toBe(3);
});
});