import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { discoverAgents, loadSettings, applyOverrides, applyInvocationOverride, dedupeByResolvedName, type AgentConfig, type AgentOverrides, type CacheEntry, type SubagentSettings, } from "../../src/agents.ts"; function makeTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "pi-simple-agents-test-")); } function writeAgentFile(dir: string, filename: string, content: string): string { const filePath = path.join(dir, filename); fs.writeFileSync(filePath, content, "utf8"); return filePath; } test("discoverAgents: directory with one valid agent .md returns one AgentConfig with resolved defaults", async () => { const dir = makeTmpDir(); try { writeAgentFile( dir, "scout.md", `--- name: scout description: Finds things tools: read, grep model: sonnet --- Body content. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); const agent = agents[0]!; assert.equal(agent.name, "scout"); assert.equal(agent.description, "Finds things"); assert.deepEqual(agent.tools, ["read", "grep"]); assert.equal(agent.model, "sonnet"); assert.equal(agent.systemPromptMode, "append"); assert.equal(agent.inheritProjectContext, true); assert.deepEqual(agent.defaultReads, []); assert.equal(agent.source, "user"); assert.equal(agent.filePath, path.join(dir, "scout.md")); assert.equal(agent.systemPrompt, "Body content."); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: directory-style agent with AGENT.md manifest (full frontmatter) is discovered", async () => { const dir = makeTmpDir(); try { const manifestDir = path.join(dir, "critical-thinker"); fs.mkdirSync(manifestDir); writeAgentFile( manifestDir, "AGENT.md", `--- name: critical-thinker description: Challenges assumptions --- Manifest body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); const agent = agents[0]!; assert.equal(agent.name, "critical-thinker"); assert.equal(agent.description, "Challenges assumptions"); assert.equal(agent.filePath, path.join(manifestDir, "AGENT.md")); assert.equal(agent.systemPrompt, "Manifest body."); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: directory-style agent with AGENT.md manifest missing name falls back to directory basename", async () => { const dir = makeTmpDir(); try { const manifestDir = path.join(dir, "worker"); fs.mkdirSync(manifestDir); writeAgentFile( manifestDir, "AGENT.md", `--- description: Does the work --- Manifest body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); assert.equal(agents[0]!.name, "worker"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: directory-style agent with AGENT.md manifest missing both name and description is skipped", async () => { const dir = makeTmpDir(); try { const manifestDir = path.join(dir, "worker"); fs.mkdirSync(manifestDir); writeAgentFile( manifestDir, "AGENT.md", `--- systemPromptMode: append --- Manifest body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 0); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: directory-style agent with AGENT.md manifest name differing from directory basename keeps frontmatter name", async () => { const dir = makeTmpDir(); try { const manifestDir = path.join(dir, "worker"); fs.mkdirSync(manifestDir); writeAgentFile( manifestDir, "AGENT.md", `--- name: custom-name description: Does the work --- Manifest body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); assert.equal(agents[0]!.name, "custom-name"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: file missing description is skipped without throwing; other valid files still returned", async () => { const dir = makeTmpDir(); try { writeAgentFile( dir, "broken.md", `--- name: broken --- No description here. `, ); writeAgentFile( dir, "good.md", `--- name: good description: Works fine --- Body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); assert.equal(agents[0]!.name, "good"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: symlinked .md with only 4 base Claude Code fields gets pi-simple-agents defaults filled in", async () => { const dir = makeTmpDir(); const realFileDir = makeTmpDir(); try { const realFilePath = writeAgentFile( realFileDir, "claude-agent.md", `--- name: claude-agent description: A Claude Code style agent tools: read model: haiku --- Claude Code body. `, ); const symlinkPath = path.join(dir, "claude-agent.md"); fs.symlinkSync(realFilePath, symlinkPath); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); const agent = agents[0]!; assert.equal(agent.name, "claude-agent"); assert.equal(agent.description, "A Claude Code style agent"); assert.equal(agent.systemPromptMode, "append"); assert.equal(agent.inheritProjectContext, true); assert.deepEqual(agent.defaultReads, []); assert.equal(agent.source, "user"); assert.equal(agent.systemPrompt, "Claude Code body."); } finally { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(realFileDir, { recursive: true, force: true }); } }); test("discoverAgents: symlinked directory (symlink to a real dir containing AGENT.md) is discovered", async () => { const dir = makeTmpDir(); const realParentDir = makeTmpDir(); try { const realManifestDir = path.join(realParentDir, "critical-thinker"); fs.mkdirSync(realManifestDir); writeAgentFile( realManifestDir, "AGENT.md", `--- name: critical-thinker description: Challenges assumptions --- Manifest body. `, ); const symlinkPath = path.join(dir, "critical-thinker"); fs.symlinkSync(realManifestDir, symlinkPath, "dir"); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); assert.equal(agents[0]!.name, "critical-thinker"); } finally { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(realParentDir, { recursive: true, force: true }); } }); test("applyInvocationOverride: empty override returns the same config", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, {}); assert.equal(result, baseAgent); }); test("applyInvocationOverride: model string returns a new config with only model replaced, original untouched", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { model: "a/b" }); assert.notEqual(result, baseAgent); assert.equal(result.model, "a/b"); assert.equal(result.name, baseAgent.name); assert.equal(result.description, baseAgent.description); assert.deepEqual(result.tools, baseAgent.tools); assert.equal(result.systemPromptMode, baseAgent.systemPromptMode); assert.equal(result.inheritProjectContext, baseAgent.inheritProjectContext); assert.deepEqual(result.defaultReads, baseAgent.defaultReads); assert.equal(result.source, baseAgent.source); assert.equal(result.filePath, baseAgent.filePath); assert.equal(result.systemPrompt, baseAgent.systemPrompt); assert.equal(baseAgent.model, "frontmatter-model"); }); test("applyInvocationOverride: tools set to empty array replaces tools with [], not undefined or original", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { tools: [] }); assert.notEqual(result, baseAgent); assert.deepEqual(result.tools, []); assert.deepEqual(baseAgent.tools, ["read"]); }); test("applyInvocationOverride: skills set to empty array replaces skills with [], not undefined or original", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], skills: ["skill-a"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { skills: [] }); assert.notEqual(result, baseAgent); assert.deepEqual(result.skills, []); assert.deepEqual(baseAgent.skills, ["skill-a"]); }); test("applyInvocationOverride: model, tools, and skills all set together replaces all three in one copy, unrelated fields untouched", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], skills: ["skill-a"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { model: "a/b", tools: ["grep", "find"], skills: ["skill-b"], }); assert.notEqual(result, baseAgent); assert.equal(result.model, "a/b"); assert.deepEqual(result.tools, ["grep", "find"]); assert.deepEqual(result.skills, ["skill-b"]); assert.equal(result.description, baseAgent.description); }); test("applyInvocationOverride: maxTurns number returns a new config with only maxTurns replaced, original untouched", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { maxTurns: 7 }); assert.notEqual(result, baseAgent); assert.equal(result.maxTurns, 7); assert.equal(result.name, baseAgent.name); assert.equal(result.description, baseAgent.description); assert.deepEqual(result.tools, baseAgent.tools); assert.equal(result.model, baseAgent.model); assert.equal(result.systemPromptMode, baseAgent.systemPromptMode); assert.equal(result.inheritProjectContext, baseAgent.inheritProjectContext); assert.deepEqual(result.defaultReads, baseAgent.defaultReads); assert.equal(result.source, baseAgent.source); assert.equal(result.filePath, baseAgent.filePath); assert.equal(result.systemPrompt, baseAgent.systemPrompt); assert.equal(baseAgent.maxTurns, undefined); }); test("applyInvocationOverride: empty override returns the same config (fast path with maxTurns field present in type)", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, {}); assert.equal(result, baseAgent); }); test("applyInvocationOverride: maxTurns set to undefined is treated as not present and returns the same config", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { maxTurns: undefined }); assert.equal(result, baseAgent); }); test("applyInvocationOverride: thinking string returns a new config with only thinking replaced, original untouched", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", thinking: "low", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { thinking: "max" }); assert.notEqual(result, baseAgent); assert.equal(result.thinking, "max"); assert.equal(result.model, baseAgent.model); assert.equal(baseAgent.thinking, "low"); }); test("applyInvocationOverride: timeoutMs number returns a new config with only timeoutMs replaced, original untouched", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", timeoutMs: 60_000, systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { timeoutMs: 20 }); assert.notEqual(result, baseAgent); assert.equal(result.timeoutMs, 20); assert.equal(result.model, baseAgent.model); assert.equal(baseAgent.timeoutMs, 60_000); }); test("applyInvocationOverride: empty override returns the same config (fast path with thinking and timeoutMs fields present in type)", () => { const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const result = applyInvocationOverride(baseAgent, { thinking: undefined, timeoutMs: undefined }); assert.equal(result, baseAgent); }); test("applyOverrides: project override wins over user override; user override wins over frontmatter when project doesn't touch the field", () => { const overrides: AgentOverrides = { scout: { model: "project-model", description: "User description" }, }; const baseAgent: AgentConfig = { name: "scout", description: "Frontmatter description", tools: ["read"], model: "frontmatter-model", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Frontmatter body.", }; const [applied] = applyOverrides([baseAgent], overrides); assert.equal(applied!.model, "project-model"); assert.equal(applied!.description, "User description"); }); test("applyOverrides: propagates new fields (thinking, inheritSkills, defaultContext, skills)", () => { const baseAgent: AgentConfig = { name: "scout", description: "test", tools: ["read"], model: "default", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "body", }; const overrides = { scout: { thinking: "high", inheritSkills: false, defaultContext: "fresh" as const, skills: ["skill-a"], }, }; const [applied] = applyOverrides([baseAgent], overrides); assert.equal(applied!.thinking, "high"); assert.equal(applied!.inheritSkills, false); assert.equal(applied!.defaultContext, "fresh"); assert.deepEqual(applied!.skills, ["skill-a"]); }); test("applyOverrides: timeoutMs override flows onto the merged config; agents without an override keep it undefined", () => { const scoutAgent: AgentConfig = { name: "scout", description: "test", tools: ["read"], model: "default", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "body", }; const otherAgent: AgentConfig = { name: "other", description: "test", tools: ["read"], model: "default", systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/other.md", systemPrompt: "body", }; const overrides: AgentOverrides = { scout: { timeoutMs: 1200000 }, }; const [appliedScout, appliedOther] = applyOverrides([scoutAgent, otherAgent], overrides); assert.equal(appliedScout!.timeoutMs, 1200000); assert.equal(appliedOther!.timeoutMs, undefined); }); test("discoverAgents: cache returns cached data on second call", async () => { const dir = makeTmpDir(); try { writeAgentFile( dir, "test-agent.md", `--- name: test-agent description: test --- Body`, ); const cache = new Map>>(); const first = await discoverAgents(dir, cache, new Map()); assert.equal(first.length, 1); assert.equal(first[0]!.name, "test-agent"); // Delete the file and call again — should still return cached data fs.rmSync(path.join(dir, "test-agent.md")); const second = await discoverAgents(dir, cache, new Map()); assert.equal(second.length, 1); assert.equal(second[0]!.name, "test-agent"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: cache is optional — not passing cache still works", async () => { const dir = makeTmpDir(); try { writeAgentFile( dir, "test-agent.md", `--- name: test-agent description: test --- Body`, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); assert.equal(agents[0]!.name, "test-agent"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: two synchronous un-awaited calls with the same cache Map and dir return Object.is-equal promises", () => { const dir = makeTmpDir(); try { const cache = new Map>>(); const first = discoverAgents(dir, cache, new Map()); const second = discoverAgents(dir, cache, new Map()); assert.ok(Object.is(first, second)); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: warnings are emitted in readdir/filename order regardless of internal parallel execution", async (t) => { const dir = makeTmpDir(); try { writeAgentFile( dir, "agent-a.md", `--- name: agent-a --- Missing description A. `, ); writeAgentFile( dir, "agent-b.md", `--- name: agent-b --- Missing description B. `, ); writeAgentFile( dir, "agent-c.md", `--- name: agent-c --- Missing description C. `, ); const warnSpy = t.mock.method(console, "warn"); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 0); const skipWarnings = warnSpy.mock.calls .map((call) => call.arguments[0] as string) .filter((message) => message.includes("skipping")); assert.equal(skipWarnings.length, 3); assert.ok(skipWarnings[0]!.includes("agent-a.md")); assert.ok(skipWarnings[1]!.includes("agent-b.md")); assert.ok(skipWarnings[2]!.includes("agent-c.md")); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: maps Claude tool names onto tools/disallowedTools and does not skip the agent for inert fields", async () => { const dir = makeTmpDir(); try { writeAgentFile( dir, "scout-tools.md", `--- name: scout-tools description: Tools mapping test tools: Read, Glob disallowedTools: Bash model: sonnet permissionMode: default maxTurns: 5 --- Body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); const agent = agents[0]!; assert.deepEqual(agent.tools, ["read", "find"]); assert.deepEqual(agent.disallowedTools, ["bash"]); assert.equal(agent.model, "sonnet"); assert.equal(agent.maxTurns, 5); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: populates maxTurns from frontmatter (in range) and leaves it undefined when absent", async () => { const dir = makeTmpDir(); try { writeAgentFile( dir, "bounded.md", `--- name: bounded description: Has a maxTurns limit maxTurns: 7 --- Body. `, ); writeAgentFile( dir, "unbounded.md", `--- name: unbounded description: No maxTurns configured --- Body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 2); const bounded = agents.find((agent) => agent.name === "bounded")!; const unbounded = agents.find((agent) => agent.name === "unbounded")!; assert.ok(bounded, "bounded agent should be discovered"); assert.ok(unbounded, "unbounded agent should be discovered"); assert.equal(bounded.maxTurns, 7); assert.equal(unbounded.maxTurns, undefined); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: populates timeoutMs from frontmatter and leaves it undefined when absent", async () => { const dir = makeTmpDir(); try { writeAgentFile( dir, "timed.md", `--- name: timed description: Has a timeoutMs timeoutMs: 60000 --- Body. `, ); writeAgentFile( dir, "untimed.md", `--- name: untimed description: No timeoutMs configured --- Body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); const timed = agents.find((agent) => agent.name === "timed")!; const untimed = agents.find((agent) => agent.name === "untimed")!; assert.ok(timed, "timed agent should be discovered"); assert.ok(untimed, "untimed agent should be discovered"); assert.equal(timed.timeoutMs, 60_000); assert.equal(untimed.timeoutMs, undefined); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: out-of-range maxTurns in frontmatter resolves to undefined and emits a per-file warning mentioning maxTurns", async (t) => { const dir = makeTmpDir(); try { writeAgentFile( dir, "huge.md", `--- name: huge description: maxTurns above the limit maxTurns: 200 --- Body. `, ); const warnSpy = t.mock.method(console, "warn", () => {}); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); const agent = agents[0]!; assert.equal(agent.name, "huge"); assert.equal(agent.maxTurns, undefined); const maxTurnsWarnings = warnSpy.mock.calls.filter((call) => { const message = call.arguments[0]; return typeof message === "string" && /maxTurns/.test(message); }); assert.ok( maxTurnsWarnings.length > 0, "expected at least one console.warn mentioning maxTurns", ); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: aggregates inert-field warnings across the whole pass into exactly one console.warn call", async (t) => { const dir = makeTmpDir(); try { writeAgentFile( dir, "agent-a.md", `--- name: agent-a description: First agent permissionMode: default --- Body A. `, ); writeAgentFile( dir, "agent-b.md", `--- name: agent-b description: Second agent permissionMode: default --- Body B. `, ); const warnSpy = t.mock.method(console, "warn"); const warnRegistry = new Map(); const agents = await discoverAgents(dir, undefined, warnRegistry); assert.equal(agents.length, 2); const inertSummaryCalls = warnSpy.mock.calls.filter( (call) => typeof call.arguments[0] === "string" && (call.arguments[0] as string).includes("accepted but inert in pi"), ); assert.equal(inertSummaryCalls.length, 1); assert.match(inertSummaryCalls[0]!.arguments[0] as string, /fields: permissionMode/); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: model alias (e.g. opus) end-to-end produces exactly one console.warn matching 'model aliases: opus'", async (t) => { const dir = makeTmpDir(); try { writeAgentFile( dir, "aliased.md", `--- name: aliased description: Uses a Claude model alias model: opus --- Body. `, ); const warnSpy = t.mock.method(console, "warn"); const warnRegistry = new Map(); const agents = await discoverAgents(dir, undefined, warnRegistry); assert.equal(agents.length, 1); assert.equal(agents[0]!.model, "opus"); assert.equal(warnSpy.mock.calls.length, 1); assert.match(warnSpy.mock.calls[0]!.arguments[0] as string, /model aliases: opus/); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: populates thinking, inheritSkills, inheritExtensions, defaultContext, skills from frontmatter", async () => { const dir = makeTmpDir(); try { writeAgentFile( dir, "thinker.md", `--- name: thinker description: Uses extended fields thinking: high inheritSkills: true inheritExtensions: false defaultContext: fresh skills: [code-review] --- Body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); const agent = agents[0]!; assert.equal(agent.thinking, "high"); assert.equal(agent.inheritSkills, true); assert.equal(agent.inheritExtensions, false); assert.equal(agent.defaultContext, "fresh"); assert.deepEqual(agent.skills, ["code-review"]); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: golden-file backward-compat gate — agents-examples/scout.md and web-scout.md (S16)", async (t) => { const agentsDir = path.join(import.meta.dirname, "../../agents-examples"); const warnSpy = t.mock.method(console, "warn"); const agents = await discoverAgents(agentsDir, undefined, new Map()); assert.equal(agents.length, 2); const scout = agents.find((agent) => agent.name === "scout")!; assert.ok(scout, "scout agent should be discovered"); assert.equal( scout.description, "Fast codebase recon — finds files, symbols, patterns, and references. " + "No analysis, no evaluation, no implementation. Returns compressed " + "findings (file paths, line numbers, excerpts) to the caller.\n", ); assert.deepEqual(scout.tools, ["read", "grep", "find", "ls"]); assert.equal(scout.systemPromptMode, "append"); assert.equal(scout.inheritProjectContext, false); const webScout = agents.find((agent) => agent.name === "web-scout")!; assert.ok(webScout, "web-scout agent should be discovered"); assert.deepEqual(webScout.tools, ["web_search", "web_read"]); assert.equal(webScout.systemPromptMode, "replace"); assert.equal(webScout.inheritProjectContext, false); // Zero warnings/inert findings for either golden file. assert.equal(warnSpy.mock.calls.length, 0); }); test("discoverAgents: top-level plain file (no extension) is ignored", async (t) => { const dir = makeTmpDir(); try { writeAgentFile(dir, "README", "Just a plain file, not an agent.\n"); const warnSpy = t.mock.method(console, "warn"); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 0); assert.equal(warnSpy.mock.calls.length, 0); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: empty subdirectory is ignored", async (t) => { const dir = makeTmpDir(); try { fs.mkdirSync(path.join(dir, "empty")); const warnSpy = t.mock.method(console, "warn"); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 0); assert.equal(warnSpy.mock.calls.length, 0); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: subdirectory without AGENT.md is ignored", async (t) => { const dir = makeTmpDir(); try { const referencesDir = path.join(dir, "references"); fs.mkdirSync(referencesDir); writeAgentFile(referencesDir, "notes.txt", "Unrelated notes, no AGENT.md here.\n"); const warnSpy = t.mock.method(console, "warn"); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 0); assert.equal(warnSpy.mock.calls.length, 0); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: flat scout.md and directory scout/AGENT.md both named 'scout' dedup to one agent with a duplicate warning", async (t) => { const dir = makeTmpDir(); try { writeAgentFile( dir, "scout.md", `--- name: scout description: Flat scout --- Flat body. `, ); const manifestDir = path.join(dir, "scout"); fs.mkdirSync(manifestDir); writeAgentFile( manifestDir, "AGENT.md", `--- name: scout description: Directory scout --- Manifest body. `, ); const warnSpy = t.mock.method(console, "warn"); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); const duplicateWarning = warnSpy.mock.calls.find((call) => { const message = call.arguments[0] as string; return message.includes("duplicate") && message.includes("scout"); }); assert.ok(duplicateWarning, "expected a console.warn call mentioning 'duplicate' and 'scout'"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: collision between a directory-style agent and a flat file is resolved by sorted filename order, not raw readdir order", async () => { const dir = makeTmpDir(); try { // Entry names sort as "a-scout" < "z-scout.md", so the directory manifest // must win regardless of the raw readdir() order the OS happens to return. writeAgentFile( dir, "z-scout.md", `--- name: scout description: Flat scout (sorts second) --- Flat body. `, ); const manifestDir = path.join(dir, "a-scout"); fs.mkdirSync(manifestDir); writeAgentFile( manifestDir, "AGENT.md", `--- name: scout description: Directory scout (sorts first) --- Manifest body. `, ); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); assert.equal(agents[0]!.description, "Directory scout (sorts first)"); assert.equal(agents[0]!.filePath, path.join(manifestDir, "AGENT.md")); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: two flat .md files with the same frontmatter name dedup to one, alphabetically-first-by-filename wins", async (t) => { const dir = makeTmpDir(); try { writeAgentFile( dir, "a-scout.md", `--- name: scout description: First scout file --- Body A. `, ); writeAgentFile( dir, "b-scout.md", `--- name: scout description: Second scout file --- Body B. `, ); const warnSpy = t.mock.method(console, "warn"); const agents = await discoverAgents(dir, undefined, new Map()); assert.equal(agents.length, 1); assert.equal(agents[0]!.description, "First scout file"); assert.equal(agents[0]!.filePath, path.join(dir, "a-scout.md")); const duplicateWarning = warnSpy.mock.calls.find((call) => { const message = call.arguments[0] as string; return message.includes("duplicate") && message.includes("scout"); }); assert.ok(duplicateWarning, "expected a console.warn call mentioning 'duplicate' and 'scout'"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("discoverAgents: duplicate-agent warning is throttled — a second call within the TTL window for the same duplicate does not re-warn", async (t) => { const dir = makeTmpDir(); try { writeAgentFile( dir, "a-scout.md", `--- name: scout description: First scout file --- Body A. `, ); writeAgentFile( dir, "b-scout.md", `--- name: scout description: Second scout file --- Body B. `, ); const warnRegistry = new Map(); const warnSpy = t.mock.method(console, "warn"); const countDuplicateWarnings = () => warnSpy.mock.calls.filter((call) => { const message = call.arguments[0] as string; return message.includes("duplicate") && message.includes("scout"); }).length; const first = await discoverAgents(dir, undefined, warnRegistry); assert.equal(first.length, 1); assert.equal(countDuplicateWarnings(), 1); // No cache passed, so this re-reads the directory from scratch; only the // shared warnRegistry should suppress the repeat warning within the TTL. const second = await discoverAgents(dir, undefined, warnRegistry); assert.equal(second.length, 1); assert.equal(countDuplicateWarnings(), 1, "second call within TTL should not re-warn"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("dedupeByResolvedName: first agent in input order wins on a name collision; the second is dropped with a warning naming both paths", (t) => { const first: AgentConfig = { name: "scout", description: "Flat scout", tools: [], systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout.md", systemPrompt: "Flat body.", }; const second: AgentConfig = { name: "scout", description: "Directory scout", tools: [], systemPromptMode: "append", inheritProjectContext: true, defaultReads: [], source: "user", filePath: "/fake/scout/AGENT.md", systemPrompt: "Manifest body.", }; const warnSpy = t.mock.method(console, "warn"); const result = dedupeByResolvedName([first, second], new Map()); assert.equal(result.length, 1); assert.equal(result[0], first); assert.equal(warnSpy.mock.calls.length, 1); const message = warnSpy.mock.calls[0]!.arguments[0] as string; assert.ok(message.includes(first.filePath)); assert.ok(message.includes(second.filePath)); }); test("loadSettings: both files missing returns empty agentOverrides and undefined concurrency", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); const projectSettingsPath = path.join(dir, "project-settings.json"); const settings = await loadSettings(userSettingsPath, projectSettingsPath); assert.deepEqual(settings.agentOverrides, {}); assert.equal(settings.concurrency, undefined); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: reads concurrency from 'pi-simple-agents' key", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { concurrency: 6 } }), "utf8", ); const settings = await loadSettings(userSettingsPath); assert.equal(settings.concurrency, 6); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: reads concurrency from legacy 'subagents' key", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ subagents: { concurrency: 6 } }), "utf8", ); const settings = await loadSettings(userSettingsPath); assert.equal(settings.concurrency, 6); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: legacy 'subagents' key emits a deprecation warning naming the settings file", async (t) => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ subagents: { concurrency: 6 } }), "utf8", ); const warnSpy = t.mock.method(console, "warn", () => {}); const settings = await loadSettings(userSettingsPath); assert.equal(settings.concurrency, 6); assert.ok( warnSpy.mock.calls.some((call) => { const message = call.arguments[0] as string; return message.includes("subagents") && message.includes(userSettingsPath); }), ); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: legacy 'subagents' key used for both agentOverrides and concurrency warns once, not twice", async (t) => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ subagents: { concurrency: 6, agentOverrides: { scout: { model: "custom" } } }, }), "utf8", ); const warnSpy = t.mock.method(console, "warn", () => {}); const settings = await loadSettings(userSettingsPath); assert.equal(settings.concurrency, 6); assert.equal(settings.agentOverrides.scout?.model, "custom"); const deprecationCalls = warnSpy.mock.calls.filter((call) => { const message = call.arguments[0] as string; return message.includes("subagents") && message.includes(userSettingsPath); }); assert.equal(deprecationCalls.length, 1); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: 'pi-simple-agents' key alone does not emit the legacy deprecation warning", async (t) => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { concurrency: 6 } }), "utf8", ); const warnSpy = t.mock.method(console, "warn", () => {}); const settings = await loadSettings(userSettingsPath); assert.equal(settings.concurrency, 6); assert.equal(warnSpy.mock.calls.length, 0); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: 'pi-simple-agents' concurrency and legacy 'subagents' agentOverrides in the same file are both honored independently", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { concurrency: 6 }, subagents: { agentOverrides: { scout: { model: "custom" } } }, }), "utf8", ); const settings = await loadSettings(userSettingsPath); assert.equal(settings.concurrency, 6); assert.equal(settings.agentOverrides.scout?.model, "custom"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: 'pi-simple-agents' agentOverrides and legacy 'subagents' concurrency in the same file are both honored independently", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { agentOverrides: { scout: { model: "custom" } } }, subagents: { concurrency: 8 }, }), "utf8", ); const settings = await loadSettings(userSettingsPath); assert.equal(settings.concurrency, 8); assert.equal(settings.agentOverrides.scout?.model, "custom"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: when both 'pi-simple-agents' and legacy 'subagents' set agentOverrides in the same file, 'pi-simple-agents' wins entirely", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { agentOverrides: { scout: { model: "primary-model" } } }, subagents: { agentOverrides: { scout: { model: "legacy-model" } } }, }), "utf8", ); const settings = await loadSettings(userSettingsPath); assert.equal(settings.agentOverrides.scout?.model, "primary-model"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: project file's concurrency overrides user file's", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); const projectSettingsPath = path.join(dir, "project-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { concurrency: 4 } }), "utf8", ); fs.writeFileSync( projectSettingsPath, JSON.stringify({ "pi-simple-agents": { concurrency: 8 } }), "utf8", ); const settings = await loadSettings(userSettingsPath, projectSettingsPath); assert.equal(settings.concurrency, 8); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: project file with no concurrency key falls back to user file's value", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); const projectSettingsPath = path.join(dir, "project-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { concurrency: 4 } }), "utf8", ); fs.writeFileSync( projectSettingsPath, JSON.stringify({ "pi-simple-agents": { agentOverrides: {} } }), "utf8", ); const settings = await loadSettings(userSettingsPath, projectSettingsPath); assert.equal(settings.concurrency, 4); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: agentOverrides merge — project field wins per-agent over user", async () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); const projectSettingsPath = path.join(dir, "project-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { agentOverrides: { scout: { model: "user-model", description: "User description" }, }, }, }), "utf8", ); fs.writeFileSync( projectSettingsPath, JSON.stringify({ "pi-simple-agents": { agentOverrides: { scout: { model: "project-model" }, }, }, }), "utf8", ); const settings = await loadSettings(userSettingsPath, projectSettingsPath); assert.equal(settings.agentOverrides.scout?.model, "project-model"); assert.equal(settings.agentOverrides.scout?.description, "User description"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: malformed JSON in project file only still returns user file's data, with a warning naming the project file", async (t) => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); const projectSettingsPath = path.join(dir, "project-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { concurrency: 6, agentOverrides: { scout: { model: "user-model" } }, }, }), "utf8", ); fs.writeFileSync(projectSettingsPath, "{ not valid json", "utf8"); const warnSpy = t.mock.method(console, "warn"); const settings = await loadSettings(userSettingsPath, projectSettingsPath); assert.equal(settings.concurrency, 6); assert.equal(settings.agentOverrides.scout?.model, "user-model"); assert.ok( warnSpy.mock.calls.some((call) => (call.arguments[0] as string).includes(projectSettingsPath), ), ); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: non-object agentOverrides in settings file falls back to {} and warns naming the file", async (t) => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); fs.writeFileSync( userSettingsPath, JSON.stringify({ "pi-simple-agents": { agentOverrides: "oops", }, }), "utf8", ); const warnSpy = t.mock.method(console, "warn"); const settings = await loadSettings(userSettingsPath); assert.deepEqual(settings.agentOverrides, {}); assert.ok( warnSpy.mock.calls.some((call) => (call.arguments[0] as string).includes(userSettingsPath), ), ); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: two synchronous un-awaited calls with the same cache Map and paths return Object.is-equal promises", () => { const dir = makeTmpDir(); try { const userSettingsPath = path.join(dir, "user-settings.json"); const projectSettingsPath = path.join(dir, "project-settings.json"); const cache = new Map>>(); const first = loadSettings(userSettingsPath, projectSettingsPath, cache); const second = loadSettings(userSettingsPath, projectSettingsPath, cache); assert.ok(Object.is(first, second)); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); test("loadSettings: cache hit within TTL does not re-read the file", async () => { const dir = makeTmpDir(); try { const settingsPath = path.join(dir, "settings.json"); fs.writeFileSync( settingsPath, JSON.stringify({ "pi-simple-agents": { agentOverrides: { scout: { model: "first-model" } } }, }), "utf8", ); const cache = new Map>>(); const first = await loadSettings(settingsPath, undefined, cache); assert.equal(first.agentOverrides.scout?.model, "first-model"); // Change the file and call again — should still return cached data fs.writeFileSync( settingsPath, JSON.stringify({ "pi-simple-agents": { agentOverrides: { scout: { model: "second-model" } } }, }), "utf8", ); const second = await loadSettings(settingsPath, undefined, cache); assert.equal(second.agentOverrides.scout?.model, "first-model"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } });