import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { BUILTIN_TOOL_NAMES, getAgentConfig, getAvailableTypes, getConfig, getDefaultAgentNames, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType, getUserAgentNames, isDefaultsDisabled, isValidType, NO_FALLBACK, registerAgents, resolveEnabledTypeIn, resolveSpawnType, resolveSpawnTypeIn, resolveType, setDefaultsDisabled, setFallbackSubagent, } from "../src/agent-types.js"; import { DEFAULT_AGENTS } from "../src/default-agents.js"; import type { AgentConfig } from "../src/types.js"; function makeAgentConfig(overrides: Partial = {}): AgentConfig { return { name: "test-agent", description: "Test agent", builtinToolNames: ["read", "grep"], extensions: false, skills: false, systemPrompt: "You are a test agent.", promptMode: "replace", inheritContext: false, runInBackground: false, isolated: false, ...overrides, }; } describe("agent type registry", () => { beforeEach(() => { registerAgents(new Map()); }); describe("default agents", () => { it("recognizes all default agent types", () => { expect(isValidType("general-purpose")).toBe(true); expect(isValidType("Explore")).toBe(true); expect(isValidType("Plan")).toBe(true); }); it("does not include removed agents", () => { expect(isValidType("statusline-setup")).toBe(false); expect(isValidType("claude-code-guide")).toBe(false); }); it("rejects unknown types", () => { expect(isValidType("nonexistent")).toBe(false); expect(isValidType("")).toBe(false); }); it("case-insensitive lookup works for isValidType", () => { expect(isValidType("explore")).toBe(true); expect(isValidType("EXPLORE")).toBe(true); expect(isValidType("General-Purpose")).toBe(true); expect(isValidType("plan")).toBe(true); }); it("case-insensitive lookup works for getAgentConfig", () => { const config = getAgentConfig("explore"); expect(config?.name).toBe("Explore"); expect(config?.model).toBe("anthropic/claude-haiku-4-5"); }); it("resolveType returns canonical key or undefined", () => { expect(resolveType("Explore")).toBe("Explore"); expect(resolveType("explore")).toBe("Explore"); expect(resolveType("GENERAL-PURPOSE")).toBe("general-purpose"); expect(resolveType("nonexistent")).toBeUndefined(); }); it("returns correct config for default types", () => { const config = getConfig("general-purpose"); expect(config.displayName).toBe("Agent"); expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); expect(config.extensions).toBe(true); expect(config.skills).toBe(true); }); it("Explore has read-only tools", () => { const config = getConfig("Explore"); expect(config.builtinToolNames).toEqual(["read", "bash", "grep", "find", "ls"]); expect(config.builtinToolNames).not.toContain("edit"); expect(config.builtinToolNames).not.toContain("write"); }); it("Explore has haiku model in config", () => { const cfg = getAgentConfig("Explore"); expect(cfg?.model).toBe("anthropic/claude-haiku-4-5"); }); it("default agents are marked isDefault", () => { const cfg = getAgentConfig("general-purpose"); expect(cfg?.isDefault).toBe(true); }); // Regression guard for #37 — default agents must not bake in callsite-strategy fields. // An explicit `false` here would silently win over the caller's `true` via `??` in // resolveAgentInvocationConfig, breaking documented Agent tool params. it("default agents do not lock strategy fields (run_in_background / inherit_context / isolated)", () => { for (const name of ["general-purpose", "Explore", "Plan"]) { const cfg = getAgentConfig(name); expect(cfg?.runInBackground, `${name}.runInBackground`).toBeUndefined(); expect(cfg?.inheritContext, `${name}.inheritContext`).toBeUndefined(); expect(cfg?.isolated, `${name}.isolated`).toBeUndefined(); } }); it("getDefaultAgentNames returns default agent names", () => { const names = getDefaultAgentNames(); expect(names).toContain("general-purpose"); expect(names).toContain("Explore"); expect(names).toContain("Plan"); }); it("BUILTIN_TOOL_NAMES includes all built-in tools", () => { expect(BUILTIN_TOOL_NAMES).toContain("read"); expect(BUILTIN_TOOL_NAMES).toContain("bash"); expect(BUILTIN_TOOL_NAMES).toContain("edit"); expect(BUILTIN_TOOL_NAMES).toContain("write"); expect(BUILTIN_TOOL_NAMES).toContain("grep"); expect(BUILTIN_TOOL_NAMES).toContain("find"); expect(BUILTIN_TOOL_NAMES).toContain("ls"); expect(BUILTIN_TOOL_NAMES.length).toBeGreaterThanOrEqual(7); }); }); describe("disable defaults", () => { // Module-level flag — always reset so later describes see the default roster. afterEach(() => { setDefaultsDisabled(false); registerAgents(new Map()); }); it("defaults to enabled", () => { expect(isDefaultsDisabled()).toBe(false); }); it("registerAgents skips DEFAULT_AGENTS when disabled", () => { setDefaultsDisabled(true); registerAgents(new Map()); expect(getAvailableTypes()).toEqual([]); expect(isValidType("general-purpose")).toBe(false); expect(isValidType("Explore")).toBe(false); expect(isValidType("Plan")).toBe(false); }); it("user agents are unaffected when defaults are disabled", () => { setDefaultsDisabled(true); registerAgents(new Map([["auditor", makeAgentConfig({ name: "auditor" })]])); expect(getAvailableTypes()).toEqual(["auditor"]); expect(isValidType("auditor")).toBe(true); expect(getDefaultAgentNames()).toEqual([]); }); it("re-enabling restores defaults on next registerAgents", () => { setDefaultsDisabled(true); registerAgents(new Map()); expect(isValidType("general-purpose")).toBe(false); setDefaultsDisabled(false); registerAgents(new Map()); expect(isValidType("general-purpose")).toBe(true); expect(isValidType("Explore")).toBe(true); expect(isValidType("Plan")).toBe(true); }); it("getConfig falls back to the hardcoded config when defaults are disabled and no user agents exist", () => { setDefaultsDisabled(true); registerAgents(new Map()); const config = getConfig("general-purpose"); expect(config.displayName).toBe("Agent"); expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); expect(config.promptMode).toBe("append"); }); }); describe("user agents", () => { it("registers and retrieves user agents", () => { const agents = new Map([["auditor", makeAgentConfig({ name: "auditor", description: "Auditor" })]]); registerAgents(agents); expect(isValidType("auditor")).toBe(true); expect(getAgentConfig("auditor")?.description).toBe("Auditor"); }); it("includes user agents in available types", () => { const agents = new Map([["auditor", makeAgentConfig({ name: "auditor" })]]); registerAgents(agents); const types = getAvailableTypes(); expect(types).toContain("general-purpose"); expect(types).toContain("Explore"); expect(types).toContain("auditor"); }); it("lists user agent names separately", () => { const agents = new Map([ ["auditor", makeAgentConfig({ name: "auditor" })], ["reviewer", makeAgentConfig({ name: "reviewer" })], ]); registerAgents(agents); const names = getUserAgentNames(); expect(names).toEqual(["auditor", "reviewer"]); expect(names).not.toContain("general-purpose"); }); it("getConfig returns config for user agents", () => { const agents = new Map([["auditor", makeAgentConfig({ name: "auditor", description: "Security auditor", builtinToolNames: ["read", "grep"], extensions: false, skills: true, })]]); registerAgents(agents); const config = getConfig("auditor"); expect(config.displayName).toBe("auditor"); expect(config.description).toBe("Security auditor"); expect(config.builtinToolNames).toEqual(["read", "grep"]); expect(config.extensions).toBe(false); expect(config.skills).toBe(true); }); it("getConfig returns extension allowlist for user agents", () => { const agents = new Map([["partial", makeAgentConfig({ name: "partial", extensions: ["web-search"], skills: ["planning"], })]]); registerAgents(agents); const config = getConfig("partial"); expect(config.extensions).toEqual(["web-search"]); expect(config.skills).toEqual(["planning"]); }); it("getToolNamesForType works for user agents", () => { const agents = new Map([["auditor", makeAgentConfig({ name: "auditor", builtinToolNames: ["read", "grep", "find"], })]]); registerAgents(agents); const names = getToolNamesForType("auditor"); expect(names).toEqual(["read", "grep", "find"]); }); it("getToolNamesForType honors an explicit empty builtinToolNames as zero built-ins", () => { // `tools: none` and `tools:` with only `ext:` entries both produce `[]`. const agents = new Map([["ext-only", makeAgentConfig({ name: "ext-only", builtinToolNames: [], })]]); registerAgents(agents); expect(getToolNamesForType("ext-only")).toEqual([]); }); it("getConfig falls back to general-purpose for unknown types", () => { const config = getConfig("nonexistent"); expect(config.displayName).toBe("Agent"); expect(config.description).toBe(DEFAULT_AGENTS.get("general-purpose")?.description); }); it("clearing user agents works (defaults remain)", () => { const agents = new Map([["auditor", makeAgentConfig({ name: "auditor" })]]); registerAgents(agents); expect(isValidType("auditor")).toBe(true); registerAgents(new Map()); expect(isValidType("auditor")).toBe(false); expect(isValidType("general-purpose")).toBe(true); }); it("user agent overrides default with same name", () => { const agents = new Map([["Explore", makeAgentConfig({ name: "Explore", description: "Custom Explore", builtinToolNames: BUILTIN_TOOL_NAMES, })]]); registerAgents(agents); const config = getConfig("Explore"); expect(config.description).toBe("Custom Explore"); expect(config.builtinToolNames).toEqual(BUILTIN_TOOL_NAMES); }); it("disabled agent is excluded from available types", () => { const agents = new Map([["Plan", makeAgentConfig({ name: "Plan", enabled: false, })]]); registerAgents(agents); expect(isValidType("Plan")).toBe(false); expect(getAvailableTypes()).not.toContain("Plan"); }); it("general-purpose can be disabled but fallback still works", () => { const agents = new Map([["general-purpose", makeAgentConfig({ name: "general-purpose", enabled: false, })]]); registerAgents(agents); expect(isValidType("general-purpose")).toBe(false); // getConfig fallback should still return something reasonable const config = getConfig("general-purpose"); expect(config.displayName).toBe("Agent"); }); }); describe("getMemoryToolNames", () => { it("returns read, write, edit when none exist", () => { const names = getMemoryToolNames(new Set()); expect(names).toContain("read"); expect(names).toContain("write"); expect(names).toContain("edit"); expect(names).toHaveLength(3); }); it("skips tools that already exist", () => { const names = getMemoryToolNames(new Set(["read", "edit"])); expect(names).toEqual(["write"]); }); it("returns empty when all memory tools already exist", () => { const names = getMemoryToolNames(new Set(["read", "write", "edit"])); expect(names).toHaveLength(0); }); }); describe("getReadOnlyMemoryToolNames", () => { it("returns only read when missing", () => { const names = getReadOnlyMemoryToolNames(new Set()); expect(names).toEqual(["read"]); }); it("returns empty when read already exists", () => { const names = getReadOnlyMemoryToolNames(new Set(["read"])); expect(names).toHaveLength(0); }); }); describe("BUILTIN_TOOL_NAMES", () => { // BUILTIN_TOOL_NAMES is derived dynamically from pi's tool factories // (createCodingTools + createReadOnlyTools). This guards against pi-mono // dropping/renaming a built-in: the set must still contain at least these // 7. It's a superset check ("at least") — pi adding a new built-in is fine // and won't fail this test. const EXPECTED = ["read", "bash", "edit", "write", "grep", "find", "ls"]; it("contains at least the 7 known built-ins", () => { for (const name of EXPECTED) { expect(BUILTIN_TOOL_NAMES).toContain(name); } }); it("has no duplicate entries", () => { expect(new Set(BUILTIN_TOOL_NAMES).size).toBe(BUILTIN_TOOL_NAMES.length); }); }); }); describe("resolveSpawnType — fail-closed dispatch (#183)", () => { afterEach(() => { setFallbackSubagent(undefined); setDefaultsDisabled(false); registerAgents(new Map()); }); const roster = () => new Map([ ["scout", makeAgentConfig({ name: "scout" })], ["retired", makeAgentConfig({ name: "retired", enabled: false })], ["router", makeAgentConfig({ name: "router" })], ]); it("resolves an enabled type case-insensitively", () => { registerAgents(roster()); expect(resolveSpawnType("SCOUT")).toEqual({ ok: true, type: "scout" }); }); it("falls back to general-purpose when unset, reporting what was asked for", () => { registerAgents(roster()); expect(resolveSpawnType("typoo")).toEqual({ ok: true, type: "general-purpose", fellBackFrom: "typoo", }); }); it("rejects unknown types under `none` and names what is available", () => { registerAgents(roster()); setFallbackSubagent(NO_FALLBACK); const r = resolveSpawnType("typoo"); expect(r.ok).toBe(false); if (r.ok) throw new Error("expected rejection"); expect(r.message).toContain('Unknown or disabled agent type: "typoo"'); expect(r.message).toContain("scout"); expect(r.message).not.toContain("retired"); // disabled agents aren't offered }); it("treats a disabled type as unresolvable, not as a valid name", () => { // Regression: the old path used resolveType(), which ignores `enabled`, so a // disabled agent dispatched with its own prompt and general-purpose's tools. registerAgents(roster()); expect(resolveSpawnType("retired")).toEqual({ ok: true, type: "general-purpose", fellBackFrom: "retired", }); setFallbackSubagent(NO_FALLBACK); expect(resolveSpawnType("retired").ok).toBe(false); }); it("refuses to guess between two types differing only by case", () => { registerAgents(new Map([ ["Scout", makeAgentConfig({ name: "Scout" })], ["scout", makeAgentConfig({ name: "scout" })], ])); // An exact match is still unambiguous... expect(resolveSpawnType("scout")).toEqual({ ok: true, type: "scout" }); // ...but a differently-cased spelling matches both, so it must not pick one. expect(resolveSpawnType("SCOUT").ok).toBe(true); expect(resolveSpawnType("SCOUT")).toEqual({ ok: true, type: "general-purpose", fellBackFrom: "SCOUT", }); }); it("routes unresolvable types to a named fallback agent", () => { registerAgents(roster()); setFallbackSubagent("router"); expect(resolveSpawnType("typoo")).toEqual({ ok: true, type: "router", fellBackFrom: "typoo", }); }); it("fails loudly when the configured fallback is itself unusable", () => { // Explicit configuration that cannot work is a misconfiguration, not a // second chance to guess — never a silent drop to general-purpose. registerAgents(roster()); setFallbackSubagent("retired"); const r = resolveSpawnType("typoo"); expect(r.ok).toBe(false); if (r.ok) throw new Error("expected rejection"); expect(r.message).toContain("fallbackSubagent"); }); it("treats a missing type like any other unresolvable one", () => { // Before this setting existed an empty type fell back like a typo; only // opting in should change that, so the default must stay permissive. registerAgents(roster()); for (const empty of ["", " ", undefined]) { expect(resolveSpawnType(empty)).toMatchObject({ ok: true, type: "general-purpose" }); } setFallbackSubagent(NO_FALLBACK); for (const empty of ["", " ", undefined]) { const r = resolveSpawnType(empty); expect(r.ok).toBe(false); if (r.ok) throw new Error("expected rejection"); expect(r.message).toContain("No agent type given"); } }); it("resolves strictly regardless of the setting, for nested delegation", () => { // Nested delegation uses this seam so a project-level fallback can't hand a // nested caller an agent its allowlist never named. const registry = roster(); setFallbackSubagent("router"); expect(resolveEnabledTypeIn(registry, "typoo")).toBeUndefined(); expect(resolveEnabledTypeIn(registry, "retired")).toBeUndefined(); expect(resolveEnabledTypeIn(registry, " SCOUT ")).toBe("scout"); // ...while the policy layer still honors it. expect(resolveSpawnTypeIn(registry, "typoo")).toEqual({ ok: true, type: "router", fellBackFrom: "typoo", }); }); });