mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
1167 lines
37 KiB
TypeScript
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");
|
|
});
|
|
});
|
|
});
|