mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
544 lines
19 KiB
TypeScript
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);
|
|
});
|
|
});
|