Files
fusion/packages/core/src/global-settings.test.ts
2026-04-19 10:13:34 -07:00

742 lines
30 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { GlobalSettingsStore, defaultGlobalDir } from "./global-settings.js";
import { DEFAULT_GLOBAL_SETTINGS } from "./types.js";
import { readFile, rm, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-global-settings-test-"));
}
/** Temporarily clear VITEST to test default GlobalSettingsStore resolution. */
async function withDefaultGlobalSettingsStore<T>(
fn: (store: GlobalSettingsStore) => Promise<T>,
): Promise<T> {
const savedVitest = process.env.VITEST;
delete process.env.VITEST;
try {
const store = new GlobalSettingsStore();
return await fn(store);
} finally {
if (savedVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = savedVitest;
}
}
}
describe("GlobalSettingsStore", () => {
let dir: string;
let store: GlobalSettingsStore;
const originalHome = process.env.HOME;
beforeEach(() => {
dir = makeTmpDir();
store = new GlobalSettingsStore(dir);
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
});
describe("init()", () => {
it("creates the directory and settings.json if missing", async () => {
const nested = join(dir, "nested", "deep");
const nestedStore = new GlobalSettingsStore(nested);
const created = await nestedStore.init();
expect(created).toBe(true);
expect(existsSync(join(nested, "settings.json"))).toBe(true);
});
it("creates settings.json with defaults on first init", async () => {
await store.init();
const raw = await readFile(join(dir, "settings.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.themeMode).toBe("dark");
expect(parsed.colorTheme).toBe("default");
expect(parsed.ntfyEnabled).toBe(false);
});
it("returns false if settings.json already exists", async () => {
await store.init(); // creates file
const created = await store.init(); // second call
expect(created).toBe(false);
});
it("preserves existing settings on re-init", async () => {
await store.init();
await store.updateSettings({ themeMode: "light" });
const created = await store.init();
expect(created).toBe(false);
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
});
it("adopts the legacy ~/.pi/kb directory when ~/.fusion does not exist", async () => {
const homeDir = makeTmpDir();
process.env.HOME = homeDir;
const legacyDir = join(homeDir, ".pi", "kb");
await mkdir(legacyDir, { recursive: true });
await writeFile(
join(legacyDir, "settings.json"),
JSON.stringify({ themeMode: "light" }),
);
try {
await withDefaultGlobalSettingsStore(async (defaultStore) => {
await defaultStore.init();
expect(defaultStore.getSettingsPath()).toBe(join(defaultGlobalDir(), "settings.json"));
expect(existsSync(join(homeDir, ".fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".pi", "kb"))).toBe(false);
const settings = await defaultStore.getSettings();
expect(settings.themeMode).toBe("light");
});
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
it("adopts the legacy ~/.pi/fusion directory when ~/.fusion does not exist", async () => {
const homeDir = makeTmpDir();
process.env.HOME = homeDir;
// Create the legacy ~/.pi/fusion directory with settings
const legacyDir = join(homeDir, ".pi", "fusion");
await mkdir(legacyDir, { recursive: true });
await writeFile(
join(legacyDir, "settings.json"),
JSON.stringify({ themeMode: "light" }),
);
// Verify legacy exists and new does not
expect(existsSync(join(homeDir, ".pi", "fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".fusion"))).toBe(false);
try {
await withDefaultGlobalSettingsStore(async (defaultStore) => {
await defaultStore.init();
// Verify migration happened
expect(defaultStore.getSettingsPath()).toBe(join(defaultGlobalDir(), "settings.json"));
expect(existsSync(join(homeDir, ".fusion", "settings.json"))).toBe(true);
expect(existsSync(join(homeDir, ".pi", "fusion"))).toBe(false);
// Verify settings were preserved
const settings = await defaultStore.getSettings();
expect(settings.themeMode).toBe("light");
});
} finally {
await rm(homeDir, { recursive: true, force: true });
}
});
});
describe("getSettings()", () => {
it("returns defaults when file does not exist", async () => {
const settings = await store.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("returns persisted values merged with defaults", async () => {
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, "settings.json"),
JSON.stringify({ themeMode: "light", colorTheme: "ocean" }),
);
const settings = await store.getSettings();
expect(settings.themeMode).toBe("light");
expect(settings.colorTheme).toBe("ocean");
// Defaults are filled in for missing fields
expect(settings.ntfyEnabled).toBe(false);
expect(settings.defaultProvider).toBeUndefined();
});
it("returns defaults on invalid JSON", async () => {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "settings.json"), "not-json{{{");
const settings = await store.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
it("returns defaults when directory does not exist", async () => {
const nonExistent = new GlobalSettingsStore(join(dir, "nope", "nada"));
const settings = await nonExistent.getSettings();
expect(settings).toEqual(DEFAULT_GLOBAL_SETTINGS);
});
});
describe("updateSettings()", () => {
it("persists a partial update and returns merged settings", async () => {
await store.init();
const updated = await store.updateSettings({ themeMode: "system" });
expect(updated.themeMode).toBe("system");
expect(updated.colorTheme).toBe("default"); // unchanged default
// Verify persistence
const raw = await readFile(join(dir, "settings.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.themeMode).toBe("system");
});
it("merges multiple updates without losing fields", async () => {
await store.init();
await store.updateSettings({ defaultProvider: "anthropic", defaultModelId: "claude-sonnet-4-5" });
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
expect(settings.ntfyEnabled).toBe(true);
expect(settings.ntfyTopic).toBe("my-topic");
expect(settings.themeMode).toBe("dark"); // preserved default
});
it("creates directory if missing", async () => {
const nested = join(dir, "auto", "create");
const nestedStore = new GlobalSettingsStore(nested);
await nestedStore.updateSettings({ themeMode: "light" });
expect(existsSync(join(nested, "settings.json"))).toBe(true);
const settings = await nestedStore.getSettings();
expect(settings.themeMode).toBe("light");
});
it("can clear a field by setting it to null (null-as-delete semantics)", async () => {
await store.init();
await store.updateSettings({ defaultProvider: "anthropic" });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ defaultProvider: null });
const settings = await store.getSettings();
expect(settings.defaultProvider).toBeUndefined();
});
it("clearing ntfyTopic with null removes it from disk and returns undefined", async () => {
await store.init();
await store.updateSettings({ ntfyEnabled: true, ntfyTopic: "my-topic" });
// Verify it was persisted
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.ntfyTopic).toBe("my-topic");
// Clear the topic with null
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ ntfyTopic: null });
// Verify it was removed from disk
const rawAfter = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(rawAfter.ntfyTopic).toBeUndefined();
// Verify getSettings returns undefined
const settings = await store.getSettings();
expect(settings.ntfyTopic).toBeUndefined();
});
it("clearing ntfyDashboardHost with null removes it from disk", async () => {
await store.init();
await store.updateSettings({ ntfyDashboardHost: "https://dashboard.example.com" });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ ntfyDashboardHost: null });
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.ntfyDashboardHost).toBeUndefined();
const settings = await store.getSettings();
expect(settings.ntfyDashboardHost).toBeUndefined();
});
it("clearing ntfyEvents with null resets to default on read", async () => {
await store.init();
await store.updateSettings({ ntfyEvents: ["in-review", "failed"] });
// Verify it was persisted with custom value
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.ntfyEvents).toEqual(["in-review", "failed"]);
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ ntfyEvents: null });
// After clear, reading back gives the default value
// (either undefined on disk with default applied, or default written directly)
const settings = await store.getSettings();
expect(settings.ntfyEvents).toEqual(["in-review", "merged", "failed", "awaiting-approval", "awaiting-user-review"]);
});
it("handles concurrent updates safely via locking", async () => {
await store.init();
// Fire 10 concurrent updates
const promises = Array.from({ length: 10 }, (_, i) =>
store.updateSettings({ ntfyTopic: `topic-${i}` }),
);
await Promise.all(promises);
// The final value should be one of the submitted values (last writer wins)
const settings = await store.getSettings();
expect(settings.ntfyTopic).toMatch(/^topic-\d$/);
});
});
describe("schema protection", () => {
it("preserves unknown keys during updateSettings", async () => {
await store.init();
// Simulate a setting that existed in an older schema version
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
raw.legacyCustomField = "preserve-me";
raw.anotherRemovedSetting = 42;
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
// Update a known field — unknown keys must survive the write cycle
await store.updateSettings({ ntfyEnabled: true });
const ondisk = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(ondisk.legacyCustomField).toBe("preserve-me");
expect(ondisk.anotherRemovedSetting).toBe(42);
expect(ondisk.ntfyEnabled).toBe(true);
});
it("readRaw returns all keys including unknown ones", async () => {
await store.init();
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
raw.futureField = "hello";
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
const result = await store.readRaw();
expect(result.futureField).toBe("hello");
expect(result.themeMode).toBe("dark");
});
it("readRaw returns empty object for missing file", async () => {
const emptyStore = new GlobalSettingsStore(join(dir, "nonexistent"));
const result = await emptyStore.readRaw();
expect(result).toEqual({});
});
it("preserves unknown keys across multiple save cycles", async () => {
await store.init();
// Inject an unknown key
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
raw.removedFeatureHost = "http://example.com";
await writeFile(join(dir, "settings.json"), JSON.stringify(raw, null, 2));
// Multiple save cycles with different known fields
await store.updateSettings({ ntfyEnabled: true });
await store.updateSettings({ ntfyTopic: "test-topic" });
await store.updateSettings({ themeMode: "light" });
const ondisk = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(ondisk.removedFeatureHost).toBe("http://example.com");
expect(ondisk.ntfyEnabled).toBe(true);
expect(ondisk.ntfyTopic).toBe("test-topic");
expect(ondisk.themeMode).toBe("light");
});
});
describe("getSettingsPath()", () => {
it("returns the path to settings.json", () => {
const path = store.getSettingsPath();
expect(path).toBe(join(dir, "settings.json"));
});
});
describe("atomic writes", () => {
it("does not leave tmp files after a successful write", async () => {
await store.init();
await store.updateSettings({ themeMode: "light" });
const tmpPath = join(dir, "settings.json.tmp");
expect(existsSync(tmpPath)).toBe(false);
});
});
describe("in-memory cache", () => {
it("returns cached result on second getSettings() call without reading disk", async () => {
await store.init();
await writeFile(join(dir, "settings.json"), JSON.stringify({ themeMode: "light" }));
// First call reads from disk
const first = await store.getSettings();
expect(first.themeMode).toBe("light");
// Modify the file externally
await writeFile(join(dir, "settings.json"), JSON.stringify({ themeMode: "dark" }));
// Second call should return cached result (light), not the new disk value (dark)
const second = await store.getSettings();
expect(second.themeMode).toBe("light");
});
it("updateSettings() updates the cache", async () => {
await store.init();
// First call populates cache
const first = await store.getSettings();
expect(first.themeMode).toBe("dark");
// Update settings
await store.updateSettings({ themeMode: "light" });
// getSettings should return updated cached value
const second = await store.getSettings();
expect(second.themeMode).toBe("light");
});
it("invalidateCache() forces re-read from disk", async () => {
await store.init();
await writeFile(join(dir, "settings.json"), JSON.stringify({ themeMode: "light" }));
// First call reads from disk
const first = await store.getSettings();
expect(first.themeMode).toBe("light");
// Modify the file externally
await writeFile(join(dir, "settings.json"), JSON.stringify({ themeMode: "system" }));
// Without invalidation, should return cached value
const cached = await store.getSettings();
expect(cached.themeMode).toBe("light");
// After invalidation, should re-read from disk
store.invalidateCache();
const afterInvalidate = await store.getSettings();
expect(afterInvalidate.themeMode).toBe("system");
});
});
// ── Global Lane Model Settings (FN-1710) ──────────────────────────
describe("global lane model settings", () => {
it("all *Global* lane fields default to undefined", async () => {
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
expect(settings.planningGlobalProvider).toBeUndefined();
expect(settings.planningGlobalModelId).toBeUndefined();
expect(settings.validatorGlobalProvider).toBeUndefined();
expect(settings.validatorGlobalModelId).toBeUndefined();
expect(settings.titleSummarizerGlobalProvider).toBeUndefined();
expect(settings.titleSummarizerGlobalModelId).toBeUndefined();
});
it("persists executionGlobalProvider/executionGlobalModelId", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.executionGlobalProvider).toBe("anthropic");
expect(raw.executionGlobalModelId).toBe("claude-sonnet-4-5");
});
it("persists planningGlobalProvider/planningGlobalModelId", async () => {
await store.updateSettings({
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
});
const settings = await store.getSettings();
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
});
it("persists validatorGlobalProvider/validatorGlobalModelId", async () => {
await store.updateSettings({
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4o",
});
const settings = await store.getSettings();
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4o");
});
it("persists titleSummarizerGlobalProvider/titleSummarizerGlobalModelId", async () => {
await store.updateSettings({
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-haiku",
});
const settings = await store.getSettings();
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
});
it("persists all global lane fields together", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-opus-4",
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4-turbo",
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-sonnet-4-5",
});
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-opus-4");
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo");
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-sonnet-4-5");
});
it("can clear global lane fields with null", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
// Clear them
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalModelId: null });
const settings = await store.getSettings();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
});
it("merges global lane fields without losing other fields", async () => {
await store.updateSettings({
executionGlobalProvider: "anthropic",
executionGlobalModelId: "claude-sonnet-4-5",
});
await store.updateSettings({
planningGlobalProvider: "google",
planningGlobalModelId: "gemini-2.5-pro",
});
const settings = await store.getSettings();
// Both should be present
expect(settings.executionGlobalProvider).toBe("anthropic");
expect(settings.executionGlobalModelId).toBe("claude-sonnet-4-5");
expect(settings.planningGlobalProvider).toBe("google");
expect(settings.planningGlobalModelId).toBe("gemini-2.5-pro");
});
});
// ── Model Baseline/Fallback Round-Trip Regression (FN-1729) ──────────────
describe("model baseline/fallback round-trip regression (FN-1729)", () => {
it("defaultProvider/defaultModelId round-trips with defaults + persisted values", async () => {
// Persist default baseline
await store.updateSettings({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
});
let settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.defaultProvider).toBe("anthropic");
expect(raw.defaultModelId).toBe("claude-sonnet-4-5");
});
it("fallbackProvider/fallbackModelId round-trips with defaults + persisted values", async () => {
await store.updateSettings({
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
});
let settings = await store.getSettings();
expect(settings.fallbackProvider).toBe("openai");
expect(settings.fallbackModelId).toBe("gpt-4o");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.fallbackProvider).toBe("openai");
expect(raw.fallbackModelId).toBe("gpt-4o");
});
it("all global model lanes round-trip correctly", async () => {
await store.updateSettings({
defaultProvider: "anthropic",
defaultModelId: "claude-sonnet-4-5",
fallbackProvider: "openai",
fallbackModelId: "gpt-4o",
executionGlobalProvider: "google",
executionGlobalModelId: "gemini-2.5-pro",
planningGlobalProvider: "anthropic",
planningGlobalModelId: "claude-opus-4",
validatorGlobalProvider: "openai",
validatorGlobalModelId: "gpt-4-turbo",
titleSummarizerGlobalProvider: "anthropic",
titleSummarizerGlobalModelId: "claude-haiku",
});
// Verify all values
let settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBe("claude-sonnet-4-5");
expect(settings.fallbackProvider).toBe("openai");
expect(settings.fallbackModelId).toBe("gpt-4o");
expect(settings.executionGlobalProvider).toBe("google");
expect(settings.executionGlobalModelId).toBe("gemini-2.5-pro");
expect(settings.planningGlobalProvider).toBe("anthropic");
expect(settings.planningGlobalModelId).toBe("claude-opus-4");
expect(settings.validatorGlobalProvider).toBe("openai");
expect(settings.validatorGlobalModelId).toBe("gpt-4-turbo");
expect(settings.titleSummarizerGlobalProvider).toBe("anthropic");
expect(settings.titleSummarizerGlobalModelId).toBe("claude-haiku");
// Verify persistence
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.defaultProvider).toBe("anthropic");
expect(raw.defaultModelId).toBe("claude-sonnet-4-5");
expect(raw.fallbackProvider).toBe("openai");
expect(raw.fallbackModelId).toBe("gpt-4o");
expect(raw.executionGlobalProvider).toBe("google");
expect(raw.executionGlobalModelId).toBe("gemini-2.5-pro");
expect(raw.planningGlobalProvider).toBe("anthropic");
expect(raw.planningGlobalModelId).toBe("claude-opus-4");
expect(raw.validatorGlobalProvider).toBe("openai");
expect(raw.validatorGlobalModelId).toBe("gpt-4-turbo");
expect(raw.titleSummarizerGlobalProvider).toBe("anthropic");
expect(raw.titleSummarizerGlobalModelId).toBe("claude-haiku");
// Clear and verify empty
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ defaultProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ defaultModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ fallbackProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ fallbackModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ executionGlobalModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ planningGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ planningGlobalModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ validatorGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ validatorGlobalModelId: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ titleSummarizerGlobalProvider: null });
// @ts-expect-error - null is intentionally used to clear field (null-as-delete)
await store.updateSettings({ titleSummarizerGlobalModelId: null });
settings = await store.getSettings();
expect(settings.defaultProvider).toBeUndefined();
expect(settings.defaultModelId).toBeUndefined();
expect(settings.fallbackProvider).toBeUndefined();
expect(settings.fallbackModelId).toBeUndefined();
expect(settings.executionGlobalProvider).toBeUndefined();
expect(settings.executionGlobalModelId).toBeUndefined();
expect(settings.planningGlobalProvider).toBeUndefined();
expect(settings.planningGlobalModelId).toBeUndefined();
expect(settings.validatorGlobalProvider).toBeUndefined();
expect(settings.validatorGlobalModelId).toBeUndefined();
expect(settings.titleSummarizerGlobalProvider).toBeUndefined();
expect(settings.titleSummarizerGlobalModelId).toBeUndefined();
// Verify cleared from persistence
const rawAfter = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(rawAfter.defaultProvider).toBeUndefined();
expect(rawAfter.defaultModelId).toBeUndefined();
expect(rawAfter.fallbackProvider).toBeUndefined();
expect(rawAfter.fallbackModelId).toBeUndefined();
expect(rawAfter.executionGlobalProvider).toBeUndefined();
expect(rawAfter.executionGlobalModelId).toBeUndefined();
expect(rawAfter.planningGlobalProvider).toBeUndefined();
expect(rawAfter.planningGlobalModelId).toBeUndefined();
expect(rawAfter.validatorGlobalProvider).toBeUndefined();
expect(rawAfter.validatorGlobalModelId).toBeUndefined();
expect(rawAfter.titleSummarizerGlobalProvider).toBeUndefined();
expect(rawAfter.titleSummarizerGlobalModelId).toBeUndefined();
});
it("model baseline/fallback defaults do not persist until explicitly set", async () => {
// Initialize the store to create settings file with defaults
await store.init();
// Don't set any model settings
const settings = await store.getSettings();
// All should be undefined (not default values)
expect(settings.defaultProvider).toBeUndefined();
expect(settings.defaultModelId).toBeUndefined();
expect(settings.fallbackProvider).toBeUndefined();
expect(settings.fallbackModelId).toBeUndefined();
// Settings file should exist with defaults but no model fields persisted
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
// Only default theme fields should be present
expect(raw.themeMode).toBe("dark");
expect(raw.colorTheme).toBe("default");
// Model fields should not be persisted
expect(raw.defaultProvider).toBeUndefined();
expect(raw.defaultModelId).toBeUndefined();
expect(raw.fallbackProvider).toBeUndefined();
expect(raw.fallbackModelId).toBeUndefined();
});
it("partial provider without modelId is valid and persists correctly", async () => {
// Set provider without modelId
await store.updateSettings({
defaultProvider: "anthropic",
// defaultModelId intentionally omitted
});
const settings = await store.getSettings();
expect(settings.defaultProvider).toBe("anthropic");
expect(settings.defaultModelId).toBeUndefined();
// Verify partial pair persisted
const raw = JSON.parse(await readFile(join(dir, "settings.json"), "utf-8"));
expect(raw.defaultProvider).toBe("anthropic");
expect(raw.defaultModelId).toBeUndefined();
});
});
});