import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "../src/enabled-models.js"; /** Mock models matching typical registry shape. */ const MODELS = [ { id: "gemma-4-31b-it", name: "Gemma 4 31B", provider: "google" }, { id: "claude-opus-4-6", name: "Claude Opus 4.6", provider: "anthropic" }, { id: "claude-opus-4-5", name: "Claude Opus 4.5", provider: "anthropic" }, { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", provider: "anthropic" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic" }, ]; function makeRegistry(models = MODELS, available?: typeof MODELS): ModelRegistryRef { return { getAll() { return models; }, getAvailable: available ? () => available : undefined, }; } describe("readEnabledModels", () => { let agentDir: string; let projectDir: string; let originalEnv: string | undefined; const projectFile = () => join(projectDir, ".pi", "settings.json"); const globalFile = () => join(agentDir, "settings.json"); beforeEach(() => { agentDir = mkdtempSync(join(tmpdir(), "pi-em-global-")); projectDir = mkdtempSync(join(tmpdir(), "pi-em-project-")); originalEnv = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = agentDir; }); afterEach(() => { if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = originalEnv; rmSync(agentDir, { recursive: true, force: true }); rmSync(projectDir, { recursive: true, force: true }); }); function writeProject(obj: unknown) { mkdirSync(join(projectDir, ".pi"), { recursive: true }); writeFileSync(projectFile(), JSON.stringify(obj)); } it("returns undefined when both settings files are missing", () => { expect(readEnabledModels(projectDir)).toBeUndefined(); }); it("returns undefined when field absent from both files", () => { writeFileSync(globalFile(), JSON.stringify({ defaultProvider: "openai" })); expect(readEnabledModels(projectDir)).toBeUndefined(); }); it("returns enabledModels from global when project file absent", () => { writeFileSync(globalFile(), JSON.stringify({ enabledModels: ["anthropic/claude-sonnet-4-6", "google/gemma-4-31b-it"], })); expect(readEnabledModels(projectDir)).toEqual([ "anthropic/claude-sonnet-4-6", "google/gemma-4-31b-it", ]); }); it("returns enabledModels from project when global file absent", () => { writeProject({ enabledModels: ["anthropic/claude-haiku-4-5"] }); expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-haiku-4-5"]); }); it("project overrides global (array replaces wholly, mirrors pi's deep-merge)", () => { writeFileSync(globalFile(), JSON.stringify({ enabledModels: ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"], })); writeProject({ enabledModels: ["anthropic/claude-haiku-4-5"] }); // Project replaces wholly — globals NOT merged in expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-haiku-4-5"]); }); it("falls back to global when project file has no enabledModels field", () => { writeFileSync(globalFile(), JSON.stringify({ enabledModels: ["anthropic/claude-sonnet-4-6"], })); writeProject({ defaultProvider: "anthropic" }); // project exists but no enabledModels expect(readEnabledModels(projectDir)).toEqual(["anthropic/claude-sonnet-4-6"]); }); it("returns undefined when global JSON is corrupt (try/catch swallow)", () => { writeFileSync(globalFile(), "not json {{{"); expect(readEnabledModels(projectDir)).toBeUndefined(); }); it("returns undefined when enabledModels is not an array (global)", () => { writeFileSync(globalFile(), JSON.stringify({ enabledModels: "anthropic/claude-sonnet-4-6" })); expect(readEnabledModels(projectDir)).toBeUndefined(); }); it("returns undefined when enabledModels is not an array (project)", () => { writeProject({ enabledModels: "anthropic/claude-haiku-4-5" }); // Project's non-array enabledModels is invalid → falls back to global; global empty → undefined expect(readEnabledModels(projectDir)).toBeUndefined(); }); }); describe("resolveEnabledModels", () => { it("returns undefined for empty patterns", () => { expect(resolveEnabledModels([], makeRegistry())).toBeUndefined(); expect(resolveEnabledModels(undefined, makeRegistry())).toBeUndefined(); }); it("returns undefined when no matches", () => { expect(resolveEnabledModels(["nonexistent/foo"], makeRegistry())).toBeUndefined(); }); it("skips empty string patterns", () => { const result = resolveEnabledModels(["", "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"], makeRegistry()); // Empty string should not match — only exact patterns should match expect(result!.size).toBe(2); }); it("skips whitespace-only patterns", () => { const result = resolveEnabledModels([" ", "google/gemma-4-31b-it"], makeRegistry()); expect(result).toEqual(new Set(["google/gemma-4-31b-it"])); }); it("returns undefined when getAvailable returns empty array", () => { const result = resolveEnabledModels( ["anthropic/claude-haiku-4-5"], makeRegistry(MODELS, []), ); expect(result).toBeUndefined(); }); it("deduplicates duplicate patterns", () => { const result = resolveEnabledModels( ["anthropic/claude-haiku-4-5", "anthropic/claude-haiku-4-5"], makeRegistry(), ); expect(result!.size).toBe(1); // duplicate resolves to one entry }); describe("exact provider/modelId", () => { it("resolves exact match (key stored lowercase)", () => { const result = resolveEnabledModels(["google/gemma-4-31b-it"], makeRegistry()); expect(result).toEqual(new Set(["google/gemma-4-31b-it"])); }); it("resolves model id with colon (part of id, not split)", () => { const result = resolveEnabledModels( ["anthropic/claude-opus-4-6"], makeRegistry(), ); expect(result).toEqual(new Set(["anthropic/claude-opus-4-6"])); }); it("is case-insensitive", () => { const result = resolveEnabledModels(["GOOGLE/GEMMA-4-31B-IT"], makeRegistry()); expect(result).toEqual(new Set(["google/gemma-4-31b-it"])); }); }); describe("no bare modelId or fuzzy matching", () => { it("returns undefined for bare id (pi always writes provider/modelId)", () => { const result = resolveEnabledModels(["gemma-4-31b-it"], makeRegistry()); expect(result).toBeUndefined(); }); it("returns undefined for bare substring patterns", () => { const result = resolveEnabledModels(["Opus"], makeRegistry()); expect(result).toBeUndefined(); }); }); describe("mixed patterns", () => { it("combines multiple exact provider/modelId in one call", () => { const result = resolveEnabledModels( ["google/gemma-4-31b-it", "anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6"], makeRegistry(), ); expect(result!.has("google/gemma-4-31b-it".toLowerCase())).toBe(true); expect(result!.has("anthropic/claude-haiku-4-5".toLowerCase())).toBe(true); expect(result!.has("anthropic/claude-sonnet-4-6".toLowerCase())).toBe(true); expect(result!.has("google/gemini-2.5-pro".toLowerCase())).toBe(false); expect(result!.has("anthropic/claude-opus-4-6".toLowerCase())).toBe(false); }); }); describe("getAvailable filtering", () => { it("resolves only against available models when getAvailable present", () => { const available = [MODELS[0], MODELS[3]]; // google + haiku only const result = resolveEnabledModels( ["anthropic/claude-haiku-4-5", "anthropic/claude-sonnet-4-6", "google/gemma-4-31b-it"], makeRegistry(MODELS, available), ); // haiku and google are available; sonnet is not expect(result!.has("anthropic/claude-haiku-4-5".toLowerCase())).toBe(true); expect(result!.has("anthropic/claude-sonnet-4-6".toLowerCase())).toBe(false); // not available expect(result!.has("google/gemma-4-31b-it".toLowerCase())).toBe(true); }); }); });