wave 2 (platform stream): settings storage & plumbing foundation (P2)

- src/core/settings.ts: versioned DELTA record schema + sanitizer (unknown keys preserved)
  — general/chat/agent{models,tools,prompts}/appearance/graph sections
- src/core/settings-resolution.ts: pure resolveSettings (factory defaults → user delta),
  extraction/graph-context channel predicates
- src/store/settings.ts + prompt-overrides.ts: SettingsStore/PromptOverrideStore adapters
  wired through stores.ts (effectiveSettings() fresh per call); lazy migration of
  default-settings.json into record.chat
- app/api/settings/** routes (record, section bodies, prompt overrides per segment)
- chat-handler: graph gates at trigger call-sites + toolToggles injection seam
- config.ts: SETTINGS_FILE + PROMPTS_OVERRIDE_DIR paths

587/587 tests; typecheck/lint/build clean
This commit is contained in:
amosblomqvist
2026-09-19 18:05:30 +03:00
parent 36c5d19437
commit cb4fe25c89
24 changed files with 2096 additions and 101 deletions
+8 -51
View File
@@ -1,20 +1,17 @@
/**
* Tests for the default-settings wave (2026-09-18): the shared settings-body
* parser (the one seam behind BOTH settings routes — per-chat and defaults —
* so their parse behavior is identical) and the default-settings store
* (data/default-settings.json read/write, first-boot null semantics).
* Tests for the shared settings-body parser, chat-section flavor
* (default-settings wave, 2026-09-18; record-backed 2026-09-19): the seam
* behind BOTH chat-settings routes — per-chat and the defaults alias.
*
* The store tests write the real repo data file (npm test's shared data/,
* disposable scratch by doctrine) and clean up after themselves.
* The record store, the legacy-file migration, and the record-backed
* default-settings facade are covered in tests/settings-store.test.ts
* (one fs owner per file — the test runner executes files in parallel).
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import { afterEach, describe, it } from "node:test";
import { GRAPH_CONTEXT_TOKENS_MAX, type ChatSettings } from "../src/core";
import { describe, it } from "node:test";
import { GRAPH_CONTEXT_TOKENS_MAX } from "../src/core";
import { parseSettingsBody } from "../src/server/settings-body.ts";
import { DEFAULT_SETTINGS_FILE } from "../config.ts";
import { readDefaultSettings, writeDefaultSettings } from "../src/store/default-settings.ts";
describe("parseSettingsBody (shared by both settings routes)", () => {
it("an empty object parses to {} — the clear-overrides write", () => {
@@ -66,43 +63,3 @@ describe("parseSettingsBody (shared by both settings routes)", () => {
assert.equal(ok.ok, true);
});
});
describe("the default-settings store", () => {
afterEach(() => {
try {
fs.rmSync(DEFAULT_SETTINGS_FILE);
} catch {
/* nothing to clean */
}
});
it("reads null on first boot (no file) — the surfaces use config defaults", () => {
try {
fs.rmSync(DEFAULT_SETTINGS_FILE);
} catch {
/* absent already */
}
assert.equal(readDefaultSettings(), null);
});
it("write then read roundtrips verbatim — validated shape, no sanitizer games", () => {
const settings: ChatSettings = {
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
thinkingLevel: "high",
graphContextTokens: 1024,
};
writeDefaultSettings(settings);
assert.deepEqual(readDefaultSettings(), settings);
});
it("an empty {} record roundtrips (all overrides cleared)", () => {
writeDefaultSettings({});
assert.deepEqual(readDefaultSettings(), {});
});
it("a corrupt file reads as null — defaults, never a crash", () => {
fs.rmSync(DEFAULT_SETTINGS_FILE, { force: true }); // a directory from a broken earlier run also blocks
fs.writeFileSync(DEFAULT_SETTINGS_FILE, "{ not json", "utf-8");
assert.equal(readDefaultSettings(), null);
});
});
+16 -2
View File
@@ -1,6 +1,6 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { modelCatalog, validateChatSettings, catalogDefaults, toModelDto } from "../src/server/model-catalog.ts";
import { modelCatalog, validateChatSettings, catalogDefaultsFor, toModelDto } from "../src/server/model-catalog.ts";
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
import { MODELS } from "../config.ts";
import { piEngineFor } from "../src/agent/models.ts";
@@ -32,11 +32,25 @@ describe("the model catalog projection", () => {
});
it("defaults mirror the conversation agents' config pin", () => {
const d = catalogDefaults();
const d = catalogDefaultsFor();
assert.deepEqual(d, { model: { provider: "openrouter", id: MODELS.prober.model }, thinkingLevel: MODELS.prober.effort });
assert.equal(d.model.id, MODELS.teacher.model, "one controller: prober and teacher share the pin");
});
it("a picked default in the record's chat section overrides the pin (catalogDefaultsFor, settings wave)", () => {
const d = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" });
assert.deepEqual(d, { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" });
// partial: only the model chosen → thinking falls back to the pin
const partial = catalogDefaultsFor({ model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } });
assert.equal(partial.thinkingLevel, MODELS.prober.effort);
assert.equal(partial.model.id, "qwen/qwen3.8-flash");
// graphContextTokens-only record: model untouched
assert.deepEqual(catalogDefaultsFor({ graphContextTokens: 512 }), {
model: { provider: "openrouter", id: MODELS.prober.model },
thinkingLevel: MODELS.prober.effort,
});
});
it("toModelDto projects the raw catalog model verbatim", () => {
const raw = piEngineFor("prober").model; // the catalog's glm entry
const dto = toModelDto(raw);
+129
View File
@@ -0,0 +1,129 @@
/**
* Tests for the prompt-override store (settings-storage wave, 2026-09-19):
* src/store/prompt-overrides.ts — override file CRUD, the mirror-layout
* path discipline (traversal guard), and the SOURCE-DERIVED segment list
* (the loader mirrors teacher-agent.ts's composeInstructions discovery;
* these tests pin that the mirror tracks the real src/agent/prompts tree).
* Writes under the repo data/prompts/ (disposable scratch) and cleans up.
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { after, describe, it } from "node:test";
import { PROMPTS_OVERRIDE_DIR } from "../config.ts";
import {
allSegmentPaths,
effectivePromptSegments,
isValidSegmentPath,
promptOverrides,
} from "../src/store/prompt-overrides.ts";
after(() => {
fs.rmSync(PROMPTS_OVERRIDE_DIR, { recursive: true, force: true });
});
describe("isValidSegmentPath (the mirror-layout discipline)", () => {
it("accepts the shared base and numeric fragments, rejects everything else", () => {
assert.equal(isValidSegmentPath("system-prompt.md"), true);
assert.equal(isValidSegmentPath("teacher/20-teaching.md"), true);
assert.equal(isValidSegmentPath("prober/10-probe-doctrine.md"), true);
for (const bad of [
"", // empty
"README.md", // no agent dir
"teacher/README.md", // not numeric-prefixed
"teacher/20-teaching.txt", // not .md
"../system-prompt.md", // traversal
"agent/../teacher/20-teaching.md", // traversal
"/etc/passwd", // absolute
"teacher", // bare dir
"teacher/", // bare dir
"teacher/sub/10-x.md", // nested (not the mirror layout)
"prober\\10-x.md", // backslash
]) {
assert.equal(isValidSegmentPath(bad), false, bad);
}
});
});
describe("the override store", () => {
it("listOverrides is empty with no dir, then lists written mirrors sorted", () => {
fs.rmSync(PROMPTS_OVERRIDE_DIR, { recursive: true, force: true });
assert.deepEqual(promptOverrides.listOverrides(), []);
promptOverrides.writeOverride("teacher/20-teaching.md", "custom teaching");
promptOverrides.writeOverride("system-prompt.md", "custom base");
assert.deepEqual(promptOverrides.listOverrides(), ["system-prompt.md", "teacher/20-teaching.md"]);
});
it("readOverride round-trips; delete removes idempotently; invalid paths no-op", () => {
promptOverrides.writeOverride("prober/30-probe-done.md", "custom probe-done");
assert.equal(promptOverrides.readOverride("prober/30-probe-done.md"), "custom probe-done");
promptOverrides.deleteOverride("prober/30-probe-done.md");
assert.equal(promptOverrides.readOverride("prober/30-probe-done.md"), null);
promptOverrides.deleteOverride("prober/30-probe-done.md"); // again: no throw
// traversal junk never reaches disk
promptOverrides.writeOverride("../evil.md", "nope");
assert.equal(fs.existsSync(path.join(PROMPTS_OVERRIDE_DIR, "..", "evil.md")), false);
});
it("switches are NOT the store's concern — files alone never enable", () => {
promptOverrides.writeOverride("system-prompt.md", "custom base");
const off = effectivePromptSegments("teacher", {}) // no switches
.find((s) => s.path === "system-prompt.md");
assert.ok(off);
assert.equal(off.overrideText, "custom base"); // file exists...
assert.equal(off.enabled, false); // ...but no switch → source default
promptOverrides.deleteOverride("system-prompt.md"); // leave clean for the sibling tests
});
});
describe("the source-derived segment list", () => {
it("mirrors composeInstructions' discovery: base first, numeric fragments sorted, per agent", () => {
const prober = effectivePromptSegments("prober", {});
const teacher = effectivePromptSegments("teacher", {});
// The shared base composes both prompts, SAME path.
assert.equal(prober[0]?.path, "system-prompt.md");
assert.equal(teacher[0]?.path, "system-prompt.md");
assert.equal(prober[0]?.shared, true);
assert.equal(teacher[0]?.shared, true);
assert.equal(prober[0]?.sourceText.length > 0, true, "base prompt text loads");
// Fragments track the REAL source tree (numeric order, right agent dir).
assert.deepEqual(
prober.map((s) => s.path),
["system-prompt.md", "prober/10-probe-doctrine.md", "prober/20-teaching-in-brief.md", "prober/30-probe-done.md"],
);
assert.equal(teacher.some((s) => s.path === "teacher/10-planning.md"), true);
assert.equal(teacher.some((s) => s.path === "teacher/50-probing-in-brief.md"), true);
assert.equal(teacher.every((s) => s.path === "system-prompt.md" || s.path.startsWith("teacher/")), true);
// README and other non-numeric files never load.
assert.equal(allSegmentPaths().some((p) => p.includes("README")), false);
});
it("effective segments: switch+file → override; either missing → source text", () => {
promptOverrides.writeOverride("teacher/20-teaching.md", "MY WAY");
const switches = { "teacher/20-teaching.md": true, "system-prompt.md": false };
const teacher = effectivePromptSegments("teacher", switches);
const teaching = teacher.find((s) => s.path === "teacher/20-teaching.md");
assert.ok(teaching);
assert.equal(teaching.overrideText, "MY WAY");
assert.equal(teaching.enabled, true);
// base: switch OFF with file ABSENT → source, disabled
const base = teacher.find((s) => s.path === "system-prompt.md");
assert.ok(base);
assert.equal(base.overrideText, null);
assert.equal(base.enabled, false);
// a switch for a segment with NO override file never enables it
const weird = effectivePromptSegments("teacher", { "teacher/10-planning.md": true });
const planning = weird.find((s) => s.path === "teacher/10-planning.md");
assert.ok(planning);
assert.equal(planning.enabled, false);
assert.equal(planning.overrideText, null);
});
it("adapts to source changes by construction: the list is computed, not stored", () => {
// If a fragment vanishes upstream, its switch/override simply stop
// composing — nothing here can go stale. Pinned by absence:
const before = allSegmentPaths().length;
assert.equal(before > 5, true, "base + prober fragments + teacher fragments");
});
});
+152
View File
@@ -0,0 +1,152 @@
/**
* Tests for the section parsers (settings-storage wave, 2026-09-19):
* src/server/settings-body.ts — general/agent/appearance/graph strictness.
* Same discipline as parseSettingsBody (its tests live in
* default-settings.test.ts): every known field is strict (bad type/shape
* → 400-style error, never a silent clamp), unknown keys drop, absent
* fields mean absent overrides. Pure — no fs.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
parseAgentSettingsBody,
parseAppearanceSettingsBody,
parseGeneralSettingsBody,
parseGraphSettingsBody,
SETTINGS_SECTIONS,
} from "../src/server/settings-body.ts";
describe("parseGeneralSettingsBody", () => {
it("parses {} and {autoUpdate} replace-whole", () => {
const empty = parseGeneralSettingsBody({});
assert.ok(empty.ok);
assert.deepEqual(empty.settings, {});
const on = parseGeneralSettingsBody({ autoUpdate: true });
assert.ok(on.ok);
assert.deepEqual(on.settings, { autoUpdate: true });
});
it("rejects non-objects and non-boolean autoUpdate", () => {
assert.equal(parseGeneralSettingsBody("x").ok, false);
assert.equal(parseGeneralSettingsBody({ autoUpdate: "yes" }).ok, false);
});
});
describe("parseAgentSettingsBody", () => {
it("models: valid roles with model pair and/or thinkingLevel; unknown role is a 400 with the list", () => {
const r = parseAgentSettingsBody({
models: {
researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } },
labeler: { thinkingLevel: "high" },
},
});
assert.ok(r.ok);
assert.deepEqual(r.settings.models, {
researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } },
labeler: { thinkingLevel: "high" },
});
const bad = parseAgentSettingsBody({ models: { teacher: {} } });
assert.equal(bad.ok, false);
if (!bad.ok) assert.match(bad.error, /unknown role "teacher" \(supported: researcher, demoBuilder, visualMaker, extractor, auditor, labeler\)/);
const malformed = parseAgentSettingsBody({ models: { researcher: { model: { provider: 1, id: "x" } } } });
assert.equal(malformed.ok, false);
});
it("tools and prompts: boolean maps; non-boolean values are 400s", () => {
const r = parseAgentSettingsBody({ tools: { research: false }, prompts: { "system-prompt.md": true } });
assert.ok(r.ok);
assert.deepEqual(r.settings.tools, { research: false });
assert.deepEqual(r.settings.prompts, { "system-prompt.md": true });
assert.equal(parseAgentSettingsBody({ tools: { quiz: "no" } }).ok, false);
assert.equal(parseAgentSettingsBody({ prompts: { "x.md": 1 } }).ok, false);
// switch validity (segment membership, file presence) is the ROUTE's
// cross-validation — the parser only sees shapes.
});
it("an empty {} agent section parses to {} (replace-whole clear)", () => {
const r = parseAgentSettingsBody({});
assert.ok(r.ok);
assert.deepEqual(r.settings, {});
});
});
describe("parseAppearanceSettingsBody", () => {
it("parses every field; hex-or-null colors; clamps happen at sanitize, rejects here", () => {
const r = parseAppearanceSettingsBody({
mode: "light",
background: "#ffffff",
fontFamily: "Some Font",
proseFontSize: 20,
sessionNameSerif: false,
mdRendering: { headingWeight: 700, boldColor: "#ff8800", italicColor: null },
});
assert.ok(r.ok);
assert.deepEqual(r.settings, {
mode: "light",
background: "#ffffff",
fontFamily: "Some Font",
proseFontSize: 20,
sessionNameSerif: false,
mdRendering: { headingWeight: 700, boldColor: "#ff8800", italicColor: null },
});
});
it("rejects bad enums, ranges, and colors — with specific messages, never clamps", () => {
const cases: unknown[] = [
{ mode: "sepia" },
{ background: "blue" },
{ background: 42 },
{ fontFamily: "" },
{ fontFamily: 12 },
{ proseFontSize: 30 },
{ proseFontSize: 64.5 },
{ sessionNameSerif: 1 },
{ mdRendering: { headingWeight: 1000 } },
{ mdRendering: { boldColor: "red" } },
];
for (const body of cases) {
assert.equal(parseAppearanceSettingsBody(body).ok, false, JSON.stringify(body));
}
// boundary values that MUST pass: 12 and 24, 100 and 900, #fff
assert.ok(parseAppearanceSettingsBody({ proseFontSize: 12 }));
assert.ok(parseAppearanceSettingsBody({ proseFontSize: 24 }));
assert.ok(parseAppearanceSettingsBody({ mdRendering: { headingWeight: 100 } }));
assert.ok(parseAppearanceSettingsBody({ mdRendering: { headingWeight: 900 } }));
assert.ok(parseAppearanceSettingsBody({ background: "#abc" }));
// null = "the mode's default" — explicit, valid
assert.ok(parseAppearanceSettingsBody({ background: null, fontFamily: null }));
});
it("rejects non-object bodies and non-object mdRendering", () => {
assert.equal(parseAppearanceSettingsBody([1]).ok, false);
assert.equal(parseAppearanceSettingsBody({ mdRendering: "big" }).ok, false);
});
});
describe("parseGraphSettingsBody", () => {
it("parses the four-rung ladder replace-whole", () => {
const r = parseGraphSettingsBody({ enabled: false, embedding: false });
assert.ok(r.ok);
assert.deepEqual(r.settings, { enabled: false, embedding: false });
assert.equal(parseGraphSettingsBody({}).ok, true);
});
it("rejects non-boolean rungs and unknown bodies", () => {
assert.equal(parseGraphSettingsBody({ enabled: "off" }).ok, false);
assert.equal(parseGraphSettingsBody("nope").ok, false);
});
it("the ladder DEPENDENCY (graph → embedding → clustering → labeling) is a UI concern, not a parse concern", () => {
// Nothing stops a stored {enabled: false, labeling: true} — the
// values persist but resolve inert (grays out in the panel).
const r = parseGraphSettingsBody({ enabled: false, clustering: true, labeling: true });
assert.ok(r.ok);
});
});
describe("SETTINGS_SECTIONS (the route's section map)", () => {
it("maps exactly the five record sections", () => {
assert.deepEqual(Object.keys(SETTINGS_SECTIONS), ["general", "chat", "agent", "appearance", "graph"]);
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Tests for the resolution layer (settings-storage wave, 2026-09-19):
* core/settings-resolution.ts — the delta→concrete projection. The factory
* layer is threaded in as a parameter (core stays pure; server/stores.ts
* threads config.ts's MODELS), so these tests use a small synthetic pin.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
extractionEnabled,
graphContextEnabled,
resolveModelSpec,
resolveSettings,
} from "../src/core/settings-resolution.ts";
import { BACKGROUND_MODEL_ROLES, type UserSettings } from "../src/core/settings.ts";
const FACTORY_PINS = Object.fromEntries(
BACKGROUND_MODEL_ROLES.map((role, i) => [
role,
{ model: `factory/model-${i}`, effort: i % 2 === 0 ? "low" : "off" },
]),
) as Record<(typeof BACKGROUND_MODEL_ROLES)[number], { model: string; effort: "low" | "off" }>;
describe("resolveSettings", () => {
it("null record (first boot): every knob lands on its factory default", () => {
const eff = resolveSettings(null, FACTORY_PINS);
assert.deepEqual(eff.general, { autoUpdate: true });
assert.deepEqual(eff.chat, {}); // BYOK: no default model, ever
assert.deepEqual(eff.agent.tools, {});
assert.deepEqual(eff.agent.prompts, {});
for (const role of BACKGROUND_MODEL_ROLES) {
assert.equal(eff.agent.models[role].id, FACTORY_PINS[role].model);
assert.equal(eff.agent.models[role].provider, "openrouter");
assert.equal(eff.agent.models[role].thinkingLevel, FACTORY_PINS[role].effort);
}
assert.equal(eff.appearance.mode, "dark");
assert.equal(eff.appearance.background, null);
assert.equal(eff.appearance.fontFamily, null);
assert.equal(eff.appearance.proseFontSize, 16);
assert.equal(eff.appearance.sessionNameSerif, true);
assert.equal(eff.appearance.mdRendering.headingWeight, 900);
assert.deepEqual(eff.graph, { enabled: true, embedding: true, clustering: true, labeling: true });
});
it("explicit record values win over every factory default", () => {
const record: UserSettings = {
version: 1,
general: { autoUpdate: false },
chat: { thinkingLevel: "high" },
agent: {
models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } },
tools: { research: false },
prompts: { "system-prompt.md": true },
},
appearance: { mode: "light", proseFontSize: 20, mdRendering: { headingWeight: 600 } },
graph: { enabled: false },
};
const eff = resolveSettings(record, FACTORY_PINS);
assert.equal(eff.general.autoUpdate, false);
assert.deepEqual(eff.chat, { thinkingLevel: "high" });
assert.deepEqual(eff.agent.models.researcher, {
provider: "openrouter",
id: "qwen/qwen3.8-flash",
thinkingLevel: "low", // thinking NOT overridden → factory effort
});
assert.deepEqual(eff.agent.tools, { research: false });
assert.deepEqual(eff.agent.prompts, { "system-prompt.md": true });
assert.equal(eff.appearance.mode, "light");
assert.equal(eff.appearance.proseFontSize, 20);
assert.equal(eff.appearance.mdRendering.headingWeight, 600);
// graph: enabled=false explicit; absent rungs stay factory-on (stored
// but inert — the ladder grays them out in the UI).
assert.deepEqual(eff.graph, { enabled: false, embedding: true, clustering: true, labeling: true });
});
it("partial role override: thinkingLevel alone swaps the budget, model stays pinned", () => {
const eff = resolveSettings(
{ version: 1, agent: { models: { extractor: { thinkingLevel: "medium" } } } },
FACTORY_PINS,
);
const extractor = FACTORY_PINS.extractor;
assert.deepEqual(eff.agent.models.extractor, {
provider: "openrouter",
id: extractor.model,
thinkingLevel: "medium",
});
// sibling roles untouched
assert.equal(eff.agent.models.researcher.thinkingLevel, FACTORY_PINS.researcher.effort);
});
it("resolveModelSpec is field-independent per override aspect", () => {
assert.deepEqual(
resolveModelSpec("labeler", { model: { provider: "other", id: "x" } }, FACTORY_PINS),
{ provider: "other", id: "x", thinkingLevel: FACTORY_PINS.labeler.effort },
);
assert.deepEqual(resolveModelSpec("labeler", {}, FACTORY_PINS), {
provider: "openrouter",
id: FACTORY_PINS.labeler.model,
thinkingLevel: FACTORY_PINS.labeler.effort,
});
});
});
describe("the graph channel gates (the wave's seam honoring)", () => {
it("WRITE channel (extraction): dies with the master switch only", () => {
assert.equal(extractionEnabled({ enabled: true, embedding: true, clustering: true, labeling: true }), true);
assert.equal(extractionEnabled({ enabled: true, embedding: false, clustering: false, labeling: false }), true);
assert.equal(extractionEnabled({ enabled: false, embedding: true, clustering: true, labeling: true }), false);
});
it("READ channel (graph-context): needs master AND the embedding rung", () => {
assert.equal(graphContextEnabled({ enabled: true, embedding: true, clustering: false, labeling: false }), true);
assert.equal(graphContextEnabled({ enabled: true, embedding: false, clustering: true, labeling: true }), false);
assert.equal(graphContextEnabled({ enabled: false, embedding: true, clustering: true, labeling: true }), false);
});
it("both gates ride resolveSettings' factory defaults (all on → both open)", () => {
const eff = resolveSettings(null, FACTORY_PINS);
assert.equal(extractionEnabled(eff.graph), true);
assert.equal(graphContextEnabled(eff.graph), true);
const off = resolveSettings({ version: 1, graph: { enabled: false } }, FACTORY_PINS);
assert.equal(extractionEnabled(off.graph), false);
assert.equal(graphContextEnabled(off.graph), false);
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Tests for the settings STORE (settings-storage wave, 2026-09-19):
* src/store/settings.ts — the record's fs adapter + the LAZY MIGRATION
* from the legacy default-settings.json — and the record-backed
* default-settings facade (src/store/default-settings.ts). Writes the
* real repo data/ files (npm test's shared data/, disposable scratch by
* doctrine) and cleans up after itself; a pre-existing settings.json is
* snapshotted and restored so the wave's own dev state survives the run.
*/
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { after, afterEach, describe, it } from "node:test";
import { DEFAULT_SETTINGS_FILE, SETTINGS_FILE } from "../config.ts";
import { readSettings, writeSettings } from "../src/store/settings.ts";
import { readDefaultSettings, writeDefaultSettings } from "../src/store/default-settings.ts";
import type { ChatSettings } from "../src/core";
const CHAT_FIXTURE: ChatSettings = {
model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" },
thinkingLevel: "low",
graphContextTokens: 1024,
};
// Snapshot any pre-existing files; restore in after() so the worktree's
// own dev state survives a test run.
let hadSettings = false;
let savedSettings = "";
try {
savedSettings = fs.readFileSync(SETTINGS_FILE, "utf-8");
hadSettings = true;
} catch {
/* none */
}
let hadDefaults = false;
let savedDefaults = "";
try {
savedDefaults = fs.readFileSync(DEFAULT_SETTINGS_FILE, "utf-8");
hadDefaults = true;
} catch {
/* none */
}
after(() => {
if (hadSettings) fs.writeFileSync(SETTINGS_FILE, savedSettings, "utf-8");
else fs.rmSync(SETTINGS_FILE, { force: true });
if (hadDefaults) fs.writeFileSync(DEFAULT_SETTINGS_FILE, savedDefaults, "utf-8");
else fs.rmSync(DEFAULT_SETTINGS_FILE, { force: true });
});
afterEach(() => {
fs.rmSync(SETTINGS_FILE, { force: true });
});
describe("the settings store", () => {
it("reads null on first boot (no record, no legacy file)", () => {
fs.rmSync(SETTINGS_FILE, { force: true });
fs.rmSync(DEFAULT_SETTINGS_FILE, { force: true });
assert.equal(readSettings(), null);
});
it("write→read round-trips through the sanitizer", () => {
writeSettings({ version: 1, graph: { enabled: false } });
const out = readSettings();
assert.ok(out);
assert.deepEqual(out.graph, { enabled: false });
});
it("corrupt settings.json → null (first-boot semantics), never throws", () => {
fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
fs.writeFileSync(SETTINGS_FILE, "{not json", "utf-8");
assert.equal(readSettings(), null);
});
it("LEGACY MIGRATION: default-settings.json folds into the record's chat section on first read", () => {
fs.rmSync(SETTINGS_FILE, { force: true });
fs.mkdirSync(path.dirname(DEFAULT_SETTINGS_FILE), { recursive: true });
fs.writeFileSync(DEFAULT_SETTINGS_FILE, JSON.stringify(CHAT_FIXTURE), "utf-8");
const migrated = readSettings();
assert.ok(migrated, "the legacy file became a record");
assert.equal(migrated.version, 1);
assert.deepEqual(migrated.chat, CHAT_FIXTURE);
// The record is now persisted — the legacy file is left inert on disk.
assert.ok(fs.existsSync(SETTINGS_FILE));
assert.deepEqual(readSettings()?.chat, CHAT_FIXTURE);
// And the default-settings facade reads the SAME section.
assert.deepEqual(readDefaultSettings(), CHAT_FIXTURE);
});
it("LEGACY MIGRATION is one-shot: an unreadable/corrupt legacy file never blocks first boot", () => {
fs.rmSync(SETTINGS_FILE, { force: true });
fs.mkdirSync(path.dirname(DEFAULT_SETTINGS_FILE), { recursive: true });
fs.writeFileSync(DEFAULT_SETTINGS_FILE, "junk", "utf-8");
assert.equal(readSettings(), null);
});
});
describe("the record-backed default-settings facade", () => {
it("writeDefaultSettings lands in the record's chat section WITHOUT touching other sections", () => {
writeSettings({ version: 1, graph: { enabled: false }, agent: { tools: { research: false } } });
writeDefaultSettings(CHAT_FIXTURE);
const out = readSettings();
assert.ok(out);
assert.deepEqual(out.chat, CHAT_FIXTURE);
assert.deepEqual(out.graph, { enabled: false }); // untouched
assert.deepEqual(out.agent?.tools, { research: false }); // untouched
});
it("writeDefaultSettings creates the record on first boot", () => {
fs.rmSync(SETTINGS_FILE, { force: true });
fs.rmSync(DEFAULT_SETTINGS_FILE, { force: true });
assert.equal(readSettings(), null);
writeDefaultSettings(CHAT_FIXTURE);
assert.deepEqual(readDefaultSettings(), CHAT_FIXTURE);
});
it("readDefaultSettings returns null when the record has no chat section", () => {
writeSettings({ version: 1, graph: { enabled: true } });
assert.equal(readDefaultSettings(), null);
});
});
+172
View File
@@ -0,0 +1,172 @@
/**
* Tests for the settings record's core layer (settings-storage wave,
* 2026-09-19): the delta schema's sanitizer (core/settings.ts) — strict
* known-field validation with graceful degradation, unknown-key
* preservation (forward-compat: a newer record must survive an older
* app), and the factory defaults. The store/adapter tests live in
* settings-store.test.ts; resolution in settings-resolution.test.ts.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
CURRENT_SETTINGS_VERSION,
FACTORY_SETTINGS,
BACKGROUND_MODEL_ROLES,
isHexColor,
sanitizeUserSettings,
} from "../src/core/settings.ts";
describe("FACTORY_SETTINGS (the compiled factory layer)", () => {
it("pins the owner-decided defaults", () => {
assert.equal(CURRENT_SETTINGS_VERSION, 1);
assert.deepEqual(FACTORY_SETTINGS.general, { autoUpdate: true });
assert.deepEqual(FACTORY_SETTINGS.graph, { enabled: true, embedding: true, clustering: true, labeling: true });
const a = FACTORY_SETTINGS.appearance;
assert.equal(a.mode, "dark");
assert.equal(a.background, null); // the mode's own default, not a factory hex
assert.equal(a.fontFamily, null); // Source Sans 3
assert.equal(a.proseFontSize, 16);
assert.equal(a.sessionNameSerif, true);
assert.equal(a.mdRendering.headingWeight, 900);
assert.equal(a.mdRendering.boldColor, null); // white
assert.equal(a.mdRendering.italicColor, null);
});
it("has NO factory model pin — chat defaults and role overrides start absent", () => {
assert.equal("chat" in FACTORY_SETTINGS, false);
assert.equal("agent" in FACTORY_SETTINGS, false);
});
});
describe("isHexColor", () => {
it("accepts #rgb and #rrggbb (case-insensitive), rejects everything else", () => {
for (const good of ["#fff", "#FFF", "#141414", "#8B5CF6"]) assert.equal(isHexColor(good), true, good);
for (const bad of ["fff", "#ffff", "#gggggg", "141414", "#12345", "", "white"]) {
assert.equal(isHexColor(bad), false, bad);
}
});
});
describe("sanitizeUserSettings", () => {
it("round-trips a fully valid record field-for-field", () => {
const record = {
version: 1,
general: { autoUpdate: false },
chat: { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "low", graphContextTokens: 256 },
agent: {
models: {
researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "high" },
visualMaker: { thinkingLevel: "max" },
},
tools: { research: false, quiz: true },
prompts: { "system-prompt.md": true, "teacher/20-teaching.md": false },
},
appearance: {
mode: "light",
background: "#ffffff",
fontFamily: "Some Font",
proseFontSize: 18,
sessionNameSerif: false,
mdRendering: { headingWeight: 700, boldColor: "#ff8800", italicColor: null },
},
graph: { enabled: false, embedding: true, clustering: false, labeling: true },
};
assert.deepEqual(sanitizeUserSettings(record), record);
});
it("null and non-objects → null (first-boot semantics), never throws", () => {
assert.equal(sanitizeUserSettings(null), null);
assert.equal(sanitizeUserSettings("junk"), null);
assert.equal(sanitizeUserSettings([1, 2]), null);
});
it("absent version defaults to CURRENT; unknown keys ride along VERBATIM", () => {
const out = sanitizeUserSettings({ futureSection: { knob: 3 }, general: { autoUpdate: true } });
assert.ok(out);
assert.equal(out.version, CURRENT_SETTINGS_VERSION);
assert.deepEqual((out as Record<string, unknown>).futureSection, { knob: 3 });
assert.deepEqual(out.general, { autoUpdate: true });
});
it("wrong-typed fields DROP (resolve to factory), never crash", () => {
const out = sanitizeUserSettings({
general: { autoUpdate: "yes" },
agent: { tools: { research: "no" }, prompts: { "system-prompt.md": 1 }, models: { researcher: "fast" } },
appearance: { mode: "sepia", sessionNameSerif: "true" },
graph: { enabled: 0 },
});
assert.ok(out);
assert.deepEqual(out.general, {});
assert.deepEqual(out.agent, {});
assert.deepEqual(out.appearance, {});
assert.deepEqual(out.graph, {});
});
it("out-of-range numbers CLAMP (proseFontSize, headingWeight)", () => {
const out = sanitizeUserSettings({
appearance: { proseFontSize: 99, mdRendering: { headingWeight: 999 } },
});
assert.ok(out);
assert.equal(out.appearance?.proseFontSize, 24);
assert.equal(out.appearance?.mdRendering?.headingWeight, 900);
const low = sanitizeUserSettings({ appearance: { proseFontSize: 4, mdRendering: { headingWeight: 5 } } });
assert.ok(low);
assert.equal(low.appearance?.proseFontSize, 12);
assert.equal(low.appearance?.mdRendering?.headingWeight, 100);
});
it("invalid colors become null (the factory default), valid ones pass", () => {
const out = sanitizeUserSettings({
appearance: { background: "blue", mdRendering: { boldColor: "#f00", italicColor: 42 } },
});
assert.ok(out);
assert.equal(out.appearance?.background, null);
assert.equal(out.appearance?.mdRendering?.boldColor, "#f00");
assert.equal(out.appearance?.mdRendering?.italicColor, null);
});
it("empty strings/whitespace fontFamily → null (Source Sans 3); wrong-type → null too (the default)", () => {
for (const bad of ["", " ", 12, { name: "x" }]) {
const out = sanitizeUserSettings({ appearance: { fontFamily: bad } });
assert.ok(out);
assert.equal(out.appearance?.fontFamily, null, JSON.stringify(bad));
}
});
it("agent roles: unknown roles drop, known roles keep only well-formed overrides", () => {
const out = sanitizeUserSettings({
agent: {
models: {
teacher: { thinkingLevel: "low" }, // NOT a background role — drops
researcher: { model: { provider: "openrouter", id: "x/y" } },
labeler: { model: { provider: 3, id: "x/y" }, thinkingLevel: "high" }, // bad model, keeps thinking
auditor: { thinkingLevel: "banana" }, // invalid level → empty override drops
},
},
});
assert.ok(out);
const models = out.agent?.models ?? {};
assert.equal("teacher" in models, false);
assert.deepEqual(models.researcher, { model: { provider: "openrouter", id: "x/y" } });
assert.deepEqual(models.labeler, { thinkingLevel: "high" });
assert.equal("auditor" in models, false);
});
it("partial maps: booleans survive, non-boolean entries drop individually", () => {
const out = sanitizeUserSettings({ agent: { tools: { quiz: false, demo: true, visual: "x" } } });
assert.ok(out);
assert.deepEqual(out.agent?.tools, { quiz: false, demo: true });
});
it("BACKGROUND_MODEL_ROLES matches the six background roles (config sync pinned in server tests)", () => {
assert.deepEqual([...BACKGROUND_MODEL_ROLES], [
"researcher",
"demoBuilder",
"visualMaker",
"extractor",
"auditor",
"labeler",
]);
});
});