Files
my-pi/pi-subagents/test/agent-file-toggle.test.ts

387 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The `/agents` enable/disable operations edit an agent .md file's frontmatter.
// The LOAD side (src/custom-agents.ts) parses that frontmatter with a real YAML
// parser, so `enabled: false` is honored wherever it appears in the block. The
// WRITE side here must agree — README.md documents `enabled: false` as a field
// users hand-write, and a hand-authored file puts it wherever the author likes.
//
// These live in src/agent-file-toggle.ts rather than inside the `/agents`
// command closure because `registerCommand` is mocked in every wiring test,
// which is why none of this had coverage.
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildNewAgentFile,
disableInContent,
enableInContent,
findAgentFile,
isDisabledContent,
isEmptyStub,
locateAgentFile,
} from "../src/agent-file-toggle.js";
/** What the loader concludes about a file, via the same parser it really uses. */
function loaderSeesDisabled(content: string): boolean {
return parseFrontmatter<Record<string, unknown>>(content).frontmatter.enabled === false;
}
const HAND_AUTHORED_DISABLED = "---\ndescription: Scout the repo\nenabled: false\n---\n\nYou are a scout.\n";
const EXTENSION_WRITTEN_DISABLED = "---\nenabled: false\ndescription: Scout the repo\n---\n\nYou are a scout.\n";
const ENABLED = "---\ndescription: Scout the repo\n---\n\nYou are a scout.\n";
describe("enableInContent", () => {
it("strips enabled: false when it is the first frontmatter line", () => {
const { content, changed } = enableInContent(EXTENSION_WRITTEN_DISABLED);
expect(changed).toBe(true);
expect(loaderSeesDisabled(content)).toBe(false);
});
it("strips enabled: false when another key precedes it", () => {
// The shape a user gets by following README.md — `description` first.
const { content, changed } = enableInContent(HAND_AUTHORED_DISABLED);
expect(changed).toBe(true);
expect(loaderSeesDisabled(content)).toBe(false);
});
it("strips enabled: false when it is the last frontmatter line", () => {
const src = "---\ndescription: Scout\ndisplay_name: Scout\nenabled: false\n---\n\nBody.\n";
const { content, changed } = enableInContent(src);
expect(changed).toBe(true);
expect(loaderSeesDisabled(content)).toBe(false);
});
it("reports changed: false when there is nothing to strip", () => {
const { content, changed } = enableInContent(ENABLED);
expect(changed).toBe(false);
expect(content).toBe(ENABLED);
});
it("leaves the body and other frontmatter keys untouched", () => {
const src = "---\ndescription: Scout\nenabled: false\n# a comment\nmodel: haiku\n---\n\nLine 1.\n\nLine 2.\n";
const { content } = enableInContent(src);
expect(content).toContain("# a comment");
expect(content).toContain("model: haiku");
expect(content).toContain("Line 1.\n\nLine 2.\n");
expect(loaderSeesDisabled(content)).toBe(false);
});
it("handles CRLF line endings", () => {
const src = "---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n";
const { content, changed } = enableInContent(src);
expect(changed).toBe(true);
expect(loaderSeesDisabled(content)).toBe(false);
});
});
describe("disableInContent", () => {
it("inserts enabled: false into a normal frontmatter block", () => {
const { content, outcome } = disableInContent(ENABLED);
expect(outcome).toBe("disabled");
expect(loaderSeesDisabled(content)).toBe(true);
});
it("is idempotent when the key is already first", () => {
expect(disableInContent(EXTENSION_WRITTEN_DISABLED).outcome).toBe("already-disabled");
});
it("is idempotent when the key is already present mid-block", () => {
expect(disableInContent(HAND_AUTHORED_DISABLED).outcome).toBe("already-disabled");
});
// The previous guard was `content.includes("\nenabled: false\n")`, which a
// trailing space defeats. It then inserted a SECOND `enabled: false`, and
// duplicate map keys make the whole file unparseable — so an agent the user
// asked to disable disappeared from `/agents` entirely (since #212 an
// unparseable agent file is skipped, not surfaced).
it("never writes a file the loader cannot parse", () => {
for (const src of [
"---\ndescription: x\nenabled: false \n---\nbody\n",
"---\ndescription: x\nenabled: false\t\n---\nbody\n",
HAND_AUTHORED_DISABLED,
EXTENSION_WRITTEN_DISABLED,
ENABLED,
]) {
const { content } = disableInContent(src);
expect(() => parseFrontmatter(content), JSON.stringify(src)).not.toThrow();
expect(loaderSeesDisabled(content), JSON.stringify(src)).toBe(true);
}
});
it("reports no-frontmatter rather than claiming success on a fence-less file", () => {
const src = "Just a body, no frontmatter at all.\n";
const { content, outcome } = disableInContent(src);
expect(outcome).toBe("no-frontmatter");
expect(content).toBe(src);
});
it("disables a CRLF file instead of misreporting it as frontmatter-less", () => {
const src = "---\r\ndescription: Scout\r\n---\r\n\r\nBody.\r\n";
const { content, outcome } = disableInContent(src);
expect(outcome).toBe("disabled");
expect(loaderSeesDisabled(content)).toBe(true);
});
it("reports no-frontmatter for a BOM-prefixed file, matching what the loader sees", () => {
// The parser doesn't accept a BOM either — it reports an empty frontmatter
// and treats the whole file as body. So refusing here is agreement with the
// read side, not a gap: inserting the key would have no effect on loading.
const src = "---\ndescription: Scout\n---\n\nBody.\n";
expect(parseFrontmatter<Record<string, unknown>>(src).frontmatter).toEqual({});
const { content, outcome } = disableInContent(src);
expect(outcome).toBe("no-frontmatter");
expect(content).toBe(src);
});
});
describe("isDisabledContent", () => {
it("sees the key at the first frontmatter line", () => {
expect(isDisabledContent(EXTENSION_WRITTEN_DISABLED)).toBe(true);
});
it("sees the key mid-block", () => {
expect(isDisabledContent(HAND_AUTHORED_DISABLED)).toBe(true);
});
it("sees the key in a CRLF file", () => {
expect(isDisabledContent("---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n")).toBe(true);
});
it("is false for an enabled file", () => {
expect(isDisabledContent(ENABLED)).toBe(false);
});
// Detection is a read, so it asks the loader's parser instead of mirroring it.
// A mirror has to be right about two independent things, and a regex was wrong
// about both: YAML's other spellings of `false`, and pi's fence scan, which
// closes the block at any line *starting* `---` — so `----` ends it early and
// the keys after it are body text, not frontmatter. Getting that backwards told
// a user their running agent was "already disabled".
it.each([
["lowercase bare false", "---\ndescription: x\nenabled: false\n---\nbody\n", true],
["False", "---\ndescription: x\nenabled: False\n---\nbody\n", true],
["FALSE", "---\ndescription: x\nenabled: FALSE\n---\nbody\n", true],
["trailing comment", "---\ndescription: x\nenabled: false # off for now\n---\nbody\n", true],
["quoted key", '---\ndescription: x\n"enabled": false\n---\nbody\n', true],
["trailing whitespace", "---\ndescription: x\nenabled: false \n---\nbody\n", true],
["'----' closes the block early", "---\ndescription: x\n----\nenabled: false\n---\nbody\n", false],
["'--- x' closes the block early", "---\ndescription: x\n--- x\nenabled: false\n---\nbody\n", false],
["quoted string, not a boolean", '---\ndescription: x\nenabled: "false"\n---\nbody\n', false],
["YAML 1.1 'no' is a string here", "---\ndescription: x\nenabled: no\n---\nbody\n", false],
["key only in the body", "---\ndescription: x\n---\nenabled: false\n", false],
])("agrees with the loader: %s", (_label, content, expected) => {
expect(loaderSeesDisabled(content)).toBe(expected); // premise: what the loader concludes
expect(isDisabledContent(content)).toBe(expected); // we must conclude the same
});
});
// The defect, stated as an invariant rather than as a list of shapes: the two
// sides must never disagree about whether a file is disabled. Whatever the
// loader reads as disabled, `/agents → Enable` has to be able to re-enable.
describe("read and write paths agree", () => {
const shapes: Array<[string, string]> = [
["key first", EXTENSION_WRITTEN_DISABLED],
["key after description", HAND_AUTHORED_DISABLED],
["key last", "---\ndescription: Scout\ndisplay_name: S\nenabled: false\n---\n\nBody.\n"],
["CRLF", "---\r\ndescription: Scout\r\nenabled: false\r\n---\r\n\r\nBody.\r\n"],
];
for (const [label, content] of shapes) {
it(`a file the loader reads as disabled can be enabled — ${label}`, () => {
expect(loaderSeesDisabled(content)).toBe(true); // premise: the loader agrees it's disabled
expect(enableInContent(content).changed).toBe(true);
});
}
it("disable → enable round-trips to the original hand-authored file", () => {
const disabled = disableInContent(ENABLED);
expect(disabled.outcome).toBe("disabled");
expect(enableInContent(disabled.content).content).toBe(ENABLED);
});
});
describe("isEmptyStub", () => {
it("recognizes the stub /agents writes to disable a built-in default", () => {
expect(isEmptyStub("---\n---\n")).toBe(true);
expect(isEmptyStub(enableInContent("---\nenabled: false\n---\n").content)).toBe(true);
});
it("is false for a file with real frontmatter", () => {
expect(isEmptyStub(ENABLED)).toBe(false);
});
});
describe("findAgentFile", () => {
let tmpDir: string;
let agentDir: string;
let prevAgentDir: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-toggle-"));
agentDir = mkdtempSync(join(tmpdir(), "pi-toggle-agentdir-"));
prevAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = agentDir;
});
afterEach(() => {
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
rmSync(tmpDir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
});
function write(dir: string, name: string) {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${name}.md`), ENABLED);
}
it("prefers .pi/agents over .agents/agents and the personal dir", () => {
write(join(tmpDir, ".pi", "agents"), "scout");
write(join(tmpDir, ".agents", "agents"), "scout");
write(join(agentDir, "agents"), "scout");
expect(findAgentFile("scout", tmpDir)).toEqual({
path: join(tmpDir, ".pi", "agents", "scout.md"),
location: "project",
});
});
it("falls back to the workspace .agents/agents dir", () => {
write(join(tmpDir, ".agents", "agents"), "scout");
write(join(agentDir, "agents"), "scout");
expect(findAgentFile("scout", tmpDir)).toEqual({
path: join(tmpDir, ".agents", "agents", "scout.md"),
location: "workspace",
});
});
it("falls back to the personal agent dir", () => {
write(join(agentDir, "agents"), "scout");
expect(findAgentFile("scout", tmpDir)).toEqual({
path: join(agentDir, "agents", "scout.md"),
location: "personal",
});
});
it("returns undefined when the agent has no file anywhere", () => {
expect(findAgentFile("nope", tmpDir)).toBeUndefined();
});
// An agent's type comes from its frontmatter `name:` now, so `<type>.md` is a
// guess. Getting it wrong is not a harmless miss: `/agents → Disable` takes
// the no-file branch and writes a NEW stub, which loses to the real file on
// load — so the agent stays enabled while the toast reports success.
describe("locateAgentFile", () => {
it("uses the file the loader read, whatever it is called", () => {
write(join(tmpDir, ".pi", "agents"), "reviewer");
const sourcePath = join(tmpDir, ".pi", "agents", "reviewer.md");
expect(locateAgentFile("code-reviewer", sourcePath, tmpDir)).toEqual({
path: sourcePath,
location: "project",
});
});
it("classifies a workspace and a personal source path", () => {
write(join(tmpDir, ".agents", "agents"), "reviewer");
write(join(agentDir, "agents"), "auditor");
expect(locateAgentFile("code-reviewer", join(tmpDir, ".agents", "agents", "reviewer.md"), tmpDir))
.toMatchObject({ location: "workspace" });
expect(locateAgentFile("code-auditor", join(agentDir, "agents", "auditor.md"), tmpDir))
.toMatchObject({ location: "personal" });
});
it("falls back to the <type>.md probe for a built-in with no source file", () => {
write(join(tmpDir, ".pi", "agents"), "scout");
expect(locateAgentFile("scout", undefined, tmpDir)).toEqual({
path: join(tmpDir, ".pi", "agents", "scout.md"),
location: "project",
});
});
it("falls back when the recorded path has since been deleted", () => {
write(join(tmpDir, ".pi", "agents"), "scout");
expect(locateAgentFile("scout", join(tmpDir, ".pi", "agents", "gone.md"), tmpDir)).toEqual({
path: join(tmpDir, ".pi", "agents", "scout.md"),
location: "project",
});
});
it("finds nothing when neither the source path nor the probe resolves", () => {
expect(locateAgentFile("nope", join(tmpDir, ".pi", "agents", "gone.md"), tmpDir)).toBeUndefined();
});
});
});
// `/agents → Create agent → Manual` writes an agent file from free-text prompts.
// The description is whatever the user typed into `ctx.ui.input("Description
// (one line)")` — no validation, no escaping — and it was interpolated straight
// into a YAML scalar.
//
// That matters more than a formatting nit because of how a broken agent file is
// handled since #212: it is SKIPPED with a warning (or aborts startup under
// strictAgentFiles). So the wizard reports "Created <path>", and the agent the
// user just built silently does not exist.
describe("buildNewAgentFile", () => {
const base = { tools: "read, grep", systemPrompt: "Do the thing.", description: "Scout" };
/** What the loader makes of the generated file. */
const parse = (content: string) => parseFrontmatter<Record<string, unknown>>(content).frontmatter;
it("round-trips an ordinary description", () => {
expect(parse(buildNewAgentFile(base)).description).toBe("Scout");
});
it("survives a description containing a colon", () => {
// "Scout: find things" is an entirely natural thing to type, and an
// unquoted YAML scalar treats the colon as a nested mapping — the parser
// throws and the whole file is unloadable.
const content = buildNewAgentFile({ ...base, description: "Scout: find things" });
expect(() => parse(content)).not.toThrow();
expect(parse(content).description).toBe("Scout: find things");
});
it("keeps a description containing a # instead of truncating it", () => {
// `#` opens a YAML comment, so "audit #security" silently becomes "audit".
const content = buildNewAgentFile({ ...base, description: "audit #security" });
expect(parse(content).description).toBe("audit #security");
});
it("leaves a `provider/model:thinking` suffix intact", () => {
// Not a hazard on its own — YAML splits on ": " (colon then space), so a
// bare `:high` suffix is a valid plain scalar either way. Pinned because
// it's the shape most likely to be typed into the custom-model prompt, and
// quoting must not mangle it.
const content = buildNewAgentFile({ ...base, model: "anthropic/claude-sonnet-4-6:high" });
expect(parse(content).model).toBe("anthropic/claude-sonnet-4-6:high");
});
it("survives a custom model containing a colon-space or a #", () => {
// The custom-model prompt is free text with no validation, so it has the
// same two hazards the description does.
for (const model of ["anthropic/foo: bar", "anthropic/x #c"]) {
const content = buildNewAgentFile({ ...base, model });
expect(() => parse(content), model).not.toThrow();
expect(parse(content).model, model).toBe(model);
}
});
it("emits the fields the wizard collects, and omits the ones left on inherit", () => {
const full = parse(buildNewAgentFile({ ...base, model: "anthropic/x", thinking: "high" }));
expect(full).toMatchObject({ tools: "read, grep", model: "anthropic/x", thinking: "high", prompt_mode: "replace" });
const minimal = parse(buildNewAgentFile(base));
expect(minimal.model).toBeUndefined();
expect(minimal.thinking).toBeUndefined();
});
it("keeps the system prompt as the body", () => {
const content = buildNewAgentFile({ ...base, systemPrompt: "Line 1.\n\nLine 2." });
expect(parseFrontmatter<Record<string, unknown>>(content).body).toBe("Line 1.\n\nLine 2.");
});
});