import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readFileSync } from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; const refreshAnthropicTokenMock = vi.fn(); const refreshGitHubCopilotTokenMock = vi.fn(); vi.mock("@earendil-works/pi-ai/oauth", () => ({ refreshAnthropicToken: (...args: unknown[]) => refreshAnthropicTokenMock(...args), refreshGitHubCopilotToken: (...args: unknown[]) => refreshGitHubCopilotTokenMock(...args), })); const tempDirs: string[] = []; function makeTempDir(): string { const dir = mkdtempSync(join(tmpdir(), "pi-pi-flant-infra-")); tempDirs.push(dir); return dir; } // Item 8: durable flant policy now lives in scoped config, not the cache file. function writeGlobalFlantConfig(agentDir: string, flant: Record) { const cfgDir = join(agentDir, "extensions", "pp"); mkdirSync(cfgDir, { recursive: true }); writeFileSync(join(cfgDir, "config.json"), JSON.stringify({ flant }), "utf-8"); } async function loadFlantInfraModule(agentDir: string) { process.env.PI_CODING_AGENT_DIR = agentDir; vi.resetModules(); return import("./flant-infra.js"); } function collectModelSpecs(value: unknown): string[] { if (!value || typeof value !== "object") return []; const out: string[] = []; const walk = (node: any) => { if (!node || typeof node !== "object") return; if (typeof node.model === "string") out.push(node.model); for (const nested of Object.values(node)) { walk(nested); } }; walk(value); return out; } afterEach(() => { refreshAnthropicTokenMock.mockReset(); refreshGitHubCopilotTokenMock.mockReset(); delete process.env.PI_CODING_AGENT_DIR; delete process.env.COPILOT_GITHUB_TOKEN; for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); describe("flant-infra", () => { it("registerFlantProviders preserves foreign (copilot/native) catalog specs", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const registry = await import("./model-registry.js"); const pi = { registerProvider: vi.fn(), unregisterProvider: vi.fn() } as any; registry.updateRegistryFromAvailableModels(["github-copilot/claude-opus-4.5", "anthropic/claude-opus-4-8"]); mod.registerFlantProviders(pi, ["sub/claude-opus-4-8", "gpt-5"], {}); const specs = registry.listRegisteredSpecs(); expect(specs).toContain("github-copilot/claude-opus-4.5"); expect(specs).toContain("anthropic/claude-opus-4-8"); expect(specs).toContain("pp-flant-openai/gpt-5"); registry.updateRegistryFromAvailableModels([]); }); it("registerFlantProviders is idempotent across repeated calls", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; const models = ["claude-opus-4-6", "gpt-5"]; mod.registerFlantProviders(pi, models, {}); mod.registerFlantProviders(pi, models, {}); expect(registered.size).toBe(1); expect([...registered.keys()]).toEqual(["pp-flant-openai"]); }); it("asks the gateway to pin a session to one upstream", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["gpt-5"], {}); expect(registered.get("pp-flant-openai").models[0].compat).toMatchObject({ sendSessionAffinityHeaders: true, sessionAffinityFormat: "openrouter", }); }); it("registers regular providers with the resolved gateway key", async () => { const dir = makeTempDir(); const previousLlmKey = process.env.LLM_API_KEY; const previousFlantKey = process.env.FLANT_API_KEY; process.env.LLM_API_KEY = "llm-gateway-key"; process.env.FLANT_API_KEY = "flant-gateway-key"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["claude-opus-4-8", "gpt-5"], {}, { subscription: false }); expect(registered.has("pp-flant-anthropic")).toBe(false); expect(registered.get("pp-flant-openai").apiKey).toBe("llm-gateway-key"); } finally { if (previousLlmKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = previousLlmKey; if (previousFlantKey === undefined) delete process.env.FLANT_API_KEY; else process.env.FLANT_API_KEY = previousFlantKey; } }); it("does not register the sub provider when subscription is disabled", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["claude-opus-4-8", "gpt-5"], {}, { subscription: false }); expect([...registered.keys()]).toEqual(["pp-flant-openai"]); }); it("registers the sub provider with sub/ models when subscription enabled and credentials present", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["claude-opus-4-8", "claude-haiku-4-5", "gpt-5"], {}, { subscription: true }); expect([...registered.keys()].sort()).toEqual(["pp-flant-anthropic-sub", "pp-flant-openai"]); const sub = registered.get("pp-flant-anthropic-sub"); expect(sub.api).toBe("anthropic-messages"); expect(sub.baseUrl).toBe("https://llm-api.flant.ru"); expect(sub.apiKey).toBe("sk-ant-oat01-test-token"); expect(sub.headers["x-litellm-api-key"]).toBe("Bearer sk-gateway-test"); expect(sub.models.map((m: any) => m.id).sort()).toEqual(["sub/claude-haiku-4-5", "sub/claude-opus-4-8"]); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("registers every catalog Claude id on the sub provider and keeps sub/ ids off the openai provider", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders( pi, ["claude-opus-4-8", "claude-fable-5", "sub/claude-opus-4-8", "gpt-5"], {}, { subscription: true }, ); const sub = registered.get("pp-flant-anthropic-sub"); expect(sub.models.map((m: any) => m.id).sort()).toEqual(["sub/claude-fable-5", "sub/claude-opus-4-8"]); expect(registered.has("pp-flant-anthropic")).toBe(false); const openai = registered.get("pp-flant-openai"); expect(openai.models.map((m: any) => m.id)).toEqual(["gpt-5"]); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("registers sub models with their real context window instead of the fallback", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders( pi, ["sub/claude-fable-5-1"], { "claude-fable-5-1": { name: "Claude Fable 5.1", context_length: 1_000_000, max_completion_tokens: 128_000, pricing: { prompt: 0, completion: 0, cacheRead: 0, cacheWrite: 0 }, modality: "text", }, }, { subscription: true }, ); const sub = registered.get("pp-flant-anthropic-sub"); expect(sub.models[0]).toMatchObject({ id: "sub/claude-fable-5-1", contextWindow: 1_000_000, maxTokens: 128_000 }); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("takes the window from pi's catalog when the gateway's metadata has none", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); mod.setModelRegistry({ getApiKeyForProvider: async () => undefined, find: (provider: string, modelId: string) => provider === "anthropic" && modelId === "claude-opus-5" ? { contextWindow: 1_000_000, maxTokens: 128_000 } : undefined, }); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["sub/claude-opus-5"], {}, { subscription: true }); expect(registered.get("pp-flant-anthropic-sub").models[0]).toMatchObject({ id: "sub/claude-opus-5", contextWindow: 1_000_000, maxTokens: 128_000, }); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("carries pi's claude capabilities onto the sub models, minus the unusable fallback list", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); mod.setModelRegistry({ getApiKeyForProvider: async () => undefined, find: (provider: string, modelId: string) => provider === "anthropic" && modelId === "claude-opus-5" ? { compat: { forceAdaptiveThinking: true, supportsTemperature: false, supportsMidConvoEffort: true, allowedFallbackModels: [{ provider: "anthropic", model: "claude-opus-4-8" }], }, thinkingLevelMap: { off: null, xhigh: "xhigh", max: "max" }, } : undefined, }); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["sub/claude-opus-5"], {}, { subscription: true }); const model = registered.get("pp-flant-anthropic-sub").models[0]; expect(model.compat).toEqual({ forceAdaptiveThinking: true, supportsTemperature: false, supportsMidConvoEffort: true }); expect(model.compat).not.toHaveProperty("allowedFallbackModels"); expect(model.thinkingLevelMap).toEqual({ off: null, xhigh: "xhigh", max: "max" }); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("omits compat entirely when the only inherited setting is the unusable fallback list", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); mod.setModelRegistry({ getApiKeyForProvider: async () => undefined, find: (provider: string, modelId: string) => provider === "anthropic" && modelId === "claude-fable-5-1" ? { compat: { allowedFallbackModels: [{ provider: "anthropic", model: "claude-opus-4-8" }] } } : undefined, }); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["sub/claude-fable-5-1"], {}, { subscription: true }); const model = registered.get("pp-flant-anthropic-sub").models[0]; expect(model.id).toBe("sub/claude-fable-5-1"); expect(model).not.toHaveProperty("compat"); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("leaves sub models untouched when pi's catalog does not know the claude id", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); mod.setModelRegistry({ getApiKeyForProvider: async () => undefined, find: () => undefined }); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["sub/claude-unreleased-9"], {}, { subscription: true }); const model = registered.get("pp-flant-anthropic-sub").models[0]; expect(model.id).toBe("sub/claude-unreleased-9"); expect(model.compat).toBeUndefined(); expect(model.thinkingLevelMap).toBeUndefined(); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("falls back to all claude models for the sub provider when the model list has no sub/ ids", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-test-token", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["claude-opus-4-8", "claude-haiku-4-5", "gpt-5"], {}, { subscription: true }); const sub = registered.get("pp-flant-anthropic-sub"); expect(sub.models.map((m: any) => m.id).sort()).toEqual(["sub/claude-haiku-4-5", "sub/claude-opus-4-8"]); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("generateFlantConfig emits session-first main and bounded worker routing", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const config = mod.generateFlantConfig( ["sub/claude-fable-5", "sub/claude-opus-4-9", "gpt-5.6-sol", "gpt-5.6-sol-pro", "gemini-3-1-pro"], true, ) as any; expect(config.agents.main.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-9"); expect(config.agents.subagents.simple.task.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-9"); expect(config.agents.subagents.pools.advisors[0].model).toBe("pp-flant-openai/gpt-5.6-sol-pro"); expect(config.agents.subagents.pools.advisors[1].model).toBe("pp-flant-anthropic-sub/sub/claude-fable-5"); expect(config.agents.subagents.pools.reviewers[0].model).toBe("pp-flant-openai/gpt-5.6-sol-pro"); expect(config.agents.subagents.presetGroups).toBeUndefined(); expect(config.agents.orchestrators).toBeUndefined(); }); it("routes all catalog Claude models through sub when the subscription is active", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const mixed = mod.generateFlantConfig(["claude-opus-4-9", "sub/claude-opus-4-8", "gpt-5.6-sol"], true) as any; expect(mixed.agents.main.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-9"); }); it("skips the sub provider when subscription enabled but OAuth token missing", async () => { const dir = makeTempDir(); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: unknown) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["claude-opus-4-8"], {}, { subscription: true }); expect(registered.has("pp-flant-anthropic-sub")).toBe(false); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); // pi restores the session's model from what extensions registered at load // time, before anything can refresh the token. Skipping registration there // drops every sub/ model from the catalog and the session lands on an // unrelated model with a "could not restore" warning. it("registers the sub provider with an expired token so the catalog is complete", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-expired", refresh: "rt", expires: Date.now() - 1000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["claude-opus-4-8"], {}, { subscription: true }); const sub = registered.get("pp-flant-anthropic-sub"); expect(sub.apiKey).toBe("sk-ant-oat01-expired"); expect(sub.models.map((m: any) => m.id)).toEqual(["sub/claude-opus-4-8"]); // The stale registration is replaced as soon as a refresh succeeds. refreshAnthropicTokenMock.mockResolvedValueOnce({ access: "sk-ant-oat01-rotated", refresh: "rt2", expires: Date.now() + 3_600_000 }); await mod.refreshSubProvider(pi); expect(registered.get("pp-flant-anthropic-sub").apiKey).toBe("sk-ant-oat01-rotated"); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("refreshSubProvider re-registers the sub provider when the token changes", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); const authPath = join(dir, "auth.json"); // Start with an expired token that has a refresh token available. writeFileSync( authPath, JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; // Refresh yields a fresh token, which is what the initial registration reads. refreshAnthropicTokenMock.mockResolvedValueOnce({ access: "sk-ant-oat01-fresh", refresh: "rt-fresh", expires: Date.now() + 3_600_000 }); await mod.refreshClaudeOAuthToken(); mod.registerFlantProviders(pi, ["claude-opus-4-8"], {}, { subscription: true }); expect(registered.get("pp-flant-anthropic-sub").apiKey).toBe("sk-ant-oat01-fresh"); // A no-op refresh (token unchanged) must not re-register. const callsBefore = pi.registerProvider.mock.calls.length; await mod.refreshSubProvider(pi); expect(pi.registerProvider.mock.calls.length).toBe(callsBefore); // Simulate the token expiring and a refresh minting a new one: the sub // provider is re-registered with the new token. writeFileSync( authPath, JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-expired", refresh: "rt-fresh", expires: Date.now() - 1000 } }), "utf-8", ); refreshAnthropicTokenMock.mockResolvedValueOnce({ access: "sk-ant-oat01-rotated", refresh: "rt-rotated", expires: Date.now() + 3_600_000 }); await mod.refreshSubProvider(pi); expect(registered.get("pp-flant-anthropic-sub").apiKey).toBe("sk-ant-oat01-rotated"); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("refreshSubProvider unregisters the sub provider when the token is gone", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); const authPath = join(dir, "auth.json"); writeFileSync( authPath, JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-live", refresh: "rt", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const registered = new Map(); const pi = { registerProvider: vi.fn((name: string, config: any) => registered.set(name, config)), unregisterProvider: vi.fn((name: string) => registered.delete(name)), } as any; mod.registerFlantProviders(pi, ["claude-opus-4-8"], {}, { subscription: true }); expect(registered.has("pp-flant-anthropic-sub")).toBe(true); // Credentials revoked: the refresh cannot mint a token any more. writeFileSync(authPath, JSON.stringify({}), "utf-8"); await mod.refreshSubProvider(pi); // Leaving it registered would keep the now-dead token as a literal apiKey. expect(registered.has("pp-flant-anthropic-sub")).toBe(false); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("refreshSubProvider is a no-op when subscription routing is inactive", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const pi = { registerProvider: vi.fn(), unregisterProvider: vi.fn(), } as any; // No registerFlantProviders({ subscription: true }) call happened, so there // is no cached sub-provider context: refresh must do nothing. await mod.refreshSubProvider(pi); expect(pi.registerProvider).not.toHaveBeenCalled(); }); it("readClaudeOAuthToken returns null for expired token", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { access: "sk-ant-oat01-old", expires: Date.now() - 1000 } }), "utf-8", ); const mod = await loadFlantInfraModule(dir); expect(mod.readClaudeOAuthToken()).toBeNull(); }); it("refreshClaudeOAuthToken returns the current token when not expired", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-fresh", refresh: "rt", expires: Date.now() + 3_600_000 } }), "utf-8", ); const mod = await loadFlantInfraModule(dir); await expect(mod.refreshClaudeOAuthToken()).resolves.toBe("sk-ant-oat01-fresh"); expect(refreshAnthropicTokenMock).not.toHaveBeenCalled(); }); it("refreshClaudeOAuthToken refreshes an expired token and persists it", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); const authPath = join(dir, "auth.json"); writeFileSync( authPath, JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }), "utf-8", ); const newExpires = Date.now() + 3_600_000; refreshAnthropicTokenMock.mockResolvedValue({ access: "sk-ant-oat01-new", refresh: "rt-new", expires: newExpires }); const mod = await loadFlantInfraModule(dir); await expect(mod.refreshClaudeOAuthToken()).resolves.toBe("sk-ant-oat01-new"); expect(refreshAnthropicTokenMock).toHaveBeenCalledWith("rt-old"); const persisted = JSON.parse(readFileSync(authPath, "utf-8")); expect(persisted.anthropic).toMatchObject({ type: "oauth", access: "sk-ant-oat01-new", refresh: "rt-new", expires: newExpires, }); // A subsequent synchronous read now sees the fresh token. expect(mod.readClaudeOAuthToken()).toBe("sk-ant-oat01-new"); }); it("refreshClaudeOAuthToken returns null when expired and no refresh token present", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", expires: Date.now() - 1000 } }), "utf-8", ); const mod = await loadFlantInfraModule(dir); await expect(mod.refreshClaudeOAuthToken()).resolves.toBeNull(); expect(refreshAnthropicTokenMock).not.toHaveBeenCalled(); }); it("refreshClaudeOAuthToken returns null when refresh fails", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { type: "oauth", access: "sk-ant-oat01-old", refresh: "rt-old", expires: Date.now() - 1000 } }), "utf-8", ); refreshAnthropicTokenMock.mockRejectedValue(new Error("refresh boom")); const mod = await loadFlantInfraModule(dir); await expect(mod.refreshClaudeOAuthToken()).resolves.toBeNull(); }); it("generateDisplayName formats model ids", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); expect(mod.generateDisplayName("claude-opus-4-6")).toBe("Claude Opus 4.6"); expect(mod.generateDisplayName("gpt-5-4-mini")).toBe("GPT 5.4 Mini"); expect(mod.generateDisplayName("o3-mini")).toBe("O3 Mini"); expect(mod.generateDisplayName("qwen-3-coder")).toBe("Qwen 3 Coder"); expect(mod.generateDisplayName("api-ai-2")).toBe("API AI 2"); }); it("generateFlantConfig creates config with mapped model specs", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const config = mod.generateFlantConfig([ "claude-opus-4-6", "gpt-5-4", "gpt-5-4-mini", "gemini-3-1-pro", "gemini-3-1-flash", "deepseek-v3", "grok-4", ], true) as any; expect(config.agents.main.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-6"); expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gpt-5-4-mini"); expect(config.agents.subagents.simple.explore.thinking).toBe("medium"); expect(config.agents.subagents.simple.librarian.model).toBe("pp-flant-openai/gpt-5-4-mini"); expect(config.agents.subagents.simple.librarian.thinking).toBe("medium"); expect(config.agents.subagents.pools.advisors[0].model).toBe("pp-flant-openai/gpt-5-4"); }); it("generateFlantConfig gives every pool the same astra-pro + fable pair at high", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const config = mod.generateFlantConfig( ["sub/claude-fable-5-1", "sub/claude-fable-5", "sub/claude-opus-5", "gpt-5.6-sol", "gpt-5.6-sol-pro", "gpt-6-astra", "gpt-6-astra-pro"], true, ) as any; const pools = config.agents.subagents.pools; for (const key of ["advisors", "reviewers", "deepDebuggers"]) { expect(pools[key]).toEqual([ { enabled: true, model: "pp-flant-openai/gpt-6-astra-pro", thinking: "high" }, { enabled: true, model: "pp-flant-anthropic-sub/sub/claude-fable-5-1", thinking: "high" }, ]); } // Astra is reserved for the pools: the main-line roles stay on their own // pickers rather than following the top-end SKU. expect(config.agents.main.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-5"); }); it("generateFlantConfig degrades the pool gpt slot to sol-pro when astra is absent", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const config = mod.generateFlantConfig(["sub/claude-fable-5", "gpt-5.6-sol", "gpt-5.6-sol-pro"], true) as any; expect(config.agents.subagents.pools.reviewers[0].model).toBe("pp-flant-openai/gpt-5.6-sol-pro"); expect(config.agents.subagents.pools.deepDebuggers[0].model).toBe("pp-flant-openai/gpt-5.6-sol-pro"); }); it("generateFlantConfig routes Claude roles through subs when subscription active", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const config = mod.generateFlantConfig( ["claude-opus-4-8", "claude-haiku-4-5", "gpt-5-4", "gpt-5.6-luna", "gemini-3-1-pro", "gemini-3-1-flash"], true, ) as any; // Claude roles -> sub provider expect(config.agents.main.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8"); expect(config.agents.subagents.simple.task.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-8"); expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gpt-5.6-luna"); expect(config.agents.subagents.simple.explore.thinking).toBe("medium"); expect(config.agents.subagents.pools.reviewers[0].model).toBe("pp-flant-openai/gpt-5-4"); }); it("generateFlantConfig excludes Claude entirely when the subscription is inactive", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const config = mod.generateFlantConfig(["claude-opus-4-8", "gpt-5-4"], false) as any; expect(config.agents.main.model).toBe("pp-flant-openai/gpt-5-4"); }); it("isSubscriptionActive requires flag + oauth token + gateway key", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { access: "sk-ant-oat01-test", expires: Date.now() + 3_600_000 } }), "utf-8", ); writeGlobalFlantConfig(dir, { enabled: true, subscription: true }); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); expect(mod.isSubscriptionActive()).toBe(true); expect(mod.isSubscriptionActive({ subscription: false } as any)).toBe(false); } finally { if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("activates the Copilot tier from OAuth credentials created by /login", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ "github-copilot": { type: "oauth", access: "tid=test;exp=1", refresh: "ghu_test", expires: Date.now() + 3_600_000 } }), "utf-8", ); const mod = await loadFlantInfraModule(dir); expect(mod.readCopilotOAuthToken()).toBe("tid=test;exp=1"); expect(mod.isCopilotTierActive({ copilotEnabled: true } as any)).toBe(true); }); it("refreshes expired Copilot OAuth credentials and persists them for pi", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); const authPath = join(dir, "auth.json"); writeFileSync( authPath, JSON.stringify({ "github-copilot": { type: "oauth", access: "expired", refresh: "ghu_refresh", expires: Date.now() - 1, enterpriseUrl: "github.example.com" } }), "utf-8", ); refreshGitHubCopilotTokenMock.mockResolvedValue({ access: "tid=fresh;exp=2", refresh: "ghu_refresh", expires: Date.now() + 3_600_000, enterpriseUrl: "github.example.com", }); const mod = await loadFlantInfraModule(dir); await expect(mod.refreshCopilotOAuthToken()).resolves.toBe("tid=fresh;exp=2"); expect(refreshGitHubCopilotTokenMock).toHaveBeenCalledWith("ghu_refresh", "github.example.com"); expect(JSON.parse(readFileSync(authPath, "utf-8"))["github-copilot"].access).toBe("tid=fresh;exp=2"); expect(mod.isCopilotTierActive({ copilotEnabled: true } as any)).toBe(true); }); it("keeps supporting COPILOT_GITHUB_TOKEN for the Copilot tier", async () => { const dir = makeTempDir(); process.env.COPILOT_GITHUB_TOKEN = "github_pat_test"; const mod = await loadFlantInfraModule(dir); expect(mod.isCopilotTierActive({ copilotEnabled: true } as any)).toBe(true); }); it("syncProviderTiers respects an active sub-fallback and does not re-enable flant-sub", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { access: "sk-ant-oat01-test", expires: Date.now() + 3_600_000 } }), "utf-8", ); writeGlobalFlantConfig(dir, { enabled: true, subscription: true }); const prevKey = process.env.LLM_API_KEY; process.env.LLM_API_KEY = "sk-gateway-test"; try { const mod = await loadFlantInfraModule(dir); const reg = await import("./model-registry.js"); // Subscription is active -> sync enables flant-sub. mod.syncProviderTiers(); expect(reg.getTierEnabled()["flant-sub"]).toBe(true); // A live rate-limit fallback disables flant-sub; a subsequent sync (e.g. // from toggling Copilot) must NOT clobber it back on. reg.setSubscriptionFallbackActive(true); mod.syncProviderTiers(); expect(reg.getTierEnabled()["flant-sub"]).toBe(false); // Clearing the fallback lets sync re-enable it. reg.setSubscriptionFallbackActive(false); mod.syncProviderTiers(); expect(reg.getTierEnabled()["flant-sub"]).toBe(true); } finally { const reg = await import("./model-registry.js"); reg.setSubscriptionFallbackActive(false); if (prevKey === undefined) delete process.env.LLM_API_KEY; else process.env.LLM_API_KEY = prevKey; } }); it("isSubscriptionActive is false without a gateway key", async () => { const dir = makeTempDir(); mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "auth.json"), JSON.stringify({ anthropic: { access: "sk-ant-oat01-test", expires: Date.now() + 3_600_000 } }), "utf-8", ); const prevKey = process.env.LLM_API_KEY; const prevFlant = process.env.FLANT_API_KEY; delete process.env.LLM_API_KEY; delete process.env.FLANT_API_KEY; try { const mod = await loadFlantInfraModule(dir); expect(mod.isSubscriptionActive({ subscription: true } as any)).toBe(false); } finally { if (prevKey !== undefined) process.env.LLM_API_KEY = prevKey; if (prevFlant !== undefined) process.env.FLANT_API_KEY = prevFlant; } }); it("generateFlantConfig returns empty object for empty models", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); expect(mod.generateFlantConfig([])).toEqual({}); }); it("generateFlantConfig with anthropic-only models routes everything through sub", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const config = mod.generateFlantConfig(["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-3-5"], true); const specs = collectModelSpecs(config); expect(specs.length).toBeGreaterThan(0); expect(specs.every((spec) => spec.startsWith("pp-flant-anthropic-sub/sub/"))).toBe(true); expect(specs.some((spec) => spec.startsWith("pp-flant-openai/"))).toBe(false); }); it("generateFlantConfig picks latest models per family", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const config = mod.generateFlantConfig([ "claude-opus-4-5", "claude-opus-4-7", "gpt-5-3", "gpt-5-4", "gemini-3-0-pro", "gemini-3-1-pro", "gemini-3-1-flash", "gemini-3-1-flash-lite", ], true) as any; expect(config.agents.main.model).toBe("pp-flant-anthropic-sub/sub/claude-opus-4-7"); expect(config.agents.subagents.pools.advisors).toHaveLength(2); expect(config.agents.subagents.simple.explore.model).toBe("pp-flant-openai/gpt-5-4"); expect(collectModelSpecs(config).some((spec) => spec.includes("gemini"))).toBe(false); }); it("loadFlantSettings returns defaults when file is missing", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); expect(mod.loadFlantSettings()).toEqual({ enabled: false, autoUpdate: true, cacheTTLDays: 3, switchBackIntervalMinutes: 10, autoRateLimitFallback: true, copilotEnabled: false, subscription: false, lastUpdated: null, cachedFlantModels: null, cachedOpenRouterData: null, unmappedModels: null, }); }); it("loadFlantSettings composes durable config + cache fields", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); // Durable policy lives in scoped config (item 8). const cfgDir = join(dir, "extensions", "pp"); mkdirSync(cfgDir, { recursive: true }); writeFileSync(join(cfgDir, "config.json"), JSON.stringify({ flant: { enabled: true, autoUpdate: false, cacheTTLDays: 3 } }), "utf-8"); // Cache-only fields live in the cache file. const settingsDir = join(cfgDir, "cache"); mkdirSync(settingsDir, { recursive: true }); writeFileSync( join(settingsDir, "flant-models.json"), JSON.stringify({ lastUpdated: "2026-01-02T03:04:05.000Z", cachedFlantModels: ["gpt-5-4", 123, "claude-opus-4-6"], cachedOpenRouterData: { "gpt-5-4": { name: "GPT 5.4", context_length: 123, max_completion_tokens: 45, pricing: { prompt: 1, completion: 2, cacheRead: 3, cacheWrite: 4 }, modality: "text", }, }, }), "utf-8", ); expect(mod.loadFlantSettings()).toEqual({ enabled: true, autoUpdate: false, cacheTTLDays: 3, switchBackIntervalMinutes: 10, autoRateLimitFallback: true, copilotEnabled: false, subscription: false, lastUpdated: "2026-01-02T03:04:05.000Z", cachedFlantModels: ["gpt-5-4", "claude-opus-4-6"], unmappedModels: null, cachedOpenRouterData: { "gpt-5-4": { name: "GPT 5.4", context_length: 123, max_completion_tokens: 45, pricing: { prompt: 1, completion: 2, cacheRead: 3, cacheWrite: 4 }, modality: "text", }, }, }); }); it("saveFlantSettings writes ONLY cache fields; durable policy stays in config", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); // Durable policy in scoped config. const cfgDir = join(dir, "extensions", "pp"); mkdirSync(cfgDir, { recursive: true }); writeFileSync(join(cfgDir, "config.json"), JSON.stringify({ flant: { enabled: true, subscription: true, switchBackIntervalMinutes: 30, autoRateLimitFallback: false, copilotEnabled: true, autoUpdate: true, cacheTTLDays: 14 } }), "utf-8"); mod.saveFlantSettings({ enabled: true, autoUpdate: true, cacheTTLDays: 14, switchBackIntervalMinutes: 30, autoRateLimitFallback: false, copilotEnabled: true, subscription: true, lastUpdated: "2026-02-01T00:00:00.000Z", cachedFlantModels: ["claude-opus-4-6", "gpt-5-4"], unmappedModels: null, cachedOpenRouterData: { "claude-opus-4-6": { name: "Claude Opus 4.6", context_length: 200000, max_completion_tokens: 32000, pricing: { prompt: 0.01, completion: 0.02, cacheRead: 0.003, cacheWrite: 0.004 }, modality: "text", }, }, }); // Cache file exists and holds ONLY cache fields (no durable policy). const cachePath = join(cfgDir, "cache", "flant-models.json"); expect(existsSync(cachePath)).toBe(true); const cacheRaw = JSON.parse(readFileSync(cachePath, "utf-8")); expect(Object.keys(cacheRaw).sort()).toEqual(["cachedFlantModels", "cachedOpenRouterData", "lastUpdated", "unmappedModels"]); // Durable fields are read back from config, cache fields from the cache file. const loaded = mod.loadFlantSettings(); expect(loaded.subscription).toBe(true); expect(loaded.switchBackIntervalMinutes).toBe(30); expect(loaded.copilotEnabled).toBe(true); expect(loaded.lastUpdated).toBe("2026-02-01T00:00:00.000Z"); expect(loaded.cachedFlantModels).toEqual(["claude-opus-4-6", "gpt-5-4"]); }); it("wiping the cache file does not change any durable setting", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const cfgDir = join(dir, "extensions", "pp"); mkdirSync(cfgDir, { recursive: true }); writeFileSync(join(cfgDir, "config.json"), JSON.stringify({ flant: { enabled: true, subscription: true, switchBackIntervalMinutes: 30 } }), "utf-8"); const cacheDir = join(cfgDir, "cache"); mkdirSync(cacheDir, { recursive: true }); writeFileSync(join(cacheDir, "flant-models.json"), JSON.stringify({ cachedFlantModels: ["gpt-5-4"] }), "utf-8"); rmSync(join(cacheDir, "flant-models.json"), { force: true }); const loaded = mod.loadFlantSettings(); expect(loaded.enabled).toBe(true); expect(loaded.subscription).toBe(true); expect(loaded.switchBackIntervalMinutes).toBe(30); expect(loaded.cachedFlantModels).toBeNull(); }); it("switchBackIntervalMinutes comes from scoped config; defaults to 10 when unset", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const cfgDir = join(dir, "extensions", "pp"); mkdirSync(cfgDir, { recursive: true }); const cfgPath = join(cfgDir, "config.json"); // Unset -> default 10. writeFileSync(cfgPath, JSON.stringify({ flant: { enabled: true } }), "utf-8"); expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(10); // Explicit value honored. writeFileSync(cfgPath, JSON.stringify({ flant: { enabled: true, switchBackIntervalMinutes: 45 } }), "utf-8"); expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(45); }); describe("legacy flant-settings migration (item 8)", () => { function writeLegacyCache(dir: string, obj: Record) { const cacheDir = join(dir, "extensions", "pp", "cache"); mkdirSync(cacheDir, { recursive: true }); writeFileSync(join(cacheDir, "flant-models.json"), JSON.stringify(obj), "utf-8"); } function readGlobalFlant(dir: string): Record { const p = join(dir, "extensions", "pp", "config.json"); return existsSync(p) ? (JSON.parse(readFileSync(p, "utf-8")).flant ?? {}) : {}; } it("migrates genuine choices but skips a historical-default switchBackIntervalMinutes: 30, and strips durable keys from cache", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); writeLegacyCache(dir, { enabled: true, subscription: true, switchBackIntervalMinutes: 30, cachedFlantModels: ["gpt-5-4"] }); mod.migrateLegacyFlantSettings(); // enabled/subscription are genuine choices (their historical default is // false), so they still migrate. expect(readGlobalFlant(dir)).toMatchObject({ enabled: true, subscription: true }); // 30 was the v0.8.0/v0.9.0 default the legacy cache persisted verbatim; // promoting it would permanently outrank the current default of 10. expect(readGlobalFlant(dir)).not.toHaveProperty("switchBackIntervalMinutes"); // Cache file no longer holds durable fields, but keeps cache data. const cacheRaw = JSON.parse(readFileSync(join(dir, "extensions", "pp", "cache", "flant-models.json"), "utf-8")); expect(cacheRaw).not.toHaveProperty("switchBackIntervalMinutes"); expect(cacheRaw.cachedFlantModels).toEqual(["gpt-5-4"]); expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(10); }); it("skips a historical-default cacheTTLDays: 7 so the new default of 3 applies", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); writeLegacyCache(dir, { cacheTTLDays: 7 }); mod.migrateLegacyFlantSettings(); expect(readGlobalFlant(dir)).not.toHaveProperty("cacheTTLDays"); expect(mod.loadFlantSettings().cacheTTLDays).toBe(3); }); // Documented benign edge: an INVALID legacy value normalizes to the CURRENT // default (3), which is not in cacheTTLDays' historical list [7], so it is // migrated as an explicit 3. Redundant but harmless — it equals the default // it would have resolved to anyway. Not worth special-casing in the skip rule. it("migrates an invalid legacy cacheTTLDays as the current default, which is harmless", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); writeLegacyCache(dir, { cacheTTLDays: "abc" }); mod.migrateLegacyFlantSettings(); expect(readGlobalFlant(dir)).toMatchObject({ cacheTTLDays: 3 }); expect(mod.loadFlantSettings().cacheTTLDays).toBe(3); }); it("migrates a non-historical switchBackIntervalMinutes: 45", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); writeLegacyCache(dir, { switchBackIntervalMinutes: 45 }); mod.migrateLegacyFlantSettings(); expect(readGlobalFlant(dir)).toMatchObject({ switchBackIntervalMinutes: 45 }); expect(mod.loadFlantSettings().switchBackIntervalMinutes).toBe(45); }); it("compares historical defaults AFTER normalization, so a legacy string \"30\" is skipped", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); writeLegacyCache(dir, { switchBackIntervalMinutes: "30" }); mod.migrateLegacyFlantSettings(); expect(readGlobalFlant(dir)).not.toHaveProperty("switchBackIntervalMinutes"); }); it("still rewrites the cache when every durable key is skipped as historical", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); writeLegacyCache(dir, { switchBackIntervalMinutes: 30, cacheTTLDays: 7, cachedFlantModels: ["gpt-5-4"] }); mod.migrateLegacyFlantSettings(); expect(readGlobalFlant(dir)).toEqual({}); const cacheRaw = JSON.parse(readFileSync(join(dir, "extensions", "pp", "cache", "flant-models.json"), "utf-8")); expect(cacheRaw).not.toHaveProperty("switchBackIntervalMinutes"); expect(cacheRaw).not.toHaveProperty("cacheTTLDays"); expect(cacheRaw.cachedFlantModels).toEqual(["gpt-5-4"]); }); it("skips a key already set explicitly in global config", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); const cfgDir = join(dir, "extensions", "pp"); mkdirSync(cfgDir, { recursive: true }); writeFileSync(join(cfgDir, "config.json"), JSON.stringify({ flant: { switchBackIntervalMinutes: 15 } }), "utf-8"); writeLegacyCache(dir, { switchBackIntervalMinutes: 30 }); mod.migrateLegacyFlantSettings(); expect(readGlobalFlant(dir)).toMatchObject({ switchBackIntervalMinutes: 15 }); }); it("does not migrate normalization-filled defaults (only raw own-props)", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); writeLegacyCache(dir, { subscription: true }); mod.migrateLegacyFlantSettings(); const g = readGlobalFlant(dir); expect(g).toMatchObject({ subscription: true }); expect(g).not.toHaveProperty("switchBackIntervalMinutes"); expect(g).not.toHaveProperty("autoUpdate"); }); it("is idempotent (a second run makes no further change)", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); writeLegacyCache(dir, { switchBackIntervalMinutes: 30 }); mod.migrateLegacyFlantSettings(); const afterFirst = JSON.stringify(readGlobalFlant(dir)); // Re-import to reset the module-level once-guard, then re-run: the cache // no longer has durable own-props, so nothing new is migrated. const mod2 = await loadFlantInfraModule(dir); mod2.migrateLegacyFlantSettings(); expect(JSON.stringify(readGlobalFlant(dir))).toBe(afterFirst); }); it("no-ops on a missing legacy file", async () => { const dir = makeTempDir(); const mod = await loadFlantInfraModule(dir); expect(() => mod.migrateLegacyFlantSettings()).not.toThrow(); expect(existsSync(join(dir, "extensions", "pp", "config.json"))).toBe(false); }); }); });