From bdd7d5e8666c5a8d7e57820e4a27b4bc726e4048 Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Mon, 26 Oct 2026 00:58:25 +0300 Subject: [PATCH] feat: add multilingual dictation and simplify chat STT picker --- components/dictation.tsx | 4 +- components/i18n.tsx | 24 ++++--- src/core/i18n.ts | 7 +- src/core/locales/en.json | 3 + src/core/locales/ru.json | 3 + src/core/settings.ts | 12 ++-- src/server/settings-body.ts | 16 +++-- tests/i18n.test.tsx | 31 +++++++-- tests/language-select-interaction.test.tsx | 74 +++++++++++++++++++--- tests/settings-store.test.ts | 6 ++ 10 files changed, 140 insertions(+), 40 deletions(-) diff --git a/components/dictation.tsx b/components/dictation.tsx index 3dfce2d..7d2b4a9 100644 --- a/components/dictation.tsx +++ b/components/dictation.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Loader2Icon, MicIcon } from "lucide-react"; import { useSettingsDialog } from "@/components/settings/settings-dialog"; -import { dictationUrl, type Language } from "@/src/core"; +import { dictationUrl, type SttLanguage } from "@/src/core"; import { LanguageSelect, useI18n } from "@/components/i18n"; import { Hint } from "@/components/ui/hint"; @@ -282,7 +282,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di const { sttLanguage, setDictating } = useI18n(); const selectedLanguage = useRef(sttLanguage); selectedLanguage.current = sttLanguage; - const recordingLanguage = useRef(sttLanguage); + const recordingLanguage = useRef(sttLanguage); const [phase, setPhase] = useState("idle"); const [error, setError] = useState(null); /** Latest RMS from the worklet — the meter samples this (dictate pattern). */ diff --git a/components/i18n.tsx b/components/i18n.tsx index 3dc251b..9a043bd 100644 --- a/components/i18n.tsx +++ b/components/i18n.tsx @@ -1,19 +1,20 @@ "use client"; import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; -import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core"; +import { resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type Translator } from "@/src/core"; import { notifySettingsSaved, useSettingsSaved } from "./settings-signal"; import { MenuShell } from "./menu-shell"; +type LanguageField = "language" | "sttLanguage"; type I18nValue = { language: Language; - sttLanguage: Language; + sttLanguage: SttLanguage; general: GeneralSettings; dictating: boolean; setDictating: (active: boolean) => void; t: Translator; saveGeneral: (patch: Partial) => Promise; - saveLanguage: (field: "language" | "sttLanguage", value: Language | undefined) => Promise; + saveLanguage: (field: Field, value: GeneralSettings[Field]) => Promise; }; const I18nContext = createContext({ language: "en", sttLanguage: "en", general: {}, dictating: false, setDictating: () => {}, t: translator("en"), saveGeneral: async () => {}, saveLanguage: async () => {} }); export function useI18n(): I18nValue { return useContext(I18nContext); } @@ -28,7 +29,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: { const latest = useRef(general); latest.current = general; const language = resolveLanguage(general.language, detected); - const sttLanguage = resolveLanguage(general.sttLanguage, detected); + const sttLanguage = resolveSttLanguage(general.sttLanguage, detected); const t = useMemo(() => translator(language), [language]); useEffect(() => { const detect = () => setDetected(resolveLanguage(undefined, navigator.languages)); @@ -70,7 +71,7 @@ export function I18nProvider({ children, initialGeneral = {}, system = "en" }: { pending.current = write; return write; }, []); - const saveLanguage = useCallback((field: "language" | "sttLanguage", value: Language | undefined) => saveGeneral({ [field]: value }), [saveGeneral]); + const saveLanguage = useCallback((field: Field, value: GeneralSettings[Field]) => saveGeneral({ [field]: value }), [saveGeneral]); const value = useMemo(() => ({ language, sttLanguage, general, dictating, setDictating, t, saveGeneral, saveLanguage }), [language, sttLanguage, general, dictating, t, saveGeneral, saveLanguage]); return {children}; } @@ -84,13 +85,16 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f const menuId = useId(); const locked = disabled || saving || (field === "sttLanguage" && dictating); const menuOpen = open && !locked; - const selected = general[field] ?? "auto"; + const compactStt = compact && field === "sttLanguage"; + const saved = general[field] ?? "auto"; + const selected = compactStt && saved === "auto" ? sttLanguage : saved; const label = t(field === "language" ? "Interface language" : "Speech recognition language"); const testid = `language-select-${field}${compact ? "-compact" : ""}`; const options = [ - { value: "auto", label: compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default") }, + ...(!compactStt ? [{ value: "auto", label: t("System default") } as const] : []), { value: "en", label: "English" }, { value: "ru", label: "Русский" }, + ...(field === "sttLanguage" ? [{ value: "multi", label: t("Multilingual") } as const] : []), ] as const; useEffect(() => { if (locked) setOpen(false); }, [locked]); useEffect(() => { @@ -114,11 +118,11 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f document.removeEventListener("keydown", escape, true); }; }, [menuOpen, menuId]); - const pick = (value: "auto" | Language) => { + const pick = (value: "auto" | SttLanguage) => { if (locked) return; setOpen(false); trigger.current?.focus(); - if (value === selected) return; + if (value === saved) return; setSaving(true); setError(null); void saveLanguage(field, value === "auto" ? undefined : value).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false)); }; @@ -130,7 +134,7 @@ export function LanguageSelect({ field, disabled = false, compact = false }: { f if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; event.preventDefault(); event.stopPropagation(); setOpen(true); }}> - {options.find((option) => option.value === selected)?.label} + {compact && selected === "multi" ? t("Multi") : options.find((option) => option.value === selected)?.label} ▾ {menuOpen && diff --git a/src/core/i18n.ts b/src/core/i18n.ts index b40a4d3..40c9890 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -2,9 +2,11 @@ import en from "./locales/en.json"; import ru from "./locales/ru.json"; export type Language = "en" | "ru"; +export type SttLanguage = Language | "multi"; export type TranslationParams = Record; export type Translator = (message: string | null | undefined, params?: TranslationParams) => string; export function isLanguage(value: unknown): value is Language { return value === "en" || value === "ru"; } +export function isSttLanguage(value: unknown): value is SttLanguage { return isLanguage(value) || value === "multi"; } /** The preferred system language wins; unsupported preferences resolve to English. */ export function systemLanguage(languages: string | readonly string[] | null | undefined): Language { @@ -20,6 +22,9 @@ export function systemLanguage(languages: string | readonly string[] | null | un export function resolveLanguage(preference: unknown, system?: string | readonly string[] | null): Language { return isLanguage(preference) ? preference : systemLanguage(system); } +export function resolveSttLanguage(preference: unknown, system?: string | readonly string[] | null): SttLanguage { + return isSttLanguage(preference) ? preference : systemLanguage(system); +} export function localeTag(language: Language): string { return language === "ru" ? "ru-RU" : "en-US"; } type Entry = string | Partial>; @@ -58,7 +63,7 @@ export function learningLanguageInstruction(language: Language): string { : "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary."; } /** Streaming configuration: the language is pinned once at recording start, including reconnects. */ -export function dictationUrl(language: Language): string { +export function dictationUrl(language: SttLanguage): string { const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" }); return `wss://api.deepgram.com/v1/listen?${params}`; } diff --git a/src/core/locales/en.json b/src/core/locales/en.json index 752521a..b74b364 100644 --- a/src/core/locales/en.json +++ b/src/core/locales/en.json @@ -249,6 +249,8 @@ "Speech recognition language": "Speech recognition language", "Auto": "Auto", "System default": "System default", + "Multilingual": "Multilingual", + "Multi": "Multi", "Global": "Global", "Chat": "Chat", "Graph view": "Graph view", @@ -723,6 +725,7 @@ "thinkingLevel must be a string": "thinkingLevel must be a string", "autoUpdate must be a boolean": "autoUpdate must be a boolean", "{v0} must be \"en\" or \"ru\"": "{v0} must be \"en\" or \"ru\"", + "sttLanguage must be \"en\", \"ru\", or \"multi\"": "sttLanguage must be \"en\", \"ru\", or \"multi\"", "{v0} must be an object": "{v0} must be an object", "{v0}.model must be {provider, id}": "{v0}.model must be {provider, id}", "{v0}.thinkingLevel must be a string": "{v0}.thinkingLevel must be a string", diff --git a/src/core/locales/ru.json b/src/core/locales/ru.json index baa0c3b..d629ee9 100644 --- a/src/core/locales/ru.json +++ b/src/core/locales/ru.json @@ -249,6 +249,8 @@ "Speech recognition language": "Язык распознавания речи", "Auto": "Авто", "System default": "Язык системы", + "Multilingual": "Несколько языков", + "Multi": "Мульти", "Global": "Общие", "Chat": "Занятие", "Graph view": "Граф", @@ -754,6 +756,7 @@ "thinkingLevel must be a string": "Уровень рассуждения должен быть строкой", "autoUpdate must be a boolean": "Параметр автообновления должен быть логическим значением", "{v0} must be \"en\" or \"ru\"": "Параметр {v0} должен иметь значение «en» или «ru»", + "sttLanguage must be \"en\", \"ru\", or \"multi\"": "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»", "{v0} must be an object": "Параметр {v0} должен быть объектом", "{v0}.model must be {provider, id}": "Поле {v0}.model должно содержать provider и id", "{v0}.thinkingLevel must be a string": "Поле {v0}.thinkingLevel должно быть строкой", diff --git a/src/core/settings.ts b/src/core/settings.ts index 608576a..8ec90b5 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts"; import type { ModelRole } from "../../config.ts"; import type { ChatSettings } from "./contracts/chat.ts"; import type { SearchEngineId } from "./search.ts"; +import { isLanguage, isSttLanguage, type Language, type SttLanguage } from "./i18n.ts"; /** The schema version this code understands. Bump ONLY for breaking shape * changes (a field renamed/meaning changed); pure additions never bump. */ @@ -236,9 +237,9 @@ export interface GeneralSettings { /** Auto-update the app (default ON). Mechanism ships in a later wave. */ autoUpdate?: boolean; /** Absent = browser/system language (English for unsupported languages). */ - language?: "en" | "ru"; - /** Independent dictation language; absent = browser/system language. */ - sttLanguage?: "en" | "ru"; + language?: Language; + /** Independent dictation language; multi = multilingual, absent = browser/system language. */ + sttLanguage?: SttLanguage; } /** Agent settings — everything about the non-conversation machinery. */ @@ -447,9 +448,8 @@ function sanitizeGeneral(raw: Record): GeneralSettings { const out: GeneralSettings = {}; const autoUpdate = boolOrDrop(raw.autoUpdate); if (autoUpdate !== undefined) out.autoUpdate = autoUpdate; - for (const key of ["language", "sttLanguage"] as const) { - if (raw[key] === "en" || raw[key] === "ru") out[key] = raw[key]; - } + if (isLanguage(raw.language)) out.language = raw.language; + if (isSttLanguage(raw.sttLanguage)) out.sttLanguage = raw.sttLanguage; return out; } diff --git a/src/server/settings-body.ts b/src/server/settings-body.ts index ede9c56..1aff88a 100644 --- a/src/server/settings-body.ts +++ b/src/server/settings-body.ts @@ -12,7 +12,7 @@ */ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; -import { isHexColor, type ChatSettings } from "../core"; +import { isHexColor, isLanguage, isSttLanguage, type ChatSettings } from "../core"; import type { AgentSettings, AppearanceSettings, @@ -75,7 +75,7 @@ function asRecord(body: unknown): Record | null { return body as Record; } -/** general: { autoUpdate?: boolean } */ +/** Independent interface/STT language choices and automatic updates. */ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; @@ -86,11 +86,13 @@ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { assert.equal(resolveLanguage("en", "ru-RU"), "en"); assert.equal(resolveLanguage("ru", "en-US"), "ru"); }); + it("resolves multilingual only for STT and keeps system defaults independent", () => { + assert.equal(resolveSttLanguage("multi", "ru-RU"), "multi"); + assert.equal(resolveSttLanguage("multi", "en-US"), "multi"); + assert.equal(resolveLanguage("multi", "ru-RU"), "ru"); + assert.equal(resolveSttLanguage(undefined, "ru-RU"), "ru"); + assert.equal(resolveSttLanguage(undefined, "de-DE"), "en"); + assert.equal(resolveSttLanguage("en", "ru-RU"), "en"); + assert.equal(resolveSttLanguage("ru", "en-US"), "ru"); + }); it("validates both independent preferences without changing existing general options", () => { const general = { autoUpdate: false, language: "ru", sttLanguage: "en" }; assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general }); @@ -38,15 +47,25 @@ describe("language preferences", () => { assert.equal(parseGeneralSettingsBody({ sttLanguage: "fr" }).ok, false); assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: null, sttLanguage: "fr", autoUpdate: false } })?.general, { autoUpdate: false }); }); + it("accepts a multilingual STT preference without allowing a multilingual interface", () => { + const general = { autoUpdate: false, language: "ru", sttLanguage: "multi" }; + assert.deepEqual(parseGeneralSettingsBody(general), { ok: true, settings: general }); + assert.deepEqual(sanitizeUserSettings({ version: 1, general })?.general, general); + assert.equal(parseGeneralSettingsBody({ language: "multi" }).ok, false); + assert.deepEqual(sanitizeUserSettings({ version: 1, general: { language: "multi", sttLanguage: "multi" } })?.general, { sttLanguage: "multi" }); + const invalid = parseGeneralSettingsBody({ sttLanguage: "auto" }); + assert(!invalid.ok); + assert.equal(translate("ru", invalid.error), "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»"); + }); it("pins an explicit supported language in the STT provider request", () => { - for (const language of ["en", "ru"] as const) { + for (const language of ["en", "ru", "multi"] as const) { const url = new URL(dictationUrl(language)); assert.equal(url.protocol, "wss:"); assert.equal(url.hostname, "api.deepgram.com"); - assert.equal(url.searchParams.get("language"), language); - assert.equal(url.searchParams.get("model"), "nova-3"); - assert.equal(url.searchParams.get("encoding"), "linear16"); - assert.equal(url.searchParams.get("sample_rate"), "16000"); + assert.deepEqual(Object.fromEntries(url.searchParams), { + model: "nova-3", language, encoding: "linear16", sample_rate: "16000", + channels: "1", smart_format: "true", punctuate: "true", endpointing: "300", + }); } }); }); diff --git a/tests/language-select-interaction.test.tsx b/tests/language-select-interaction.test.tsx index c4a1c5b..e05d175 100644 --- a/tests/language-select-interaction.test.tsx +++ b/tests/language-select-interaction.test.tsx @@ -6,7 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import { parseHTML } from "linkedom"; import { I18nProvider, LanguageSelect, useI18n } from "../components/i18n.tsx"; import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT } from "../components/menu-shell.tsx"; -import { translate, type GeneralSettings } from "../src/core/index.ts"; +import { dictationUrl, translate, type GeneralSettings, type SttLanguage } from "../src/core/index.ts"; const dom = parseHTML(""); Object.assign(globalThis, { @@ -27,6 +27,7 @@ let writes: GeneralSettings[]; let failSave = false; let holdSave: (() => Promise) | undefined; let recording: (active: boolean) => void; +let resolvedSttLanguage: SttLanguage; function element(selector: string): HTMLElement { const found = document.querySelector(selector); @@ -45,8 +46,8 @@ async function pick(text: string) { assert(option, `expected option ${text}`); await click(option); } -async function mount() { - general = { language: "ru", sttLanguage: "en", autoUpdate: false }; +async function mount(initialGeneral: GeneralSettings = { language: "ru", sttLanguage: "en", autoUpdate: false }) { + general = initialGeneral; writes = []; failSave = false; holdSave = undefined; globalThis.fetch = async (input, init) => { const url = String(input); @@ -65,6 +66,7 @@ async function mount() { host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); function Controls() { recording = useI18n().setDictating; + resolvedSttLanguage = useI18n().sttLanguage; return <>; } await act(async () => { root!.render(); }); @@ -77,7 +79,7 @@ it("picks languages through the shared menu and preserves the other language and await mount(); assert.equal(document.querySelector("select"), null); await click(trigger("sttLanguage")); - assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3); + assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 4); assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("English")); assert(document.querySelector('[role="menu"]')?.textContent?.includes("Язык системы")); await pick("Русский"); @@ -88,6 +90,61 @@ it("picks languages through the shared menu and preserves the other language and assert.deepEqual(general, { sttLanguage: "ru", autoUpdate: false }); }); +for (const language of ["en", "ru"] as const) it(`saves multilingual STT from both selectors and restores it on reload (${language})`, async () => { + const label = translate(language, "Multilingual"); + const shortLabel = translate(language, "Multi"); + await mount({ language, sttLanguage: "en", autoUpdate: false }); + await click(trigger("language")); + assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3); + assert(!document.querySelector('[role="menu"]')?.textContent?.includes(label)); + await click(trigger("sttLanguage", true)); await pick(label); + await click(trigger("sttLanguage", true)); + assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3); + assert(!document.querySelector('[role="menu"]')?.textContent?.includes(translate(language, "System default"))); + await click(document.body); + assert.deepEqual(general, { language, sttLanguage: "multi", autoUpdate: false }); + assert.equal(resolvedSttLanguage, "multi"); + assert.equal(new URL(dictationUrl(resolvedSttLanguage)).searchParams.get("language"), "multi"); + assert(trigger("sttLanguage").textContent?.includes(label)); + assert(trigger("sttLanguage", true).textContent?.includes(shortLabel)); + const saved = general; + await act(async () => root!.unmount()); root = null; host.remove(); + await mount(saved); + assert.equal(resolvedSttLanguage, "multi"); + await click(trigger("sttLanguage")); + assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes(label)); + await pick(translate(language, "System default")); + assert.equal(resolvedSttLanguage, "en", "system default follows the browser, independently of the interface"); + assert.deepEqual(general, { language, autoUpdate: false }); + await click(trigger("sttLanguage")); await pick(label); + assert.deepEqual(general, { language, sttLanguage: "multi", autoUpdate: false }); +}); + +for (const system of ["en", "ru"] as const) it(`shows the resolved system language in chat without an Auto option or an implicit save (${system})`, async () => { + const browserLanguages = navigator.languages; + Object.assign(navigator, { languages: [system === "ru" ? "ru-RU" : "en-US"] }); + try { + const label = system === "ru" ? "Русский" : "English"; + await mount({ language: "ru", autoUpdate: false }); + assert(trigger("sttLanguage", true).textContent?.includes(label)); + assert(!trigger("sttLanguage", true).textContent?.includes("Авто")); + await click(trigger("sttLanguage", true)); + assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 3); + assert(!document.querySelector('[role="menu"]')?.textContent?.includes("Авто")); + assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes(label)); + assert.equal(writes.length, 0); + assert.deepEqual(general, { language: "ru", autoUpdate: false }); + await click(trigger("sttLanguage")); + assert.equal(document.querySelectorAll('[role="menuitemradio"]').length, 4); + assert(element('[role="menuitemradio"][aria-checked="true"]').textContent?.includes("Язык системы")); + await click(trigger("sttLanguage", true)); await pick(label); + assert.deepEqual(general, { language: "ru", sttLanguage: system, autoUpdate: false }); + assert.equal(writes.length, 1, "an explicit pick pins the language even when it matches the system"); + } finally { + Object.assign(navigator, { languages: browserLanguages }); + } +}); + it("dismisses outside clicks and Escape, and opening another selector balances menu scroll locks", async () => { await mount(); let opened = 0; @@ -108,7 +165,7 @@ it("dismisses outside clicks and Escape, and opening another selector balances m }); it("locks both STT selectors and closes an open menu when recording begins", async () => { - await mount(); await click(trigger("sttLanguage", true)); + await mount({ language: "ru", sttLanguage: "multi", autoUpdate: false }); await click(trigger("sttLanguage", true)); await act(async () => recording(true)); assert.equal(document.querySelector('[role="menu"]'), null); assert((trigger("sttLanguage") as HTMLButtonElement).disabled); @@ -119,16 +176,17 @@ it("locks both STT selectors and closes an open menu when recording begins", asy assert(!(trigger("sttLanguage", true) as HTMLButtonElement).disabled); assert.equal(trigger("sttLanguage", true).getAttribute("aria-expanded"), "false"); assert.equal(writes.length, 0); + assert.equal(resolvedSttLanguage, "multi"); }); it("shows a localized save failure without changing the selected language and allows retry", async () => { await mount(); failSave = true; - await click(trigger("sttLanguage")); await pick("Русский"); + await click(trigger("sttLanguage")); await pick("Несколько языков"); assert.equal(element('[role="alert"]').textContent, translate("ru", "Could not save language settings")); assert.equal(general.sttLanguage, "en"); assert(trigger("sttLanguage").textContent?.includes("English")); failSave = false; - await click(trigger("sttLanguage")); await pick("Русский"); - assert.equal(general.sttLanguage, "ru"); assert.equal(document.querySelector('[role="alert"]'), null); + await click(trigger("sttLanguage")); await pick("Несколько языков"); + assert.equal(general.sttLanguage, "multi"); assert.equal(document.querySelector('[role="alert"]'), null); }); it("serializes rapid picks in independent selectors without losing either preference", async () => { diff --git a/tests/settings-store.test.ts b/tests/settings-store.test.ts index e3ada67..225de08 100644 --- a/tests/settings-store.test.ts +++ b/tests/settings-store.test.ts @@ -48,6 +48,12 @@ describe("the settings store", () => { assert.ok(out); assert.deepEqual(out.graph, { enabled: false }); }); + it("persists multilingual STT independently of the interface language and update preference", () => { + const general = { language: "ru", sttLanguage: "multi", autoUpdate: false } as const; + writeSettings({ version: 1, general }); + assert.deepEqual(JSON.parse(fs.readFileSync(SETTINGS_FILE, "utf-8")).general, general); + assert.deepEqual(readSettings()?.general, general); + }); it("a chat section on disk is IGNORED — the field died with per-session models (owner, 2026-09-28)", () => { fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });