// 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>(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>(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 `.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 .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 ", 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>(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>(content).body).toBe("Line 1.\n\nLine 2."); }); });