mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
412 lines
19 KiB
TypeScript
412 lines
19 KiB
TypeScript
// End-to-end test for `toolDescriptionMode` (#91): settings file → sanitize →
|
|
// applier → registration-time description pick. Instantiates the real extension
|
|
// with a mock pi (same pattern as print-mode.test.ts) inside a temp cwd, then
|
|
// inspects the registered Agent tool's description.
|
|
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import subagentsExtension from "../src/index.js";
|
|
import { setWorktreeIsolationEnabled } from "../src/worktree.js";
|
|
|
|
const EXAMPLE_TEMPLATE = fileURLToPath(new URL("../examples/agent-tool-description.md", import.meta.url));
|
|
|
|
function makePi() {
|
|
const tools = new Map<string, any>();
|
|
const handlers = new Map<string, any>();
|
|
|
|
return {
|
|
pi: {
|
|
registerMessageRenderer: vi.fn(),
|
|
registerTool: vi.fn((tool: any) => {
|
|
tools.set(tool.name, tool);
|
|
}),
|
|
registerCommand: vi.fn(),
|
|
on: vi.fn((event: string, handler: any) => {
|
|
handlers.set(event, handler);
|
|
}),
|
|
events: {
|
|
emit: vi.fn(),
|
|
on: vi.fn(() => vi.fn()),
|
|
},
|
|
appendEntry: vi.fn(),
|
|
sendMessage: vi.fn(),
|
|
} as any,
|
|
tools,
|
|
handlers,
|
|
};
|
|
}
|
|
|
|
describe("toolDescriptionMode", () => {
|
|
let tmpDir: string;
|
|
let hermeticAgentDir: string;
|
|
let prevCwd: string;
|
|
let prevAgentDir: string | undefined;
|
|
let prevHome: string | undefined;
|
|
let shutdown: (() => Promise<void>) | undefined;
|
|
|
|
function setup(settings?: Record<string, unknown>, beforeInstantiate?: () => void) {
|
|
tmpDir = mkdtempSync(join(tmpdir(), "pi-tooldesc-"));
|
|
// Isolate global settings (getAgentDir / ~/.pi) so the dev's real
|
|
// subagents.json can't leak into the "default is full" assertion.
|
|
hermeticAgentDir = mkdtempSync(join(tmpdir(), "pi-tooldesc-agentdir-"));
|
|
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
prevHome = process.env.HOME;
|
|
process.env.PI_CODING_AGENT_DIR = hermeticAgentDir;
|
|
process.env.HOME = hermeticAgentDir;
|
|
prevCwd = process.cwd();
|
|
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
|
|
if (settings) {
|
|
writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify(settings));
|
|
}
|
|
beforeInstantiate?.();
|
|
process.chdir(tmpDir);
|
|
|
|
const { pi, tools, handlers } = makePi();
|
|
subagentsExtension(pi);
|
|
shutdown = async () => {
|
|
await handlers.get("session_shutdown")?.({}, { hasUI: false, ui: {} } as any);
|
|
};
|
|
return tools;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await shutdown?.();
|
|
shutdown = undefined;
|
|
// applySettings only applies keys that are PRESENT, so a subagents.json
|
|
// without `worktreeIsolation` leaves the module singleton wherever the
|
|
// previous test left it. Reset it so each setup()'s settings decide, and
|
|
// so the "default" assertions below really test the default.
|
|
setWorktreeIsolationEnabled(true);
|
|
process.chdir(prevCwd);
|
|
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
|
|
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
|
|
if (prevHome == null) delete process.env.HOME;
|
|
else process.env.HOME = prevHome;
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
rmSync(hermeticAgentDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("defaults to the full description", () => {
|
|
const tools = setup();
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("## Usage notes");
|
|
expect(desc).toContain("## Writing the prompt");
|
|
// Full agent descriptions are embedded (a late Explore sentence survives).
|
|
expect(desc).toContain("very thorough");
|
|
});
|
|
|
|
it("compact mode swaps in the short description with one-line type list", () => {
|
|
const tools = setup({ toolDescriptionMode: "compact" });
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("Launch an autonomous agent");
|
|
expect(desc).not.toContain("## Usage notes");
|
|
expect(desc).not.toContain("## Writing the prompt");
|
|
// Type list keeps every agent but only the first sentence of each description.
|
|
expect(desc).toContain("- general-purpose:");
|
|
expect(desc).toContain("- Explore: Fast read-only search agent for locating code. (Tools:");
|
|
expect(desc).not.toContain("very thorough");
|
|
// The point of the feature: materially smaller than the full version.
|
|
expect(desc.length).toBeLessThan(1600);
|
|
});
|
|
|
|
it("invalid mode in the settings file is dropped — full description", () => {
|
|
const tools = setup({ toolDescriptionMode: "tiny" });
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("## Usage notes");
|
|
});
|
|
|
|
it("compact keeps every load-bearing contract — fails when a behavior change forgets compact", () => {
|
|
const tools = setup({ toolDescriptionMode: "compact" });
|
|
const desc: string = tools.get("Agent").description;
|
|
// One keyword per behavioral contract the orchestrator must know about.
|
|
// If you change one of these behaviors, update BOTH descriptions.
|
|
for (const contract of [
|
|
"run_in_background",
|
|
"resume",
|
|
"steer_subagent",
|
|
'isolation: "worktree"',
|
|
".pi/agents/",
|
|
"self-contained",
|
|
]) {
|
|
expect(desc).toContain(contract);
|
|
}
|
|
});
|
|
|
|
// The compact test above pins the prose alone, which is right for compact —
|
|
// it is the only place that mode states these. `full` is different: several
|
|
// contracts are stated twice, in the description AND in the param schema, so
|
|
// pinning prose alone would block a legitimate move of one into the other
|
|
// while missing the failure that actually matters — a contract that ends up
|
|
// in neither. Asserting over description + schema is the invariant that
|
|
// survives either choice. The second test then keeps the schema half honest,
|
|
// so "it's also in the schema" can never degrade to an empty stub.
|
|
it("full states every load-bearing contract in the description or the schema", () => {
|
|
const tool = setup().get("Agent");
|
|
const visible = `${tool.description}\n${JSON.stringify(tool.parameters)}`;
|
|
for (const contract of [
|
|
"run_in_background",
|
|
"resume",
|
|
"steer_subagent",
|
|
"worktree",
|
|
".pi/agents/",
|
|
"self-contained",
|
|
"model",
|
|
"thinking",
|
|
"inherit_context",
|
|
]) {
|
|
expect(visible).toContain(contract);
|
|
}
|
|
});
|
|
|
|
it("every strategy param carries a real description of its own", () => {
|
|
const props = setup().get("Agent").parameters?.properties ?? {};
|
|
for (const name of ["run_in_background", "model", "thinking", "inherit_context"]) {
|
|
// Long enough to be an explanation the model can act on, not a bare label.
|
|
expect(props[name]?.description?.length ?? 0).toBeGreaterThan(40);
|
|
}
|
|
});
|
|
|
|
it("custom mode renders the project template with placeholders substituted", () => {
|
|
const tools = setup({ toolDescriptionMode: "custom" }, () => {
|
|
writeFileSync(
|
|
join(tmpDir, ".pi", "agent-tool-description.md"),
|
|
"My agents:\n{{typeList}}\n\nGlobal dir: {{agentDir}}\nUnknown: {{nope}}\nCost: $& stays literal",
|
|
);
|
|
});
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("My agents:");
|
|
expect(desc).toContain("- general-purpose:"); // {{typeList}} expanded
|
|
expect(desc).toContain(`Global dir: ${hermeticAgentDir}`); // {{agentDir}} expanded
|
|
expect(desc).toContain("Unknown: {{nope}}"); // unknown placeholder left verbatim
|
|
expect(desc).toContain("Cost: $& stays literal"); // no $-pattern expansion
|
|
expect(desc).not.toContain("## Usage notes");
|
|
});
|
|
|
|
it("custom mode falls back to the global file when no project file exists", () => {
|
|
const tools = setup({ toolDescriptionMode: "custom" }, () => {
|
|
writeFileSync(join(hermeticAgentDir, "agent-tool-description.md"), "GLOBAL CUSTOM\n{{compactTypeList}}");
|
|
});
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("GLOBAL CUSTOM");
|
|
expect(desc).toContain("- Explore: Fast read-only search agent for locating code. (Tools:");
|
|
});
|
|
|
|
it("{{scheduleGuideline}} expands to the schedule bullet when scheduling is on (default)", () => {
|
|
const tools = setup({ toolDescriptionMode: "custom" }, () => {
|
|
writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), "RULES:{{scheduleGuideline}}\nEND");
|
|
});
|
|
const desc: string = tools.get("Agent").description;
|
|
// The expansion carries its own leading "\n- " bullet.
|
|
expect(desc).toContain("RULES:\n- Use `schedule` only when");
|
|
});
|
|
|
|
it("{{scheduleGuideline}} expands to the empty string when scheduling is disabled", () => {
|
|
const tools = setup({ toolDescriptionMode: "custom", schedulingEnabled: false }, () => {
|
|
writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), "RULES:{{scheduleGuideline}}\nEND");
|
|
});
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("RULES:\nEND");
|
|
expect(desc).not.toContain("schedule");
|
|
});
|
|
|
|
it("{{isolationGuideline}} expands to the isolation bullet when worktrees are on (default)", () => {
|
|
const tools = setup({ toolDescriptionMode: "custom" }, () => {
|
|
writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), "RULES:{{isolationGuideline}}\nEND");
|
|
});
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain('RULES:\n- Use isolation: "worktree"');
|
|
});
|
|
|
|
it("{{isolationGuideline}} expands to the empty string when worktree isolation is disabled", () => {
|
|
const tools = setup({ toolDescriptionMode: "custom", worktreeIsolation: false }, () => {
|
|
writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), "RULES:{{isolationGuideline}}\nEND");
|
|
});
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("RULES:\nEND");
|
|
expect(desc).not.toContain("isolation");
|
|
});
|
|
|
|
it("every documented placeholder is replaced — no {{ }} residue", () => {
|
|
const tools = setup({ toolDescriptionMode: "custom" }, () => {
|
|
writeFileSync(
|
|
join(tmpDir, ".pi", "agent-tool-description.md"),
|
|
"A {{typeList}} B {{compactTypeList}} C {{agentDir}} D {{scheduleGuideline}} E {{isolationGuideline}} F",
|
|
);
|
|
});
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).not.toContain("{{");
|
|
expect(desc).not.toContain("}}");
|
|
});
|
|
|
|
it("the shipped example template renders byte-identical to the full description", async () => {
|
|
// Guards examples/agent-tool-description.md against going stale: it must
|
|
// reproduce the full description exactly. If you edit one, edit the other.
|
|
const example = readFileSync(EXAMPLE_TEMPLATE, "utf-8");
|
|
const tools = setup({ toolDescriptionMode: "custom" }, () => {
|
|
writeFileSync(join(tmpDir, ".pi", "agent-tool-description.md"), example);
|
|
});
|
|
const customDesc: string = tools.get("Agent").description;
|
|
|
|
// Second instance in the same hermetic cwd, flipped to full mode.
|
|
writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ toolDescriptionMode: "full" }));
|
|
const second = makePi();
|
|
subagentsExtension(second.pi);
|
|
try {
|
|
expect(customDesc).toBe(second.tools.get("Agent").description);
|
|
} finally {
|
|
await second.handlers.get("session_shutdown")?.({}, { hasUI: false, ui: {} } as any);
|
|
}
|
|
});
|
|
|
|
it("custom mode without a file falls back to the full description with a warning", () => {
|
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
try {
|
|
const tools = setup({ toolDescriptionMode: "custom" });
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("## Usage notes");
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("no agent-tool-description.md found"));
|
|
} finally {
|
|
warn.mockRestore();
|
|
}
|
|
});
|
|
|
|
// README:87 promises that disabling scheduling "removes `schedule` from the
|
|
// `Agent` tool spec (no LLM-context cost)". Only the {{scheduleGuideline}}
|
|
// TEXT expansion was tested — nothing asserted the schema itself, so the
|
|
// parameter could keep costing tokens (and stay callable) while the prose
|
|
// claimed otherwise.
|
|
describe("schedulingEnabled gates the schedule parameter", () => {
|
|
const props = (tools: Map<string, any>) =>
|
|
Object.keys(tools.get("Agent").parameters?.properties ?? {});
|
|
|
|
it("advertises `schedule` by default", () => {
|
|
expect(props(setup())).toContain("schedule");
|
|
});
|
|
|
|
it("removes `schedule` from the tool schema when scheduling is disabled", () => {
|
|
const names = props(setup({ schedulingEnabled: false }));
|
|
expect(names).not.toContain("schedule");
|
|
// The rest of the parameter surface is untouched — this gates one field,
|
|
// not the tool.
|
|
expect(names).toEqual(expect.arrayContaining(["prompt", "description", "subagent_type"]));
|
|
});
|
|
});
|
|
|
|
// The schema half of `worktreeIsolation: false` shipped without the prose
|
|
// half: `isolationParam` dropped the field while both descriptions kept
|
|
// telling the model to pass it. Nothing rejects the undeclared key (TypeBox
|
|
// sets no additionalProperties: false) and, by design, nothing notes the
|
|
// downgrade on the result — so the model had every reason to report a
|
|
// `pi-agent-*` branch that was never created. Schema and prose have to move
|
|
// together, which is why both are asserted here.
|
|
describe("worktreeIsolation gates the isolation parameter and its prose", () => {
|
|
const props = (tools: Map<string, any>) =>
|
|
Object.keys(tools.get("Agent").parameters?.properties ?? {});
|
|
|
|
it("advertises `isolation` in schema and prose by default", () => {
|
|
const tools = setup();
|
|
expect(props(tools)).toContain("isolation");
|
|
expect(tools.get("Agent").description).toContain('Use isolation: "worktree"');
|
|
});
|
|
|
|
it("drops both when worktree isolation is disabled", () => {
|
|
const tools = setup({ worktreeIsolation: false });
|
|
const names = props(tools);
|
|
expect(names).not.toContain("isolation");
|
|
expect(tools.get("Agent").description).not.toContain("isolation");
|
|
// One field, not the tool — and the neighbouring gate is unaffected.
|
|
expect(names).toEqual(expect.arrayContaining(["prompt", "description", "subagent_type", "schedule"]));
|
|
});
|
|
|
|
it("drops the compact description's bullet too", () => {
|
|
const enabled = setup({ toolDescriptionMode: "compact" });
|
|
expect(enabled.get("Agent").description).toContain('isolation: "worktree"');
|
|
});
|
|
|
|
it("compact mode says nothing about isolation when disabled", () => {
|
|
const tools = setup({ toolDescriptionMode: "compact", worktreeIsolation: false });
|
|
expect(tools.get("Agent").description).not.toContain("isolation");
|
|
// The bullet above it survives — the gate trims a suffix, not the list.
|
|
expect(tools.get("Agent").description).toContain("resume continues a previous agent by ID");
|
|
});
|
|
});
|
|
|
|
// The tool description is the only thing the orchestrator LLM knows about an
|
|
// agent's capabilities before spawning it. `tools: none` and an `ext:`-only
|
|
// `tools:` both parse to zero built-ins (custom-agents.ts parseToolsField),
|
|
// and test/fixtures/.pi/agents/tools-none.md pins that the *runtime* really
|
|
// does drop every built-in. So the description must not claim otherwise —
|
|
// an agent advertised as having `bash` that cannot run `bash` gets routed
|
|
// work it can only fail at.
|
|
describe("tool scope suffix reflects the real built-in set", () => {
|
|
function withAgent(name: string, frontmatter: string, settings?: Record<string, unknown>) {
|
|
const extra = frontmatter ? `${frontmatter}\n` : "";
|
|
return setup(settings, () => {
|
|
mkdirSync(join(tmpDir, ".pi", "agents"), { recursive: true });
|
|
writeFileSync(
|
|
join(tmpDir, ".pi", "agents", `${name}.md`),
|
|
`---\ndescription: ${name} agent.\n${extra}---\n\nBody.\n`,
|
|
);
|
|
});
|
|
}
|
|
|
|
it("`tools: none` never claims the full built-in set", () => {
|
|
const tools = withAgent("quiet", "tools: none");
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).not.toContain("- quiet: quiet agent. (Tools: *)");
|
|
});
|
|
|
|
it("`tools: none` says none only when the agent can call nothing at all", () => {
|
|
// extensions: false and isolated: true both leave the agent with zero
|
|
// built-ins AND zero extension tools — the one case "none" is true.
|
|
for (const fm of ["tools: none\nextensions: false", "tools: none\nisolated: true"]) {
|
|
const tools = withAgent("silent", fm);
|
|
expect(tools.get("Agent").description).toContain("- silent: silent agent. (Tools: none)");
|
|
}
|
|
});
|
|
|
|
it("`tools: none` with extensions loaded is not described as having no tools", () => {
|
|
// Zero built-ins is not zero tools: test/fixtures/.pi/agents/tools-none.md
|
|
// pins that such an agent still surfaces alpha_read, alpha_write, beta_tool.
|
|
// Saying "none" understates it and routes work away from the only agent
|
|
// that could do it — the mirror of the bug this suffix used to have.
|
|
const tools = withAgent("probe", 'tools: none\nextensions: "./ext-alpha.mjs"');
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("- probe: probe agent. (Tools: no built-ins, extension tools only)");
|
|
expect(desc).not.toContain("- probe: probe agent. (Tools: *)");
|
|
expect(desc).not.toContain("- probe: probe agent. (Tools: none)");
|
|
});
|
|
|
|
it("an ext:-only `tools:` is described by what it actually has", () => {
|
|
const tools = withAgent("extonly", 'tools: "ext:probe.mjs"');
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("- extonly: extonly agent. (Tools: no built-ins, extension tools only)");
|
|
expect(desc).not.toContain("- extonly: extonly agent. (Tools: *)");
|
|
});
|
|
|
|
it("compact mode shares the suffix builder and must not diverge", () => {
|
|
const tools = withAgent("quiet", "tools: none\nextensions: false", { toolDescriptionMode: "compact" });
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("- quiet: quiet agent. (Tools: none)");
|
|
expect(desc).not.toContain("- quiet: quiet agent. (Tools: *)");
|
|
});
|
|
|
|
it("an omitted `tools:` still renders as * — absent means all built-ins", () => {
|
|
// Guards the fix from over-correcting: undefined (inherit everything,
|
|
// as the shipped defaults do) is not the same as [] (explicitly zero).
|
|
const tools = withAgent("broad", "");
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("- broad: broad agent. (Tools: *)");
|
|
});
|
|
|
|
it("a narrowed `tools:` still lists the names it actually has", () => {
|
|
const tools = withAgent("narrow", "tools: read, grep");
|
|
const desc: string = tools.get("Agent").description;
|
|
expect(desc).toContain("- narrow: narrow agent. (Tools: read, grep)");
|
|
});
|
|
});
|
|
});
|