import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { applyAndEmitLoaded, applySettings, loadSettings, persistToastFor, type SettingsAppliers, saveAndEmitChanged, saveSettings, } from "../src/settings.js"; /** * Tests for persistent settings. Uses two tmp directories: * - `globalDir`: redirected via PI_CODING_AGENT_DIR so getAgentDir() returns it. * Simulates `~/.pi/agent/` — the global scope. * - `projectDir`: passed explicitly as cwd to load/save. * Simulates the user's project root. Settings live at `/.pi/subagents.json`. */ describe("settings persistence", () => { let globalDir: string; let projectDir: string; let originalAgentDirEnv: string | undefined; const globalFile = () => join(globalDir, "subagents.json"); const projectFile = () => join(projectDir, ".pi", "subagents.json"); beforeEach(() => { globalDir = mkdtempSync(join(tmpdir(), "pi-settings-global-")); projectDir = mkdtempSync(join(tmpdir(), "pi-settings-project-")); originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = globalDir; }); afterEach(() => { if (originalAgentDirEnv == null) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv; rmSync(globalDir, { recursive: true, force: true }); rmSync(projectDir, { recursive: true, force: true }); }); function writeGlobal(obj: unknown) { writeFileSync(globalFile(), JSON.stringify(obj)); } function writeProject(obj: unknown) { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(projectFile(), JSON.stringify(obj)); } it("returns {} when both files are missing", () => { expect(loadSettings(projectDir)).toEqual({}); }); it("returns {} when both files are malformed JSON", () => { writeFileSync(globalFile(), "not json {{"); mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(projectFile(), "also not json"); expect(loadSettings(projectDir)).toEqual({}); }); it("loads from global when no project file", () => { writeGlobal({ maxConcurrent: 16, graceTurns: 10 }); expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 16, graceTurns: 10 }); }); it("loads from project when no global file", () => { writeProject({ maxConcurrent: 8, defaultJoinMode: "group" }); expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 8, defaultJoinMode: "group" }); }); it("merges global + project with project winning on conflicts", () => { writeGlobal({ maxConcurrent: 16, graceTurns: 10, defaultJoinMode: "async" }); writeProject({ maxConcurrent: 4, defaultMaxTurns: 50 }); expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 4, // project wins graceTurns: 10, // from global defaultJoinMode: "async", // from global defaultMaxTurns: 50, // from project only }); }); it("round-trips values: saveSettings then loadSettings", () => { const settings = { maxConcurrent: 7, defaultMaxTurns: 30, graceTurns: 3, defaultJoinMode: "smart" as const, schedulingEnabled: false, toolDescriptionMode: "compact" as const, }; saveSettings(settings, projectDir); expect(loadSettings(projectDir)).toEqual(settings); }); it("round-trips schedulingEnabled (true and false), and absence stays absent", () => { saveSettings({ schedulingEnabled: false }, projectDir); expect(loadSettings(projectDir)).toEqual({ schedulingEnabled: false }); saveSettings({ schedulingEnabled: true }, projectDir); expect(loadSettings(projectDir)).toEqual({ schedulingEnabled: true }); // Absence — caller's "use default" signal — must not become a stored false. saveSettings({}, projectDir); expect(loadSettings(projectDir)).toEqual({}); }); it("round-trips fleetView (true and false); keeps boolean, drops non-boolean", () => { saveSettings({ fleetView: false }, projectDir); expect(loadSettings(projectDir)).toEqual({ fleetView: false }); saveSettings({ fleetView: true }, projectDir); expect(loadSettings(projectDir)).toEqual({ fleetView: true }); writeProject({ fleetView: "on" } as any); expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped }); it("round-trips agentMentions modes; drops an unknown one", () => { for (const mode of ["model", "direct", "off"] as const) { saveSettings({ agentMentions: mode }, projectDir); expect(loadSettings(projectDir)).toEqual({ agentMentions: mode }); } writeProject({ agentMentions: "on" } as any); expect(loadSettings(projectDir)).toEqual({}); // unknown mode dropped }); it("reads the pre-mode agentMentions booleans as their modes", () => { // The setting shipped as a boolean before `model` existed, so a config // written then — or hand-written from the old README — must keep working. // `true` meant "on", and on is now `model`. writeProject({ agentMentions: true } as any); expect(loadSettings(projectDir)).toEqual({ agentMentions: "model" }); writeProject({ agentMentions: false } as any); expect(loadSettings(projectDir)).toEqual({ agentMentions: "off" }); }); it("round-trips rememberAgents (true and false); keeps boolean, drops non-boolean", () => { saveSettings({ rememberAgents: false }, projectDir); expect(loadSettings(projectDir)).toEqual({ rememberAgents: false }); saveSettings({ rememberAgents: true }, projectDir); expect(loadSettings(projectDir)).toEqual({ rememberAgents: true }); writeProject({ rememberAgents: "on" } as any); expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped }); it("round-trips widgetMode; keeps valid values, drops invalid", () => { saveSettings({ widgetMode: "off" }, projectDir); expect(loadSettings(projectDir)).toEqual({ widgetMode: "off" }); saveSettings({ widgetMode: "background" }, projectDir); expect(loadSettings(projectDir)).toEqual({ widgetMode: "background" }); writeProject({ widgetMode: "sideways" } as any); expect(loadSettings(projectDir)).toEqual({}); // invalid value dropped }); it("round-trips outputTranscript; drops non-boolean", () => { saveSettings({ outputTranscript: false }, projectDir); expect(loadSettings(projectDir)).toEqual({ outputTranscript: false }); saveSettings({ outputTranscript: true }, projectDir); expect(loadSettings(projectDir)).toEqual({ outputTranscript: true }); writeProject({ outputTranscript: "no" } as any); expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped }); it("round-trips backgroundByDefault (true and false), and absence stays absent", () => { // `false` is the load-bearing case: it's how a user restores the previous // foreground default, so it must survive a save/load rather than being // read back as absent and re-defaulting to background. saveSettings({ backgroundByDefault: false }, projectDir); expect(loadSettings(projectDir)).toEqual({ backgroundByDefault: false }); saveSettings({ backgroundByDefault: true }, projectDir); expect(loadSettings(projectDir)).toEqual({ backgroundByDefault: true }); saveSettings({}, projectDir); expect(loadSettings(projectDir)).toEqual({}); }); it("sanitize drops non-boolean backgroundByDefault silently", () => { writeProject({ backgroundByDefault: "yes" } as any); expect(loadSettings(projectDir)).toEqual({}); writeProject({ backgroundByDefault: 0 } as any); expect(loadSettings(projectDir)).toEqual({}); }); it("round-trips worktreeIsolation; drops non-boolean", () => { saveSettings({ worktreeIsolation: false }, projectDir); expect(loadSettings(projectDir)).toEqual({ worktreeIsolation: false }); saveSettings({ worktreeIsolation: true }, projectDir); expect(loadSettings(projectDir)).toEqual({ worktreeIsolation: true }); writeProject({ worktreeIsolation: "off" } as any); expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped }); it("round-trips reportUsage and showCost; drops non-boolean", () => { saveSettings({ reportUsage: true, showCost: true }, projectDir); expect(loadSettings(projectDir)).toEqual({ reportUsage: true, showCost: true }); saveSettings({ reportUsage: false, showCost: false }, projectDir); expect(loadSettings(projectDir)).toEqual({ reportUsage: false, showCost: false }); // The sanitizer is an allowlist: a key it does not name is dropped, and the // setting silently never applies. writeProject({ reportUsage: "on", showCost: 1 } as any); expect(loadSettings(projectDir)).toEqual({}); }); it("sanitize drops non-boolean schedulingEnabled silently", async () => { writeProject({ schedulingEnabled: "yes" } as any); expect(loadSettings(projectDir)).toEqual({}); writeProject({ schedulingEnabled: 1 } as any); expect(loadSettings(projectDir)).toEqual({}); }); it("saveSettings writes only to the project file; global is untouched", () => { writeGlobal({ maxConcurrent: 16 }); saveSettings({ maxConcurrent: 2 }, projectDir); // Project file contains the new value expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual({ maxConcurrent: 2 }); // Global file unchanged expect(JSON.parse(readFileSync(globalFile(), "utf-8"))).toEqual({ maxConcurrent: 16 }); }); it("saveSettings creates /.pi/ when missing", () => { expect(existsSync(join(projectDir, ".pi"))).toBe(false); saveSettings({ maxConcurrent: 4 }, projectDir); expect(existsSync(projectFile())).toBe(true); }); it("round-trips defaultMaxTurns: 0 (unlimited marker)", () => { saveSettings({ defaultMaxTurns: 0 }, projectDir); expect(loadSettings(projectDir)).toEqual({ defaultMaxTurns: 0 }); }); it("ignores unknown extra fields on load (forward-compat)", () => { writeProject({ maxConcurrent: 2, futureField: "ignored" }); const loaded = loadSettings(projectDir); expect(loaded.maxConcurrent).toBe(2); // Unknown fields are stripped by the sanitizer — old versions won't persist garbage expect((loaded as Record).futureField).toBeUndefined(); }); it("composes partial global + partial project correctly", () => { writeGlobal({ graceTurns: 10 }); writeProject({ maxConcurrent: 2 }); expect(loadSettings(projectDir)).toEqual({ graceTurns: 10, maxConcurrent: 2 }); }); describe("sanitizer", () => { it("drops maxConcurrent < 1", () => { writeProject({ maxConcurrent: 0, graceTurns: 5 }); expect(loadSettings(projectDir)).toEqual({ graceTurns: 5 }); }); it("drops negative maxConcurrent", () => { writeProject({ maxConcurrent: -3 }); expect(loadSettings(projectDir)).toEqual({}); }); it("drops non-integer maxConcurrent (floats, NaN, strings)", () => { writeProject({ maxConcurrent: 3.5 }); expect(loadSettings(projectDir).maxConcurrent).toBeUndefined(); writeProject({ maxConcurrent: "four" }); expect(loadSettings(projectDir).maxConcurrent).toBeUndefined(); writeProject({ maxConcurrent: null }); expect(loadSettings(projectDir).maxConcurrent).toBeUndefined(); }); it("accepts defaultMaxTurns: 0 (explicit unlimited)", () => { writeProject({ defaultMaxTurns: 0 }); expect(loadSettings(projectDir)).toEqual({ defaultMaxTurns: 0 }); }); it("drops negative defaultMaxTurns", () => { writeProject({ defaultMaxTurns: -1 }); expect(loadSettings(projectDir)).toEqual({}); }); it("drops graceTurns < 1", () => { writeProject({ graceTurns: 0 }); expect(loadSettings(projectDir)).toEqual({}); }); it("keeps maxSubagentDepth 0 (nesting off) but drops negative, fractional, and over-ceiling values", () => { writeProject({ maxSubagentDepth: 0 }); expect(loadSettings(projectDir)).toEqual({ maxSubagentDepth: 0 }); writeProject({ maxSubagentDepth: -1 }); expect(loadSettings(projectDir)).toEqual({}); writeProject({ maxSubagentDepth: 1.5 }); expect(loadSettings(projectDir)).toEqual({}); writeProject({ maxSubagentDepth: 17 }); expect(loadSettings(projectDir)).toEqual({}); }); it("accepts `none` and `false` as the disabled fallback, nothing else", () => { // Only the boolean needs an alias: it would otherwise be dropped, leaving // the PERMISSIVE default while the author believed strict was on. Every // string stays an agent name, so a mistaken "off" fails loudly at dispatch // instead of meaning one thing here and another in the resolver. for (const spelling of ["none", "NONE", " none ", false]) { writeProject({ fallbackSubagent: spelling }); expect(loadSettings(projectDir).fallbackSubagent?.toLowerCase()).toBe("none"); } writeProject({ fallbackSubagent: "off" }); expect(loadSettings(projectDir)).toEqual({ fallbackSubagent: "off" }); }); it("drops values that aren't a string or `false`, without coercing them", () => { // String(["none"]) is "none" — coercing would silently enable strict mode. for (const junk of [["none"], null, 42, true, {}]) { writeProject({ fallbackSubagent: junk }); expect(loadSettings(projectDir)).toEqual({}); } }); it("keeps a named fallback agent and drops non-strings", () => { writeProject({ fallbackSubagent: " my-router " }); expect(loadSettings(projectDir)).toEqual({ fallbackSubagent: "my-router" }); writeProject({ fallbackSubagent: 42 }); expect(loadSettings(projectDir)).toEqual({}); writeProject({ fallbackSubagent: " " }); expect(loadSettings(projectDir)).toEqual({}); }); it("drops invalid defaultJoinMode values", () => { writeProject({ defaultJoinMode: "invalid" }); expect(loadSettings(projectDir)).toEqual({}); writeProject({ defaultJoinMode: 42 }); expect(loadSettings(projectDir)).toEqual({}); writeProject({ defaultJoinMode: "" }); expect(loadSettings(projectDir)).toEqual({}); }); it("accepts all three valid join modes", () => { for (const mode of ["async", "group", "smart"] as const) { writeProject({ defaultJoinMode: mode }); expect(loadSettings(projectDir)).toEqual({ defaultJoinMode: mode }); } }); it("accepts scopeModels boolean (true and false)", () => { writeProject({ scopeModels: true }); expect(loadSettings(projectDir)).toEqual({ scopeModels: true }); writeProject({ scopeModels: false }); expect(loadSettings(projectDir)).toEqual({ scopeModels: false }); }); it("accepts strictAgentFiles boolean (true and false)", () => { writeProject({ strictAgentFiles: true }); expect(loadSettings(projectDir)).toEqual({ strictAgentFiles: true }); writeProject({ strictAgentFiles: false }); expect(loadSettings(projectDir)).toEqual({ strictAgentFiles: false }); }); it("drops non-boolean strictAgentFiles", () => { writeProject({ strictAgentFiles: "yes" }); expect(loadSettings(projectDir).strictAgentFiles).toBeUndefined(); writeProject({ strictAgentFiles: 1 }); expect(loadSettings(projectDir).strictAgentFiles).toBeUndefined(); }); it("drops non-boolean scopeModels", () => { writeProject({ scopeModels: "yes" }); expect(loadSettings(projectDir).scopeModels).toBeUndefined(); writeProject({ scopeModels: 1 }); expect(loadSettings(projectDir).scopeModels).toBeUndefined(); writeProject({ scopeModels: null }); expect(loadSettings(projectDir).scopeModels).toBeUndefined(); }); it("accepts disableDefaultAgents boolean (true and false)", () => { writeProject({ disableDefaultAgents: true }); expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: true }); writeProject({ disableDefaultAgents: false }); expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: false }); }); it("drops non-boolean disableDefaultAgents", () => { writeProject({ disableDefaultAgents: "yes" }); expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined(); writeProject({ disableDefaultAgents: 1 }); expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined(); writeProject({ disableDefaultAgents: null }); expect(loadSettings(projectDir).disableDefaultAgents).toBeUndefined(); }); it("accepts all valid toolDescriptionMode values", () => { for (const mode of ["full", "compact", "custom"] as const) { writeProject({ toolDescriptionMode: mode }); expect(loadSettings(projectDir)).toEqual({ toolDescriptionMode: mode }); } }); it("drops invalid toolDescriptionMode", () => { writeProject({ toolDescriptionMode: "tiny" }); expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined(); writeProject({ toolDescriptionMode: true }); expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined(); writeProject({ toolDescriptionMode: null }); expect(loadSettings(projectDir).toolDescriptionMode).toBeUndefined(); }); it("returns {} when the JSON root is not an object (array, string, null)", () => { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(projectFile(), '["not", "an", "object"]'); expect(loadSettings(projectDir)).toEqual({}); writeFileSync(projectFile(), '"just a string"'); expect(loadSettings(projectDir)).toEqual({}); writeFileSync(projectFile(), "null"); expect(loadSettings(projectDir)).toEqual({}); }); it("keeps valid fields while dropping invalid siblings", () => { writeProject({ maxConcurrent: 4, // ok defaultMaxTurns: -5, // dropped graceTurns: 3, // ok defaultJoinMode: "nope", // dropped }); expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 4, graceTurns: 3 }); }); it("accepts values at the ceiling (maxConcurrent=1024, defaultMaxTurns=10000, graceTurns=1000)", () => { writeProject({ maxConcurrent: 1024, defaultMaxTurns: 10_000, graceTurns: 1_000 }); expect(loadSettings(projectDir)).toEqual({ maxConcurrent: 1024, defaultMaxTurns: 10_000, graceTurns: 1_000, }); }); it("drops values above the ceiling", () => { writeProject({ maxConcurrent: 1025 }); expect(loadSettings(projectDir).maxConcurrent).toBeUndefined(); writeProject({ defaultMaxTurns: 10_001 }); expect(loadSettings(projectDir).defaultMaxTurns).toBeUndefined(); writeProject({ graceTurns: 1_001 }); expect(loadSettings(projectDir).graceTurns).toBeUndefined(); }); it("drops absurdly large values (e.g. 1e6)", () => { writeProject({ maxConcurrent: 1_000_000, defaultMaxTurns: 1_000_000, graceTurns: 1_000_000 }); expect(loadSettings(projectDir)).toEqual({}); }); }); describe("save result + corrupt-file warning", () => { it("saveSettings returns true on success", () => { expect(saveSettings({ maxConcurrent: 2 }, projectDir)).toBe(true); expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual({ maxConcurrent: 2 }); }); it("saveSettings returns false when the target dir cannot be created", () => { // Place a regular file where the parent of the settings file would go — // mkdirSync + writeFileSync both fail with ENOTDIR / EEXIST. const filePosingAsCwd = join(tmpdir(), `pi-settings-notdir-${Date.now()}`); writeFileSync(filePosingAsCwd, ""); try { expect(saveSettings({ maxConcurrent: 1 }, filePosingAsCwd)).toBe(false); } finally { rmSync(filePosingAsCwd, { force: true }); } }); it("warns to console.warn when an existing file is malformed", () => { const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(projectFile(), "not valid json {{{"); try { expect(loadSettings(projectDir)).toEqual({}); expect(spy).toHaveBeenCalledTimes(1); expect(String(spy.mock.calls[0][0])).toMatch(/Ignoring malformed settings/); } finally { spy.mockRestore(); } }); it("does NOT warn when a file is simply missing", () => { const spy = vi.spyOn(console, "warn").mockImplementation(() => {}); try { expect(loadSettings(projectDir)).toEqual({}); expect(spy).not.toHaveBeenCalled(); } finally { spy.mockRestore(); } }); }); describe("applySettings", () => { let appliers: SettingsAppliers; beforeEach(() => { appliers = { setMaxConcurrent: vi.fn(), setDefaultMaxTurns: vi.fn(), setGraceTurns: vi.fn(), setDefaultJoinMode: vi.fn(), setBackgroundByDefault: vi.fn(), setSchedulingEnabled: vi.fn(), setScopeModels: vi.fn(), setStrictAgentFiles: vi.fn(), setDisableDefaultAgents: vi.fn(), setToolDescriptionMode: vi.fn(), setFleetView: vi.fn(), setAgentMentions: vi.fn(), setRememberAgents: vi.fn(), setWidgetMode: vi.fn(), setOutputTranscript: vi.fn(), setWorktreeIsolation: vi.fn(), setMaxSubagentDepth: vi.fn(), setFallbackSubagent: vi.fn(), setReportUsage: vi.fn(), setShowCost: vi.fn(), }; }); it("applies reportUsage and showCost", () => { applySettings({ reportUsage: true, showCost: true }, appliers); expect(appliers.setReportUsage).toHaveBeenCalledWith(true); expect(appliers.setShowCost).toHaveBeenCalledWith(true); applySettings({ reportUsage: false, showCost: false }, appliers); expect(appliers.setReportUsage).toHaveBeenCalledWith(false); expect(appliers.setShowCost).toHaveBeenCalledWith(false); }); it("is a no-op on an empty settings object", () => { applySettings({}, appliers); expect(appliers.setReportUsage).not.toHaveBeenCalled(); expect(appliers.setShowCost).not.toHaveBeenCalled(); expect(appliers.setMaxConcurrent).not.toHaveBeenCalled(); expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled(); expect(appliers.setGraceTurns).not.toHaveBeenCalled(); expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled(); expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled(); expect(appliers.setScopeModels).not.toHaveBeenCalled(); expect(appliers.setDisableDefaultAgents).not.toHaveBeenCalled(); expect(appliers.setToolDescriptionMode).not.toHaveBeenCalled(); }); it("applies fallbackSubagent through to the registry", () => { // Without this, deleting the applySettings line for this field leaves the // whole suite green while `subagents.json` silently stops working. applySettings({ fallbackSubagent: "none" }, appliers); expect(appliers.setFallbackSubagent).toHaveBeenCalledWith("none"); }); it("applies only the fields that are present", () => { applySettings({ maxConcurrent: 4, graceTurns: 3, maxSubagentDepth: 1 }, appliers); expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(4); expect(appliers.setGraceTurns).toHaveBeenCalledWith(3); expect(appliers.setMaxSubagentDepth).toHaveBeenCalledWith(1); expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled(); expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled(); expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled(); expect(appliers.setScopeModels).not.toHaveBeenCalled(); }); it("applies all fields when all are present", () => { applySettings( { maxConcurrent: 8, defaultMaxTurns: 50, graceTurns: 7, defaultJoinMode: "group", schedulingEnabled: false, scopeModels: true, disableDefaultAgents: true, toolDescriptionMode: "compact", fleetView: false, widgetMode: "off", }, appliers, ); expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(8); expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(50); expect(appliers.setGraceTurns).toHaveBeenCalledWith(7); expect(appliers.setDefaultJoinMode).toHaveBeenCalledWith("group"); expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(false); expect(appliers.setScopeModels).toHaveBeenCalledWith(true); expect(appliers.setStrictAgentFiles).not.toHaveBeenCalled(); // absent from this snapshot expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(true); expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("compact"); expect(appliers.setFleetView).toHaveBeenCalledWith(false); expect(appliers.setWidgetMode).toHaveBeenCalledWith("off"); }); it("applies strictAgentFiles; skips it when absent", () => { applySettings({ strictAgentFiles: true }, appliers); expect(appliers.setStrictAgentFiles).toHaveBeenCalledWith(true); applySettings({}, appliers); expect(appliers.setStrictAgentFiles).toHaveBeenCalledTimes(1); }); it("applies widgetMode; skips it when absent", () => { applySettings({ widgetMode: "off" }, appliers); expect(appliers.setWidgetMode).toHaveBeenCalledWith("off"); applySettings({}, appliers); expect(appliers.setWidgetMode).toHaveBeenCalledTimes(1); // absence is "use default" }); it("applies fleetView (true and false); skips it when absent", () => { applySettings({ fleetView: true }, appliers); expect(appliers.setFleetView).toHaveBeenCalledWith(true); applySettings({}, appliers); expect(appliers.setFleetView).toHaveBeenCalledTimes(1); // absence is "use default" }); it("applies agentMentions; skips it when absent", () => { applySettings({ agentMentions: "direct" }, appliers); expect(appliers.setAgentMentions).toHaveBeenCalledWith("direct"); applySettings({}, appliers); expect(appliers.setAgentMentions).toHaveBeenCalledTimes(1); // absence is "use default" }); it("applies rememberAgents; skips it when absent", () => { applySettings({ rememberAgents: false }, appliers); expect(appliers.setRememberAgents).toHaveBeenCalledWith(false); applySettings({}, appliers); expect(appliers.setRememberAgents).toHaveBeenCalledTimes(1); // absence is "use default" }); it("applies scopeModels: false", () => { applySettings({ scopeModels: false }, appliers); expect(appliers.setScopeModels).toHaveBeenCalledWith(false); }); it("applies disableDefaultAgents: false", () => { applySettings({ disableDefaultAgents: false }, appliers); expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(false); }); it("applies toolDescriptionMode", () => { applySettings({ toolDescriptionMode: "full" }, appliers); expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("full"); }); it("applies outputTranscript (both true and false)", () => { applySettings({ outputTranscript: false }, appliers); expect(appliers.setOutputTranscript).toHaveBeenCalledWith(false); applySettings({ outputTranscript: true }, appliers); expect(appliers.setOutputTranscript).toHaveBeenCalledWith(true); }); it("applies worktreeIsolation (both true and false)", () => { applySettings({ worktreeIsolation: false }, appliers); expect(appliers.setWorktreeIsolation).toHaveBeenCalledWith(false); applySettings({ worktreeIsolation: true }, appliers); expect(appliers.setWorktreeIsolation).toHaveBeenCalledWith(true); }); it("applies defaultMaxTurns: 0 as the explicit unlimited marker", () => { applySettings({ defaultMaxTurns: 0 }, appliers); expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(0); }); it("calls setBackgroundByDefault with either boolean", () => { applySettings({ backgroundByDefault: false }, appliers); expect(appliers.setBackgroundByDefault).toHaveBeenCalledWith(false); applySettings({ backgroundByDefault: true }, appliers); expect(appliers.setBackgroundByDefault).toHaveBeenCalledWith(true); }); // Absence must leave the in-memory default (background) alone — calling // the applier with `undefined` would read as foreground at the spawn site. it("does not call setBackgroundByDefault when the field is absent", () => { applySettings({ maxConcurrent: 4 }, appliers); expect(appliers.setBackgroundByDefault).not.toHaveBeenCalled(); }); // Wiring tests for the master switch — ensures the schedulingEnabled // field flows from the parsed settings into the applier callback that // sets the in-memory flag in index.ts. it("calls setSchedulingEnabled(true) when schedulingEnabled is true", () => { applySettings({ schedulingEnabled: true }, appliers); expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(true); }); it("calls setSchedulingEnabled(false) when schedulingEnabled is false", () => { applySettings({ schedulingEnabled: false }, appliers); expect(appliers.setSchedulingEnabled).toHaveBeenCalledWith(false); }); // Absence preserves the in-memory default — the applier must NOT be // called, otherwise loading a settings file without the field would // overwrite the runtime default with `undefined`. it("does not call setSchedulingEnabled when the field is absent", () => { applySettings({ maxConcurrent: 4 }, appliers); expect(appliers.setSchedulingEnabled).not.toHaveBeenCalled(); }); }); describe("persistToastFor", () => { it("returns info-level toast with the plain message on success", () => { expect(persistToastFor("Max concurrency set to 7", true)).toEqual({ message: "Max concurrency set to 7", level: "info", }); }); it("returns warning-level toast with session-only suffix on failure", () => { expect(persistToastFor("Max concurrency set to 7", false)).toEqual({ message: "Max concurrency set to 7 (session only; failed to persist)", level: "warning", }); }); }); describe("applyAndEmitLoaded", () => { let appliers: SettingsAppliers; beforeEach(() => { appliers = { setMaxConcurrent: vi.fn(), setDefaultMaxTurns: vi.fn(), setGraceTurns: vi.fn(), setDefaultJoinMode: vi.fn(), setBackgroundByDefault: vi.fn(), setSchedulingEnabled: vi.fn(), setScopeModels: vi.fn(), setStrictAgentFiles: vi.fn(), setDisableDefaultAgents: vi.fn(), setToolDescriptionMode: vi.fn(), setFleetView: vi.fn(), setAgentMentions: vi.fn(), setRememberAgents: vi.fn(), setWidgetMode: vi.fn(), setOutputTranscript: vi.fn(), setWorktreeIsolation: vi.fn(), setMaxSubagentDepth: vi.fn(), setFallbackSubagent: vi.fn(), setReportUsage: vi.fn(), setShowCost: vi.fn(), }; }); it("loads, applies, and emits subagents:settings_loaded with merged settings", () => { writeGlobal({ maxConcurrent: 16 }); writeProject({ graceTurns: 7 }); const emit = vi.fn(); const result = applyAndEmitLoaded(appliers, emit, projectDir); expect(appliers.setMaxConcurrent).toHaveBeenCalledWith(16); expect(appliers.setGraceTurns).toHaveBeenCalledWith(7); expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled(); expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled(); expect(emit).toHaveBeenCalledTimes(1); expect(emit).toHaveBeenCalledWith("subagents:settings_loaded", { settings: { maxConcurrent: 16, graceTurns: 7 }, }); expect(result).toEqual({ maxConcurrent: 16, graceTurns: 7 }); }); it("still emits the event when both files are missing (payload carries {})", () => { const emit = vi.fn(); const result = applyAndEmitLoaded(appliers, emit, projectDir); expect(emit).toHaveBeenCalledWith("subagents:settings_loaded", { settings: {} }); expect(result).toEqual({}); // No setters fired — defaults preserved expect(appliers.setMaxConcurrent).not.toHaveBeenCalled(); expect(appliers.setDefaultMaxTurns).not.toHaveBeenCalled(); expect(appliers.setGraceTurns).not.toHaveBeenCalled(); expect(appliers.setDefaultJoinMode).not.toHaveBeenCalled(); }); }); describe("saveAndEmitChanged", () => { it("persists, emits with persisted=true, and returns info toast on success", () => { const emit = vi.fn(); const snapshot = { maxConcurrent: 5, graceTurns: 2 }; const toast = saveAndEmitChanged(snapshot, "Max concurrency set to 5", emit, projectDir); expect(emit).toHaveBeenCalledTimes(1); expect(emit).toHaveBeenCalledWith("subagents:settings_changed", { settings: snapshot, persisted: true, }); expect(toast).toEqual({ message: "Max concurrency set to 5", level: "info" }); // File actually written expect(JSON.parse(readFileSync(projectFile(), "utf-8"))).toEqual(snapshot); }); it("emits with persisted=false and returns warning toast on save failure", () => { const filePosingAsCwd = join(tmpdir(), `pi-settings-notdir-${Date.now()}`); writeFileSync(filePosingAsCwd, ""); const emit = vi.fn(); try { const toast = saveAndEmitChanged( { maxConcurrent: 5 }, "Max concurrency set to 5", emit, filePosingAsCwd, ); expect(emit).toHaveBeenCalledWith("subagents:settings_changed", { settings: { maxConcurrent: 5 }, persisted: false, }); expect(toast).toEqual({ message: "Max concurrency set to 5 (session only; failed to persist)", level: "warning", }); } finally { rmSync(filePosingAsCwd, { force: true }); } }); }); });