mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
812 lines
34 KiB
TypeScript
812 lines
34 KiB
TypeScript
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 `<projectDir>/.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 <cwd>/.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<string, unknown>).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 });
|
|
}
|
|
});
|
|
});
|
|
});
|