Files
my-pi/pi-subagents/test/custom-agents.test.ts
T

1167 lines
37 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 { serializeAgentFile } from "../src/agent-file-toggle.js";
import { BUILTIN_TOOL_NAMES } from "../src/agent-types.js";
import { loadCustomAgents } from "../src/custom-agents.js";
import type { AgentConfig } from "../src/types.js";
describe("loadCustomAgents", () => {
let tmpDir: string;
let originalHome: string | undefined;
let originalAgentDir: string | undefined;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "pi-test-"));
originalHome = process.env.HOME;
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
process.env.HOME = tmpDir;
delete process.env.PI_CODING_AGENT_DIR;
});
afterEach(() => {
if (originalHome == null) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalAgentDir;
rmSync(tmpDir, { recursive: true, force: true });
});
function writeAgentIn(projectDir: ".agents" | ".pi", name: string, content: string) {
const dir = join(tmpDir, projectDir, "agents");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${name}.md`), content);
}
function writeAgent(name: string, content: string) {
writeAgentIn(".pi", name, content);
}
function writeWorkspaceAgent(name: string, content: string) {
writeAgentIn(".agents", name, content);
}
it("returns empty map when custom agent dirs do not exist", () => {
const result = loadCustomAgents(tmpDir);
expect(result.size).toBe(0);
});
it("loads a workspace project agent from .agents/agents", () => {
writeWorkspaceAgent("reviewer", `---
description: Workspace Reviewer
---
Workspace prompt.`);
const result = loadCustomAgents(tmpDir);
expect(result.size).toBe(1);
expect(result.get("reviewer")?.description).toBe("Workspace Reviewer");
expect(result.get("reviewer")?.systemPrompt).toBe("Workspace prompt.");
expect(result.get("reviewer")?.source).toBe("project");
});
it(".pi/agents overrides .agents/agents on a name clash", () => {
writeWorkspaceAgent("dupe", `---
description: Workspace Project
---
Workspace prompt.`);
writeAgent("dupe", `---
description: Pi Project
---
Pi prompt.`);
const result = loadCustomAgents(tmpDir);
expect(result.size).toBe(1);
expect(result.get("dupe")?.description).toBe("Pi Project");
expect(result.get("dupe")?.systemPrompt).toBe("Pi prompt.");
});
it("workspace project agents override global agents", () => {
const globalAgentDir = join(tmpDir, "global-agent-dir");
process.env.PI_CODING_AGENT_DIR = globalAgentDir;
const globalAgents = join(globalAgentDir, "agents");
mkdirSync(globalAgents, { recursive: true });
writeFileSync(join(globalAgents, "dupe.md"), `---
description: Global
---
Global prompt.`);
writeWorkspaceAgent("dupe", `---
description: Workspace Project
---
Workspace prompt.`);
const result = loadCustomAgents(tmpDir);
expect(result.size).toBe(1);
expect(result.get("dupe")?.description).toBe("Workspace Project");
expect(result.get("dupe")?.systemPrompt).toBe("Workspace prompt.");
});
it("loads a basic agent with all frontmatter fields", () => {
writeAgent("auditor", `---
description: Security Auditor
tools: read, grep, find
model: anthropic/claude-opus-4-6
thinking: high
max_turns: 30
persist_session: true
output_transcript: false
session_dir: .seams/pi-sessions/seam-plan-reviewer
allowed_subagents: scout, reviewer
prompt_mode: replace
inherit_context: true
run_in_background: true
isolated: true
---
You are a security auditor.`);
const result = loadCustomAgents(tmpDir);
expect(result.size).toBe(1);
const agent = result.get("auditor")!;
expect(agent.name).toBe("auditor");
expect(agent.description).toBe("Security Auditor");
expect(agent.builtinToolNames).toEqual(["read", "grep", "find"]);
expect(agent.model).toBe("anthropic/claude-opus-4-6");
expect(agent.thinking).toBe("high");
expect(agent.maxTurns).toBe(30);
expect(agent.persistSession).toBe(true);
expect(agent.outputTranscript).toBe(false);
expect(agent.sessionDir).toBe(".seams/pi-sessions/seam-plan-reviewer");
expect(agent.allowedSubagents).toEqual(["scout", "reviewer"]);
expect(agent.promptMode).toBe("replace");
expect(agent.inheritContext).toBe(true);
expect(agent.runInBackground).toBe(true);
expect(agent.isolated).toBe(true);
expect(agent.systemPrompt).toBe("You are a security auditor.");
});
it("uses sensible defaults when frontmatter is empty", () => {
writeAgent("minimal", `---
---
Just a prompt.`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("minimal")!;
expect(agent.name).toBe("minimal");
expect(agent.displayName).toBeUndefined();
expect(agent.color).toBeUndefined();
expect(agent.description).toBe("minimal"); // defaults to filename
expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); // all tools
expect(agent.extensions).toBe(true); // inherit all
expect(agent.skills).toBe(true); // inherit all
expect(agent.model).toBeUndefined();
expect(agent.thinking).toBeUndefined();
expect(agent.maxTurns).toBeUndefined();
expect(agent.persistSession).toBeUndefined();
expect(agent.outputTranscript).toBeUndefined();
expect(agent.sessionDir).toBeUndefined();
expect(agent.allowedSubagents).toBeUndefined();
expect(agent.promptMode).toBe("replace");
expect(agent.inheritContext).toBeUndefined();
expect(agent.runInBackground).toBeUndefined();
expect(agent.isolated).toBeUndefined();
expect(agent.systemPrompt).toBe("Just a prompt.");
});
it("uses sensible defaults when no frontmatter at all", () => {
writeAgent("bare", "Just a system prompt, no frontmatter.");
const result = loadCustomAgents(tmpDir);
const agent = result.get("bare")!;
expect(agent.name).toBe("bare");
expect(agent.description).toBe("bare");
expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
expect(agent.systemPrompt).toBe("Just a system prompt, no frontmatter.");
});
it("parses allowed_subagents: off by default, `all` wildcard, csv restriction", () => {
writeAgent("omitted", `---
---
Off.`);
writeAgent("unrestricted", `---
allowed_subagents: all
---
Unrestricted.`);
writeAgent("wildcard", `---
allowed_subagents: "*"
---
Unrestricted.`);
writeAgent("mixed-case", `---
allowed_subagents: scout, ALL
---
Unrestricted.`);
writeAgent("none", `---
allowed_subagents: none
---
Off.`);
writeAgent("blank", `---
allowed_subagents:
---
Off.`);
writeAgent("restricted", `---
allowed_subagents: scout, reviewer
---
Restricted.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("omitted")!.allowedSubagents).toBeUndefined();
expect(result.get("unrestricted")!.allowedSubagents).toBe("all");
expect(result.get("wildcard")!.allowedSubagents).toBe("all");
expect(result.get("mixed-case")!.allowedSubagents).toBe("all");
expect(result.get("none")!.allowedSubagents).toBeUndefined();
expect(result.get("blank")!.allowedSubagents).toBeUndefined();
expect(result.get("restricted")!.allowedSubagents).toEqual(["scout", "reviewer"]);
});
it("accepts booleans like extensions:/skills: do, instead of a type named \"true\"", () => {
writeAgent("bool-on", `---
allowed_subagents: true
---
On.`);
writeAgent("bool-off", `---
allowed_subagents: false
---
Off.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("bool-on")!.allowedSubagents).toBe("all");
expect(result.get("bool-off")!.allowedSubagents).toBeUndefined();
});
it("handles tools: none → empty array", () => {
writeAgent("notool", `---
tools: none
---
No tools.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("notool")!.builtinToolNames).toEqual([]);
});
it("handles extensions: false → no extensions", () => {
writeAgent("noext", `---
extensions: false
skills: false
---
No extensions.`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("noext")!;
expect(agent.extensions).toBe(false);
expect(agent.skills).toBe(false);
});
it("handles extension allowlist", () => {
writeAgent("partial", `---
extensions: web-search, mcp-server
skills: planning, review
---
Partial access.`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("partial")!;
expect(agent.extensions).toEqual(["web-search", "mcp-server"]);
expect(agent.skills).toEqual(["planning", "review"]);
});
it("parses exclude_extensions CSV", () => {
writeAgent("no-notify", `---
extensions: true
exclude_extensions: pi-notify, telemetry
---
No notifications.`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("no-notify")!;
expect(agent.extensions).toBe(true);
expect(agent.excludeExtensions).toEqual(["pi-notify", "telemetry"]);
});
it("parses exclude_extensions YAML list", () => {
writeAgent("no-notify-yaml", `---
exclude_extensions:
- pi-notify
---
No notifications.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("no-notify-yaml")!.excludeExtensions).toEqual(["pi-notify"]);
});
it("exclude_extensions omitted or none → undefined", () => {
writeAgent("plain", `---
description: plain
---
Plain.`);
writeAgent("explicit-none", `---
exclude_extensions: none
---
None.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("plain")!.excludeExtensions).toBeUndefined();
expect(result.get("explicit-none")!.excludeExtensions).toBeUndefined();
});
it("passes through unknown tool names (not filtered)", () => {
writeAgent("custom-tools", `---
tools: read, my_custom_tool, grep
---
Custom tools.`);
const result = loadCustomAgents(tmpDir);
// Unknown tool names are passed through — filtering happens at tool creation time
expect(result.get("custom-tools")!.builtinToolNames).toEqual(["read", "my_custom_tool", "grep"]);
});
it("partitions tools: ext: entries out of builtinToolNames into extSelectors", () => {
writeAgent("ext-agent", `---
tools: read, ext:foo, ext:bar/x
---
Extension selectors.`);
const agent = loadCustomAgents(tmpDir).get("ext-agent")!;
expect(agent.builtinToolNames).toEqual(["read"]);
expect(agent.extSelectors).toEqual(["ext:foo", "ext:bar/x"]);
});
it("tools: with only ext: entries yields zero built-ins", () => {
writeAgent("ext-only", `---
tools: ext:foo/bar
---
Ext only.`);
const agent = loadCustomAgents(tmpDir).get("ext-only")!;
expect(agent.builtinToolNames).toEqual([]);
expect(agent.extSelectors).toEqual(["ext:foo/bar"]);
});
it("tools: '*' expands to all built-ins and composes with ext: selectors", () => {
writeAgent("wild", `---
tools: "*, ext:foo"
---
Wildcard plus ext.`);
const agent = loadCustomAgents(tmpDir).get("wild")!;
expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
expect(agent.extSelectors).toEqual(["ext:foo"]);
});
it("tools: 'all' is a case-insensitive alias for '*' (closes #75)", () => {
// `tools: all` previously parsed "all" as a single tool name → allowlist
// containing the non-existent tool "all" → silent zero-tool agent.
for (const [name, value] of [["all-lower", "all"], ["all-upper", "ALL"], ["all-mixed", "All"]]) {
writeAgent(name, `---\ntools: ${value}\n---\n\nAlias.`);
const agent = loadCustomAgents(tmpDir).get(name)!;
expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
expect(agent.extSelectors).toBeUndefined();
}
});
it("tools: 'all' composes with ext: selectors like '*'", () => {
writeAgent("all-plus-ext", `---
tools: "all, ext:foo"
---
All plus ext.`);
const agent = loadCustomAgents(tmpDir).get("all-plus-ext")!;
expect(agent.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES);
expect(agent.extSelectors).toEqual(["ext:foo"]);
});
it("leaves extSelectors undefined when tools: has no ext: entries", () => {
writeAgent("plain", `---
tools: read, bash
---
Plain tools.`);
const agent = loadCustomAgents(tmpDir).get("plain")!;
expect(agent.builtinToolNames).toEqual(["read", "bash"]);
expect(agent.extSelectors).toBeUndefined();
});
it("passes through thinking level as-is (no validation)", () => {
writeAgent("anythink", `---
thinking: turbo
---
Any thinking.`);
const result = loadCustomAgents(tmpDir);
// Pi validates at session creation — we just pass through
expect(result.get("anythink")!.thinking).toBe("turbo");
});
it("loads thinking: max (pi 0.80's top level) unchanged (#147)", () => {
writeAgent("deepthink", `---
thinking: max
---
Think hard.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("deepthink")!.thinking).toBe("max");
});
it("accepts max_turns: 0 as unlimited", () => {
writeAgent("unlimited", `---
max_turns: 0
---
Unlimited turns.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("unlimited")!.maxTurns).toBe(0);
});
it("rejects negative max_turns", () => {
writeAgent("negturns", `---
max_turns: -5
---
Negative turns.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("negturns")!.maxTurns).toBeUndefined();
});
it("handles prompt_mode: append", () => {
writeAgent("appender", `---
prompt_mode: append
---
Extra instructions.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("appender")!.promptMode).toBe("append");
});
it("defaults unknown prompt_mode to replace", () => {
writeAgent("badmode", `---
prompt_mode: merge
---
Unknown mode.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("badmode")!.promptMode).toBe("replace");
});
it("loads multiple agents", () => {
writeAgent("agent1", `---
description: First
---
First agent.`);
writeAgent("agent2", `---
description: Second
---
Second agent.`);
const result = loadCustomAgents(tmpDir);
expect(result.size).toBe(2);
expect(result.has("agent1")).toBe(true);
expect(result.has("agent2")).toBe(true);
});
it("skips non-.md files", () => {
const dir = join(tmpDir, ".pi", "agents");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "notes.txt"), "not an agent");
writeFileSync(join(dir, "real.md"), `---
description: Real Agent
---
Real.`);
const result = loadCustomAgents(tmpDir);
expect(result.size).toBe(1);
expect(result.has("real")).toBe(true);
});
it("allows agents with names matching defaults (overrides them)", () => {
writeAgent("Explore", `---
description: Custom Explore
---
Custom explore agent.`);
writeAgent("custom", `---
description: Custom Agent
---
Should be loaded.`);
const result = loadCustomAgents(tmpDir);
expect(result.has("Explore")).toBe(true);
expect(result.get("Explore")!.description).toBe("Custom Explore");
expect(result.has("custom")).toBe(true);
});
it("handles empty body with frontmatter", () => {
writeAgent("nobody", `---
description: No body
tools: read
---
`);
const result = loadCustomAgents(tmpDir);
expect(result.get("nobody")!.systemPrompt).toBe("");
});
it("supports inherit_extensions as alternative to extensions", () => {
writeAgent("altkey", `---
inherit_extensions: false
inherit_skills: false
---
Alt keys.`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("altkey")!;
expect(agent.extensions).toBe(false);
expect(agent.skills).toBe(false);
});
it("extensions: none → false", () => {
writeAgent("extnone", `---
extensions: none
skills: none
---
None.`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("extnone")!;
expect(agent.extensions).toBe(false);
expect(agent.skills).toBe(false);
});
it("extensions: true → true (inherit all)", () => {
writeAgent("exttrue", `---
extensions: true
skills: true
---
All.`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("exttrue")!;
expect(agent.extensions).toBe(true);
expect(agent.skills).toBe(true);
});
it("handles enabled: false frontmatter", () => {
writeAgent("disabled", `---
enabled: false
---
`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("disabled")!;
expect(agent.enabled).toBe(false);
});
it("takes the agent type from frontmatter name, not the filename", () => {
// Claude Code's rule: "the filename doesn't have to match". The same file
// dropped into either tool must dispatch under the same type.
writeAgent("blubb", `---
name: code-review
description: Reviews code
---
Agent prompt.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("code-review")!.name).toBe("code-review");
expect(result.get("blubb")).toBeUndefined();
});
it("records the file it was read from, not the one its type would name", () => {
// `/agents` edits `sourcePath`: probing for `<type>.md` finds nothing here,
// and its no-file branch writes a stub that loses to this file on load.
writeAgent("blubb", `---
name: code-review
description: Reviews code
---
Agent prompt.`);
expect(loadCustomAgents(tmpDir).get("code-review")!.sourcePath)
.toBe(join(tmpDir, ".pi", "agents", "blubb.md"));
});
it("falls back to the filename for an empty or blank declared name", () => {
// A quoted empty `name:` would otherwise register the agent under the empty
// type — unspawnable, and it takes the filename-derived one down with it.
writeAgent("myagent", "---\nname: \"\"\ndescription: My Agent\n---\n\nPrompt.");
writeAgent("other", "---\nname: \" \"\ndescription: Other\n---\n\nPrompt.");
const result = loadCustomAgents(tmpDir);
expect(result.get("myagent")!.name).toBe("myagent");
expect(result.get("other")!.name).toBe("other");
expect(result.has("")).toBe(false);
});
it("trims a declared name so it matches what the user meant to type", () => {
writeAgent("blubb", "---\nname: \" code-review \"\ndescription: Reviews code\n---\n\nPrompt.");
expect(loadCustomAgents(tmpDir).get("code-review")!.name).toBe("code-review");
});
it("falls back to the filename when no name is declared", () => {
// Claude Code requires `name`; most existing files here predate it and
// must keep loading under the identity they already dispatch by.
writeAgent("myagent", `---
description: My Agent
---
Agent prompt.`);
expect(loadCustomAgents(tmpDir).get("myagent")!.name).toBe("myagent");
});
it("keeps display_name as a label only, independent of the type", () => {
writeAgent("blubb", `---
name: code-review
description: My Agent
display_name: MyAgent
---
Agent prompt.`);
const agent = loadCustomAgents(tmpDir).get("code-review")!;
expect(agent.name).toBe("code-review");
expect(agent.displayName).toBe("MyAgent");
});
it("leaves displayName unset so the badge falls back to the type", () => {
// A Claude Code file has no display_name; `getConfig` resolves the label
// to the type, so it still badges as "code-reviewer" as it did before.
writeAgent("whatever", `---
name: code-reviewer
description: Reviews code
color: "#8B5CF6"
---
Agent prompt.`);
const agent = loadCustomAgents(tmpDir).get("code-reviewer")!;
expect(agent.name).toBe("code-reviewer");
expect(agent.displayName).toBeUndefined();
expect(agent.color).toBe("#8B5CF6");
});
it("accepts a name Claude Code accepts, however unlike a type it looks", () => {
// Its docs describe names as "lowercase letters and hyphens", but the only
// load failure they state is the colon — so this must still load.
writeAgent("reviewer", `---
name: Code Reviewer
description: Reviews code
---
Agent prompt.`);
expect(loadCustomAgents(tmpDir).get("Code Reviewer")!.name).toBe("Code Reviewer");
});
it("refuses a name containing the plugin-scope separator", () => {
// Claude Code doesn't load these. Skipping beats loading it under the
// filename, which would dispatch an agent whose declared identity nothing
// honoured.
writeAgent("scoped", `---
name: my-plugin:reviewer
description: Reviews code
---
Agent prompt.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("my-plugin:reviewer")).toBeUndefined();
expect(result.get("scoped")).toBeUndefined();
});
it("does not claim the rejected file was overriding its filename's agent", () => {
// It would have registered under its *declared* name, which a colon keeps
// out of the registry entirely — so it shadowed nothing. Reporting a
// substitution of the same-named file from another directory describes a
// swap that never happened, and points at an agent that is unchanged.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
writeWorkspaceAgent("scoped", "---\ndescription: An unrelated agent\n---\n\nBody.");
writeAgent("scoped", "---\nname: my-plugin:reviewer\ndescription: Reviews code\n---\n\nBody.");
const result = loadCustomAgents(tmpDir);
expect(result.get("scoped")?.description).toBe("An unrelated agent");
const message = warn.mock.calls.map(args => String(args[0])).join("\n");
expect(message).toContain("reserved for plugin-scoped identifiers");
expect(message).not.toContain("now loads from");
} finally {
warn.mockRestore();
}
});
it("lets a later file win a declared-name clash, as a filename clash always did", () => {
// Filenames were unique per directory by construction; declared names are
// not, so two files in one directory can now claim the same type.
writeAgent("a-first", `---
name: shared
description: first
---
First.`);
writeAgent("b-second", `---
name: shared
description: second
---
Second.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("shared")!.description).toBe("second");
expect([...result.keys()].filter(k => k === "shared")).toHaveLength(1);
});
it("parses disallowed_tools as csv list", () => {
writeAgent("restricted", `---
description: Restricted Agent
disallowed_tools: bash, write
---
No bash or write.`);
const result = loadCustomAgents(tmpDir);
const agent = result.get("restricted")!;
expect(agent.disallowedTools).toEqual(["bash", "write"]);
});
it("disallowed_tools defaults to undefined when omitted", () => {
writeAgent("unrestricted", `---
description: Unrestricted
---
All tools.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("unrestricted")!.disallowedTools).toBeUndefined();
});
it("parses memory scope", () => {
writeAgent("rememberer", `---
description: Agent with memory
memory: project
---
Remember things.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("rememberer")!.memory).toBe("project");
});
it("parses memory: user scope", () => {
writeAgent("global-mem", `---
memory: user
---
User memory.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("global-mem")!.memory).toBe("user");
});
it("memory defaults to undefined when omitted", () => {
writeAgent("no-mem", `---
description: No memory
---
Stateless.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("no-mem")!.memory).toBeUndefined();
});
it("rejects invalid memory scope", () => {
writeAgent("bad-mem", `---
memory: invalid
---
Bad memory.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("bad-mem")!.memory).toBeUndefined();
});
it("parses isolation: worktree", () => {
writeAgent("isolated-wt", `---
description: Worktree agent
isolation: worktree
---
Isolated.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("isolated-wt")!.isolation).toBe("worktree");
});
it("isolation defaults to undefined when omitted", () => {
writeAgent("no-isolation", `---
description: Normal
---
Normal.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("no-isolation")!.isolation).toBeUndefined();
});
it("rejects invalid isolation mode", () => {
writeAgent("bad-isolation", `---
isolation: docker
---
Bad isolation.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("bad-isolation")!.isolation).toBeUndefined();
});
// `isolation: off` is a veto, not a synonym for omitting the field: agent
// config outranks tool-call params, so it turns a caller's "worktree" back
// off. That is why it must survive parsing as "off" rather than undefined.
it("parses isolation: off", () => {
writeAgent("no-wt", `---
description: Never worktree
isolation: off
---
No worktree.`);
const result = loadCustomAgents(tmpDir);
expect(result.get("no-wt")!.isolation).toBe("off");
});
// pi's frontmatter parser is not YAML 1.1, so bare `off`/`no` stay strings
// and only `false` becomes a boolean — accept the spellings an author is
// likely to reach for rather than silently dropping them.
it.each([
["false", "isolation: false"],
["none", "isolation: none"],
["no", "isolation: no"],
])("accepts %s as a spelling of off", (name, line) => {
writeAgent(`off-${name}`, `---
${line}
---
Off.`);
const result = loadCustomAgents(tmpDir);
expect(result.get(`off-${name}`)!.isolation).toBe("off");
});
// A YAML error in one file used to escape loadFromDir and abort the whole
// extension load — pi exited 1 before the TUI. Regression for #212.
it("skips a file with malformed frontmatter and still loads the others", () => {
// Unquoted `description` containing ": " — the shape Claude Code tolerates.
writeAgent("broken", `---
name: broken
description: Use this: that
---
Broken body.`);
writeAgent("good", `---
description: Still loads
---
Good body.`);
const result = loadCustomAgents(tmpDir);
expect(result.has("broken")).toBe(false);
expect(result.get("good")?.description).toBe("Still loads");
});
it("names the offending file and the reason when skipping it", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
writeAgent("broken", "---\nname: broken\ndescription: Use this: that\n---\n\nBroken body.");
loadCustomAgents(tmpDir);
const message = warn.mock.calls.map(args => String(args[0])).join("\n");
expect(message).toContain(join(tmpDir, ".pi", "agents", "broken.md"));
expect(message).toContain("Nested mappings are not allowed");
} finally {
warn.mockRestore();
}
});
// Skipping an override is not the same as skipping an agent: the name still
// resolves, to a different prompt, model and tool policy. Nothing downstream
// can flag that, because the Agent call succeeds.
it("warns when a skipped file was overriding an agent that stays resolvable", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
writeWorkspaceAgent("dup", "---\ndescription: Earlier definition\n---\n\nEarlier body.");
writeAgent("dup", "---\nname: dup\ndescription: Use this: that\n---\n\nBroken body.");
const result = loadCustomAgents(tmpDir);
expect(result.get("dup")?.description).toBe("Earlier definition");
const message = warn.mock.calls.map(args => String(args[0])).join("\n");
expect(message).toContain(`Agent "dup" now loads from ${join(tmpDir, ".agents", "agents", "dup.md")} instead`);
} finally {
warn.mockRestore();
}
});
// A disabled agent does not dispatch (resolveEnabledTypeIn), so claiming the
// name "still resolves" to it would send the user chasing the wrong file.
it("does not claim a fallback when the shadowed definition is disabled", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
writeWorkspaceAgent("dup", "---\ndescription: Earlier definition\nenabled: false\n---\n\nEarlier body.");
writeAgent("dup", "---\nname: dup\ndescription: Use this: that\n---\n\nBroken body.");
loadCustomAgents(tmpDir);
const message = warn.mock.calls.map(args => String(args[0])).join("\n");
expect(message).toContain("Skipping agent file");
expect(message).not.toContain("now loads from");
} finally {
warn.mockRestore();
}
});
it("does not claim a fallback when the skipped file overrode nothing", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
writeAgent("lonely", "---\nname: lonely\ndescription: Use this: that\n---\n\nBroken body.");
loadCustomAgents(tmpDir);
const message = warn.mock.calls.map(args => String(args[0])).join("\n");
expect(message).toContain("Skipping agent file");
expect(message).not.toContain("now loads from");
} finally {
warn.mockRestore();
}
});
// strictAgentFiles: opt in to failing closed rather than running a substitute.
it("throws naming the file when strict, and skips it when not", () => {
writeAgent("broken", "---\nname: broken\ndescription: Use this: that\n---\n\nBroken.");
writeAgent("healthy", "---\ndescription: Fine\n---\n\nFine.");
const brokenPath = join(tmpDir, ".pi", "agents", "broken.md");
expect(() => loadCustomAgents(tmpDir, true)).toThrow(brokenPath);
expect(() => loadCustomAgents(tmpDir, true)).toThrow("Nested mappings are not allowed");
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const result = loadCustomAgents(tmpDir);
expect(result.has("broken")).toBe(false);
expect(result.has("healthy")).toBe(true);
} finally {
warn.mockRestore();
}
});
// The rule is "warn when it breaks, stay quiet while it stays broken".
// Suppressing an unchanged problem must not suppress it forever.
it("warns when a file breaks, not while it stays broken", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
// Two loads while broken: the second must be suppressed as unchanged.
writeAgent("flip", "---\nname: flip\ndescription: Use this: that\n---\n\nBroken.");
loadCustomAgents(tmpDir);
loadCustomAgents(tmpDir);
expect(warn).toHaveBeenCalledTimes(1);
writeAgent("flip", "---\ndescription: Fixed\n---\n\nFixed.");
expect(loadCustomAgents(tmpDir).get("flip")?.description).toBe("Fixed");
// Same breakage again — a new problem, not the one already reported.
writeAgent("flip", "---\nname: flip\ndescription: Use this: that\n---\n\nBroken.");
loadCustomAgents(tmpDir);
expect(warn).toHaveBeenCalledTimes(2);
} finally {
warn.mockRestore();
}
});
// Agents reload on every Agent call, so repeating would scribble a live TUI.
it("warns once per message, not on every reload", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
writeAgent("noisy", "---\nname: noisy\ndescription: Use this: that\n---\n\nBroken body.");
loadCustomAgents(tmpDir);
loadCustomAgents(tmpDir);
loadCustomAgents(tmpDir);
expect(warn).toHaveBeenCalledTimes(1);
} finally {
warn.mockRestore();
}
});
it("honors PI_CODING_AGENT_DIR for global custom agent discovery", () => {
const altAgentDir = mkdtempSync(join(tmpdir(), "pi-alt-agent-"));
const originalEnv = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = altAgentDir;
try {
const globalAgentsDir = join(altAgentDir, "agents");
mkdirSync(globalAgentsDir, { recursive: true });
writeFileSync(
join(globalAgentsDir, "via-env.md"),
"---\ndescription: Discovered via env var\n---\n\nTest body.",
);
const result = loadCustomAgents(tmpDir);
// Agent is found at $PI_CODING_AGENT_DIR/agents, not at $HOME/.pi/agent/agents
expect(result.has("via-env")).toBe(true);
expect(result.get("via-env")!.description).toBe("Discovered via env var");
} finally {
if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = originalEnv;
rmSync(altAgentDir, { recursive: true, force: true });
}
});
// `/agents → Eject` writes an AgentConfig back out as frontmatter. That writer
// and this loader are the two halves of one format, but nothing pinned them
// together — so a field can serialize to something the loader reads back
// differently, and the agent silently changes shape on eject.
describe("eject round-trip", () => {
function roundTrip(cfg: Partial<AgentConfig>) {
const full: AgentConfig = {
description: "Round trip agent",
systemPrompt: "Body prompt.",
promptMode: "append",
...cfg,
} as AgentConfig;
writeAgent("rt", serializeAgentFile(full));
const loaded = loadCustomAgents(tmpDir).get("rt");
expect(loaded).toBeDefined();
return loaded!;
}
it("preserves an explicitly narrowed tool list", () => {
expect(roundTrip({ builtinToolNames: ["read", "grep"] }).builtinToolNames).toEqual(["read", "grep"]);
});
it("preserves the full built-in set", () => {
expect(roundTrip({ builtinToolNames: [...BUILTIN_TOOL_NAMES] }).builtinToolNames)
.toEqual([...BUILTIN_TOOL_NAMES]);
});
it("preserves an empty tool list instead of widening it to every built-in", () => {
// `tools: none` parses to [] on load, so ejecting an agent with zero
// built-ins must not write `tools: all` — that hands it the whole toolbox.
expect(roundTrip({ builtinToolNames: [] }).builtinToolNames).toEqual([]);
});
it("preserves the scalar and list fields it writes", () => {
const loaded = roundTrip({
displayName: "RT",
model: "anthropic/claude-haiku-4-5",
thinking: "low",
maxTurns: 7,
allowedSubagents: ["Explore"],
excludeExtensions: ["ext-beta"],
disallowedTools: ["write"],
inheritContext: true,
runInBackground: true,
outputTranscript: false,
isolated: true,
memory: "project",
isolation: "worktree",
});
expect(loaded.displayName).toBe("RT");
expect(loaded.model).toBe("anthropic/claude-haiku-4-5");
expect(loaded.thinking).toBe("low");
expect(loaded.maxTurns).toBe(7);
expect(loaded.allowedSubagents).toEqual(["Explore"]);
expect(loaded.excludeExtensions).toEqual(["ext-beta"]);
expect(loaded.disallowedTools).toEqual(["write"]);
expect(loaded.inheritContext).toBe(true);
expect(loaded.runInBackground).toBe(true);
expect(loaded.outputTranscript).toBe(false);
expect(loaded.isolated).toBe(true);
expect(loaded.memory).toBe("project");
expect(loaded.isolation).toBe("worktree");
});
// The writer used to emit `run_in_background` only when truthy, so an
// explicit `false` was dropped. Harmless while foreground was the default
// and omission meant the same thing — but with `backgroundByDefault` on,
// dropping it flips the ejected agent to background.
it("preserves an explicit run_in_background: false instead of dropping it", () => {
expect(roundTrip({ runInBackground: false }).runInBackground).toBe(false);
});
it("leaves run_in_background unset when the config doesn't pin it", () => {
// Absent must stay absent — writing a value would freeze the agent
// against the setting rather than letting it follow the default.
expect(roundTrip({}).runInBackground).toBeUndefined();
});
it("preserves the extension and skill list fields", () => {
// These serialize as bare CSV and are re-parsed by parseExtensionsSpec /
// the skills field. A generate/parse mismatch here is silent: the ejected
// agent loads fine but with a different extension or skill scope than the
// one that was ejected.
const loaded = roundTrip({
extensions: ["mcp", "pi-notify"],
skills: ["planning", "review"],
disallowedTools: ["write", "edit"],
});
expect(loaded.extensions).toEqual(["mcp", "pi-notify"]);
expect(loaded.skills).toEqual(["planning", "review"]);
expect(loaded.disallowedTools).toEqual(["write", "edit"]);
});
it("preserves the boolean forms of extensions and skills", () => {
const off = roundTrip({ extensions: false, skills: false });
expect(off.extensions).toBe(false);
expect(off.skills).toBe(false);
});
it("preserves allowed_subagents in both its list and `all` forms", () => {
expect(roundTrip({ allowedSubagents: "all" }).allowedSubagents).toBe("all");
expect(roundTrip({ allowedSubagents: ["Explore", "Plan"] }).allowedSubagents)
.toEqual(["Explore", "Plan"]);
});
it("preserves a description containing a colon", () => {
// Serialized via JSON.stringify precisely so YAML doesn't split on the colon.
expect(roundTrip({ description: "Scout: find things" }).description).toBe("Scout: find things");
});
});
});