From 76aeb26d3897df6b30258aa849c133eedaa62ec4 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:27:46 -0500 Subject: [PATCH 01/18] test(dashboard): reproduce engine page save reverts and 400s EngineConfigPage seeds its form once at load and PUTs the whole engine sub-object from that copy. The server replaces the sub-object row, so a save writes back every field the page loaded, including ones it does not show. The new tests reproduce four results of that: - Aggressive: thresholds, tool strategies, and the summarizer switch that the settings tab saved after the page loaded go back to the page's copy. - Ultra, default install: every save returns 400. The form sends modelPath "" and the route requires a non-empty string. - Ultra: a save drops ultra.enabled, because the page strips it from the body and the server replaces the row. - Ultra: emptying the model path returns 400; it should clear the path. Two more cases pin the fixed behavior: a save whose fresh read fails writes nothing, and a second save does not resend the first one's field. The UI test runs the page against a fake route that applies the real update schema. The node test runs the same flows through the real settings module and imports the save helper the fix adds, so it fails on this commit with a missing-module error. --- .../compression/engine-config-save.test.ts | 156 ++++++++++++++ .../engine-config-page-partial-save.test.tsx | 196 ++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 tests/unit/compression/engine-config-save.test.ts create mode 100644 tests/unit/ui/engine-config-page-partial-save.test.tsx diff --git a/tests/unit/compression/engine-config-save.test.ts b/tests/unit/compression/engine-config-save.test.ts new file mode 100644 index 000000000000..721cbafc0414 --- /dev/null +++ b/tests/unit/compression/engine-config-save.test.ts @@ -0,0 +1,156 @@ +import { describe, it, beforeEach, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-engine-config-save-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../src/lib/db/core.ts"); +const { getCompressionSettings, updateCompressionSettings } = + await import("../../../src/lib/db/compression.ts"); +const { compressionSettingsUpdateSchema } = + await import("../../../src/shared/validation/compressionConfigSchemas.ts"); +const { aggressiveEngine, ultraEngine } = + await import("../../../open-sse/services/compression/engines/cavemanAdapter.ts"); +const { seedEngineForm, buildEngineDetailUpdate } = + await import("../../../src/shared/components/compression/engineConfigSave.ts"); + +type Settings = Record; +type Engine = typeof aggressiveEngine; + +beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +async function readSettings(): Promise { + return (await getCompressionSettings()) as unknown as Settings; +} + +// Mirrors PUT /api/settings/compression: the route's schema validates the body, then the +// settings are written. +async function put(body: Settings): Promise { + const parsed = compressionSettingsUpdateSchema.safeParse(body); + if (!parsed.success) return 400; + await updateCompressionSettings(parsed.data as Parameters[0]); + return 200; +} + +// An engine page save: the form was seeded from `loadedSettings`, the operator applied +// `edits`, and the body is built from a read made at save time. +async function pageSave( + engine: Engine, + subKey: string, + loadedSettings: Settings, + edits: Settings +): Promise { + const loaded = seedEngineForm(engine.id, engine.getConfigSchema(), loadedSettings[subKey]); + const current = await readSettings(); + return put({ + [subKey]: buildEngineDetailUpdate(loaded, { ...loaded, ...edits }, current[subKey]), + }); +} + +describe("engine config page save", () => { + it("keeps aggressive fields another page saved after the engine page loaded", async () => { + const loadedSettings = await readSettings(); + const now = (await readSettings()).aggressive as Settings & { + thresholds: Settings; + toolStrategies: Settings; + }; + const fromSettingsTab = { + ...now, + thresholds: { ...now.thresholds, fullSummary: 9 }, + toolStrategies: { ...now.toolStrategies, json: false }, + summarizerEnabled: false, + }; + assert.equal(await put({ aggressive: fromSettingsTab }), 200); + + const status = await pageSave(aggressiveEngine, "aggressive", loadedSettings, { + maxTokensPerMessage: 4096, + }); + + assert.equal(status, 200); + assert.deepEqual((await readSettings()).aggressive, { + ...fromSettingsTab, + maxTokensPerMessage: 4096, + }); + }); + + it("saves the ultra page on a default install, where the form shows an empty model path", async () => { + const loadedSettings = await readSettings(); + const before = loadedSettings.ultra as Settings; + assert.equal(before.modelPath, undefined); + + const status = await pageSave(ultraEngine, "ultra", loadedSettings, { compressionRate: 0.4 }); + + assert.equal(status, 200); + assert.deepEqual((await readSettings()).ultra, { ...before, compressionRate: 0.4 }); + }); + + it("keeps ultra.enabled and the model path when the ultra page saves another field", async () => { + const now = (await readSettings()).ultra as Settings; + assert.equal(await put({ ultra: { ...now, enabled: true, modelPath: "/models/ultra" } }), 200); + const loadedSettings = await readSettings(); + + const status = await pageSave(ultraEngine, "ultra", loadedSettings, { compressionRate: 0.4 }); + + assert.equal(status, 200); + assert.deepEqual((await readSettings()).ultra, { + ...now, + enabled: true, + modelPath: "/models/ultra", + compressionRate: 0.4, + }); + }); + + it("clears the stored model path when the field is emptied", async () => { + const now = (await readSettings()).ultra as Settings; + assert.equal(await put({ ultra: { ...now, modelPath: "/models/ultra" } }), 200); + const loadedSettings = await readSettings(); + + const status = await pageSave(ultraEngine, "ultra", loadedSettings, { modelPath: " " }); + + assert.equal(status, 200); + assert.deepEqual((await readSettings()).ultra, now); + }); + + it("never writes enabled from the page form", async () => { + const now = (await readSettings()).ultra as Settings; + assert.equal(await put({ ultra: { ...now, enabled: true } }), 200); + const loadedSettings = await readSettings(); + + const status = await pageSave(ultraEngine, "ultra", loadedSettings, { enabled: false }); + + assert.equal(status, 200); + assert.equal(((await readSettings()).ultra as Settings).enabled, true); + }); + + it("sends back the stored sub-object when nothing was edited", async () => { + const settings = await readSettings(); + const loaded = seedEngineForm( + aggressiveEngine.id, + aggressiveEngine.getConfigSchema(), + settings.aggressive + ); + + assert.deepEqual( + buildEngineDetailUpdate(loaded, loaded, settings.aggressive), + settings.aggressive + ); + }); +}); diff --git a/tests/unit/ui/engine-config-page-partial-save.test.tsx b/tests/unit/ui/engine-config-page-partial-save.test.tsx new file mode 100644 index 000000000000..51a2d70f39a5 --- /dev/null +++ b/tests/unit/ui/engine-config-page-partial-save.test.tsx @@ -0,0 +1,196 @@ +// @vitest-environment jsdom +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EngineConfigPage } from "@/shared/components/compression/EngineConfigPage"; +import { compressionSettingsUpdateSchema } from "@/shared/validation/compressionConfigSchemas"; +import { + aggressiveEngine, + ultraEngine, +} from "@omniroute/open-sse/services/compression/engines/cavemanAdapter.ts"; +import { + DEFAULT_AGGRESSIVE_CONFIG, + DEFAULT_ULTRA_CONFIG, +} from "@omniroute/open-sse/services/compression/types.ts"; + +type Settings = Record; + +// The same fields GET /api/compression/engines returns, taken from the real engines. +const ENGINES = { + engines: [aggressiveEngine, ultraEngine].map((engine) => ({ + id: engine.id, + name: engine.name, + description: engine.description, + icon: engine.icon, + stackable: engine.stackable, + stackPriority: engine.stackPriority, + metadata: engine.metadata, + configSchema: engine.getConfigSchema(), + })), +}; + +// Stands in for /api/settings/compression. A PUT is checked against the real update schema, +// and each key in its body replaces the stored sub-object whole, as updateCompressionSettings +// does. `readsFail` makes later GETs fail. +function startServer(initial: Settings) { + let stored: Settings = JSON.parse(JSON.stringify(initial)); + const server = { + readsFail: false, + puts: [] as { body: Settings; status: number }[], + get stored() { + return stored; + }, + // A save made on another page, such as the compression settings tab. + write(patch: Settings) { + stored = { ...stored, ...patch }; + }, + }; + const respond = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const { pathname } = new URL(String(input), "http://localhost"); + if (pathname === "/api/compression/engines") return respond(ENGINES); + if (pathname === "/api/context/analytics/engine") { + return respond({ engineId: "", runs: 0, tokensSaved: 0, avgSavingsPercent: 0, days: 7 }); + } + if (pathname === "/api/settings/compression") { + if (init?.method !== "PUT") { + return server.readsFail ? respond({ error: "unavailable" }, 500) : respond(stored); + } + const body = JSON.parse(String(init.body)) as Settings; + const parsed = compressionSettingsUpdateSchema.safeParse(body); + server.puts.push({ body, status: parsed.success ? 200 : 400 }); + if (!parsed.success) return respond({ error: "Invalid request" }, 400); + stored = { ...stored, ...parsed.data }; + return respond(stored); + } + return respond(null, 404); + }) + ); + return server; +} + +async function settle() { + for (let i = 0; i < 3; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } +} + +function inputFor(label: string): HTMLInputElement { + const input = screen.getByText(label).closest("label")?.querySelector("input"); + if (!input) throw new Error(`no input for ${label}`); + return input; +} + +async function renderPage(engineId: string) { + render(); + await settle(); +} + +async function save() { + fireEvent.click(screen.getByRole("button", { name: "Save" })); + await settle(); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("EngineConfigPage saves only what the operator changed", () => { + it("keeps Aggressive thresholds and tool strategies saved elsewhere after the page loaded", async () => { + const server = startServer({ aggressive: DEFAULT_AGGRESSIVE_CONFIG }); + await renderPage("aggressive"); + // The compression settings tab edits the same sub-object after this page loaded. + const fromSettingsTab = { + ...DEFAULT_AGGRESSIVE_CONFIG, + thresholds: { ...DEFAULT_AGGRESSIVE_CONFIG.thresholds, fullSummary: 9 }, + toolStrategies: { ...DEFAULT_AGGRESSIVE_CONFIG.toolStrategies, json: false }, + summarizerEnabled: false, + }; + server.write({ aggressive: fromSettingsTab }); + + fireEvent.change(inputFor("Maximum tokens per message"), { target: { value: "4096" } }); + await save(); + + expect(server.puts.at(-1)?.status).toBe(200); + expect(server.stored.aggressive).toEqual({ ...fromSettingsTab, maxTokensPerMessage: 4096 }); + }); + + it("saves the Ultra page on a default install that has no model path", async () => { + const server = startServer({ ultra: DEFAULT_ULTRA_CONFIG }); + await renderPage("ultra"); + + fireEvent.change(inputFor("Compression rate"), { target: { value: "0.4" } }); + await save(); + + expect(server.puts.at(-1)?.status).toBe(200); + expect(server.stored.ultra).toEqual({ ...DEFAULT_ULTRA_CONFIG, compressionRate: 0.4 }); + expect(screen.queryByText("Failed to save configuration.")).toBeNull(); + }); + + it("keeps ultra.enabled when the Ultra page saves", async () => { + const server = startServer({ + ultra: { ...DEFAULT_ULTRA_CONFIG, enabled: true, modelPath: "/models/ultra" }, + }); + await renderPage("ultra"); + + fireEvent.change(inputFor("Compression rate"), { target: { value: "0.4" } }); + await save(); + + expect(server.puts.at(-1)?.status).toBe(200); + expect(server.stored.ultra).toEqual({ + ...DEFAULT_ULTRA_CONFIG, + enabled: true, + modelPath: "/models/ultra", + compressionRate: 0.4, + }); + }); + + it("clears a stored model path when the field is emptied", async () => { + const server = startServer({ ultra: { ...DEFAULT_ULTRA_CONFIG, modelPath: "/models/ultra" } }); + await renderPage("ultra"); + + fireEvent.change(inputFor("Model path"), { target: { value: " " } }); + await save(); + + expect(server.puts.at(-1)?.status).toBe(200); + expect(server.stored.ultra).toEqual(DEFAULT_ULTRA_CONFIG); + }); + + it("fails the save without writing when the current settings cannot be read", async () => { + const server = startServer({ aggressive: DEFAULT_AGGRESSIVE_CONFIG }); + await renderPage("aggressive"); + server.readsFail = true; + + fireEvent.change(inputFor("Maximum tokens per message"), { target: { value: "4096" } }); + await save(); + + expect(server.puts).toHaveLength(0); + expect(screen.getByText("Failed to save configuration.")).toBeTruthy(); + }); + + it("sends a second save's change without resending the first one", async () => { + const server = startServer({ aggressive: DEFAULT_AGGRESSIVE_CONFIG }); + await renderPage("aggressive"); + + fireEvent.change(inputFor("Maximum tokens per message"), { target: { value: "4096" } }); + await save(); + // Another page sets the same field back after the first save. + server.write({ aggressive: { ...DEFAULT_AGGRESSIVE_CONFIG, maxTokensPerMessage: 1024 } }); + + fireEvent.change(inputFor("Minimum savings threshold"), { target: { value: "0.2" } }); + await save(); + + expect(server.stored.aggressive).toEqual({ + ...DEFAULT_AGGRESSIVE_CONFIG, + maxTokensPerMessage: 1024, + minSavingsThreshold: 0.2, + }); + }); +}); From ea78466121c100d037fe5c80b6c98d436101e58e Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:31:19 -0500 Subject: [PATCH 02/18] test(dashboard): cover the ultra preview on a default install The Ultra page's Preview button sends the whole form as config.ultra, including the empty model path the form shows by default. The preview route checks that config against the same strict schema as a save, so the preview returns 400 and the page shows "Preview failed." on every install that has no model path set. The UI test clicks Preview on a default install against a fake route that applies the preview schema. The node test checks a preview config built from the seeded form with that schema; it needs the helper the fix adds. --- .../compression/engine-config-save.test.ts | 14 ++++++-- .../engine-config-page-partial-save.test.tsx | 34 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/tests/unit/compression/engine-config-save.test.ts b/tests/unit/compression/engine-config-save.test.ts index 721cbafc0414..f50cc2f9aeb2 100644 --- a/tests/unit/compression/engine-config-save.test.ts +++ b/tests/unit/compression/engine-config-save.test.ts @@ -11,11 +11,11 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../../src/lib/db/core.ts"); const { getCompressionSettings, updateCompressionSettings } = await import("../../../src/lib/db/compression.ts"); -const { compressionSettingsUpdateSchema } = +const { compressionPreviewConfigSchema, compressionSettingsUpdateSchema } = await import("../../../src/shared/validation/compressionConfigSchemas.ts"); const { aggressiveEngine, ultraEngine } = await import("../../../open-sse/services/compression/engines/cavemanAdapter.ts"); -const { seedEngineForm, buildEngineDetailUpdate } = +const { seedEngineForm, buildEngineDetailUpdate, withoutEmptyText } = await import("../../../src/shared/components/compression/engineConfigSave.ts"); type Settings = Record; @@ -153,4 +153,14 @@ describe("engine config page save", () => { settings.aggressive ); }); + + it("builds an ultra preview config that the preview schema accepts on a default install", async () => { + const settings = await readSettings(); + const form = seedEngineForm(ultraEngine.id, ultraEngine.getConfigSchema(), settings.ultra); + assert.equal(form.modelPath, ""); + + const parsed = compressionPreviewConfigSchema.safeParse({ ultra: withoutEmptyText(form) }); + + assert.equal(parsed.success, true); + }); }); diff --git a/tests/unit/ui/engine-config-page-partial-save.test.tsx b/tests/unit/ui/engine-config-page-partial-save.test.tsx index 51a2d70f39a5..e1136753017f 100644 --- a/tests/unit/ui/engine-config-page-partial-save.test.tsx +++ b/tests/unit/ui/engine-config-page-partial-save.test.tsx @@ -2,7 +2,10 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { EngineConfigPage } from "@/shared/components/compression/EngineConfigPage"; -import { compressionSettingsUpdateSchema } from "@/shared/validation/compressionConfigSchemas"; +import { + compressionPreviewConfigSchema, + compressionSettingsUpdateSchema, +} from "@/shared/validation/compressionConfigSchemas"; import { aggressiveEngine, ultraEngine, @@ -30,12 +33,14 @@ const ENGINES = { // Stands in for /api/settings/compression. A PUT is checked against the real update schema, // and each key in its body replaces the stored sub-object whole, as updateCompressionSettings -// does. `readsFail` makes later GETs fail. +// does. `readsFail` makes later GETs fail. A preview's config is checked against the preview +// route's schema. function startServer(initial: Settings) { let stored: Settings = JSON.parse(JSON.stringify(initial)); const server = { readsFail: false, puts: [] as { body: Settings; status: number }[], + previews: [] as { config: unknown; status: number }[], get stored() { return stored; }, @@ -68,6 +73,19 @@ function startServer(initial: Settings) { stored = { ...stored, ...parsed.data }; return respond(stored); } + if (pathname === "/api/compression/preview") { + const { config } = JSON.parse(String(init?.body)) as { config?: unknown }; + const parsed = compressionPreviewConfigSchema.optional().safeParse(config); + server.previews.push({ config, status: parsed.success ? 200 : 400 }); + if (!parsed.success) return respond({ error: "Invalid request" }, 400); + return respond({ + original: "original text", + compressed: "compressed text", + originalTokens: 4, + compressedTokens: 2, + savingsPct: 50, + }); + } return respond(null, 404); }) ); @@ -193,4 +211,16 @@ describe("EngineConfigPage saves only what the operator changed", () => { minSavingsThreshold: 0.2, }); }); + + it("previews the Ultra engine on a default install that has no model path", async () => { + const server = startServer({ ultra: DEFAULT_ULTRA_CONFIG }); + await renderPage("ultra"); + + fireEvent.click(screen.getByRole("button", { name: "Preview" })); + await settle(); + + expect(server.previews.at(-1)?.status).toBe(200); + expect(screen.queryByText("Preview failed.")).toBeNull(); + expect(screen.getByText("compressed text")).toBeTruthy(); + }); }); From 33aa7a7419b2e2d5383c659dc55c9791e87e8027 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:35:27 -0500 Subject: [PATCH 03/18] fix(dashboard): engine pages save only the fields that changed EngineConfigPage read the settings once at load and PUT the whole engine sub-object from that copy on every save. The server replaces the row, so a save wrote back every field the page loaded: - On the Aggressive page, thresholds, tool strategies, and the summarizer switch saved from the settings tab after the page loaded went back to the page's copy. - On the Ultra page, the form's empty model path failed the route's non-empty check, so every save on a default install returned 400. Once a path was set, a save dropped ultra.enabled, which the page strips from the body. A save now reads the stored sub-object again and applies only the fields edited since the page loaded or last saved. enabled is never taken from the form, and an emptied text field removes its key, so clearing the model path clears it. If that read fails, the save fails and writes nothing. Lite keeps its own body, since its server write already merges with the stored row. Preview had the same empty model path problem: the preview route checks config against the same schema, so an Ultra preview returned 400 on a default install. Preview now drops emptied text fields. The seeding and body logic moves to engineConfigSave.ts so node:test can cover it next to the UI test. --- .../compression/EngineConfigPage.tsx | 58 ++++++++++-------- .../compression/engineConfigSave.ts | 60 +++++++++++++++++++ 2 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 src/shared/components/compression/engineConfigSave.ts diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx index 4c05af2caafd..51973d550bb9 100644 --- a/src/shared/components/compression/EngineConfigPage.tsx +++ b/src/shared/components/compression/EngineConfigPage.tsx @@ -4,6 +4,11 @@ import { useEffect, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types"; import { EngineConfigForm } from "@/shared/components/compression/EngineConfigForm"; +import { + buildEngineDetailUpdate, + seedEngineForm, + withoutEmptyText, +} from "@/shared/components/compression/engineConfigSave"; // ── Types ───────────────────────────────────────────────────────────────── @@ -119,6 +124,8 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { // ── Data state ────────────────────────────────────────────────────────── const [engine, setEngine] = useState(null); const [configState, setConfigState] = useState>({}); + // Form values as last loaded or saved. A save sends only the fields changed since. + const [savedConfig, setSavedConfig] = useState>({}); const [analytics, setAnalytics] = useState(null); const [loadError, setLoadError] = useState(null); const [loading, setLoading] = useState(true); @@ -167,24 +174,13 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { // the on/off + level moved to the panel. 404/null/missing = schema defaults. const subKey = SETTINGS_SUBOBJECT[engineId]; const stored = subKey ? settingsData?.[subKey] : undefined; - const currentConfig: Record = - stored && typeof stored === "object" ? (stored as Record) : {}; if (!cancelled) { if (analyticsData) setAnalytics(analyticsData); setEngine(foundEngine); - // Seed configState from defaultValues then override with the stored sub-object. - const defaults: Record = {}; - for (const field of foundEngine?.configSchema ?? []) { - defaults[field.key] = field.defaultValue; - } - // Do not seed lite.maxToolLength from the schema default. Persisting 2000 - // would freeze the cap in settings and hide OMNIROUTE_LITE_MAX_TOOL_LENGTH. - // The form still shows 2000 via field.defaultValue until the operator edits it. - if (engineId === "lite" && currentConfig.maxToolLength === undefined) { - delete defaults.maxToolLength; - } - setConfigState({ ...defaults, ...currentConfig }); + const seeded = seedEngineForm(engineId, foundEngine?.configSchema ?? [], stored); + setConfigState(seeded); + setSavedConfig(seeded); setLoading(false); } } @@ -207,14 +203,13 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { setSaveError(null); return; } - // Strip the `enabled` key — engine on/off is the panel's responsibility. - const { enabled: _ignored, ...formDetail } = configState; - void _ignored; - let detail: Record = formDetail; + const edited = configState; + // Lite sends its two fields as they are; its server write merges with the stored row. + let detail: Record | null = null; if (engineId === "lite") { - const raw = formDetail.maxToolLength; - const compressToolResults = formDetail.compressToolResults !== false; - if (!Object.prototype.hasOwnProperty.call(formDetail, "maxToolLength")) { + const raw = edited.maxToolLength; + const compressToolResults = edited.compressToolResults !== false; + if (!Object.prototype.hasOwnProperty.call(edited, "maxToolLength")) { detail = { compressToolResults }; } else if (typeof raw === "number" && Number.isFinite(raw)) { const n = Math.floor(raw); @@ -230,12 +225,27 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { setSaving(true); setSaveError(null); try { + if (!detail) { + // The server replaces the whole sub-object, so the body starts from the copy stored + // now and changes only the fields edited here. A copy this page loaded earlier would + // write back fields another page saved since. + const current = (await fetch("/api/settings/compression").then((r) => + r.ok ? r.json() : null + )) as CompressionSettings | null; + if (!current) { + setSaveError(t("saveFailed")); + return; + } + detail = buildEngineDetailUpdate(savedConfig, edited, current[subKey]); + } const res = await fetch("/api/settings/compression", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [subKey]: detail }), }); - if (!res.ok) { + if (res.ok) { + setSavedConfig(edited); + } else { setSaveError(t("saveFailed")); } } catch { @@ -262,9 +272,9 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { }, } : engineId === "aggressive" - ? { aggressive: { ...configState } } + ? { aggressive: withoutEmptyText(configState) } : engineId === "ultra" - ? { ultra: { ...configState } } + ? { ultra: withoutEmptyText(configState) } : undefined; const res = await fetch("/api/compression/preview", { method: "POST", diff --git a/src/shared/components/compression/engineConfigSave.ts b/src/shared/components/compression/engineConfigSave.ts new file mode 100644 index 000000000000..41fdf4838f65 --- /dev/null +++ b/src/shared/components/compression/engineConfigSave.ts @@ -0,0 +1,60 @@ +import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types"; + +type FormValues = Record; + +function asRecord(value: unknown): FormValues { + return value && typeof value === "object" ? (value as FormValues) : {}; +} + +// The settings schemas reject an empty string, so an emptied text field means "not set". +function isEmptyText(value: unknown): boolean { + return typeof value === "string" && value.trim() === ""; +} + +/** Form values an engine page shows on load: schema defaults, then the stored sub-object. */ +export function seedEngineForm( + engineId: string, + schema: EngineConfigField[], + stored: unknown +): FormValues { + const current = asRecord(stored); + const defaults: FormValues = {}; + for (const field of schema) { + defaults[field.key] = field.defaultValue; + } + // Do not seed lite.maxToolLength from the schema default. Persisting 2000 + // would freeze the cap in settings and hide OMNIROUTE_LITE_MAX_TOOL_LENGTH. + // The form still shows 2000 via field.defaultValue until the operator edits it. + if (engineId === "lite" && current.maxToolLength === undefined) { + delete defaults.maxToolLength; + } + return { ...defaults, ...current }; +} + +/** + * The sub-object an engine page PUTs on save. The server replaces the whole sub-object row, so + * the body starts from the copy stored at save time and applies only the fields edited since + * `loaded`. `enabled` belongs to the compression panel and is never written here. An emptied + * text field removes its key. + */ +export function buildEngineDetailUpdate( + loaded: FormValues, + edited: FormValues, + stored: unknown +): FormValues { + const next = { ...asRecord(stored) }; + for (const [key, value] of Object.entries(edited)) { + if (key === "enabled" || Object.is(value, loaded[key])) continue; + if (isEmptyText(value)) { + delete next[key]; + } else { + next[key] = value; + } + } + return next; +} + +/** Form values without emptied text fields, so a preview config passes the settings schema. */ +export function withoutEmptyText(values: FormValues): FormValues { + return Object.fromEntries(Object.entries(values).filter(([, value]) => !isEmptyText(value))); +} From e4a005942c1f82559c45090eff96d25d97e3014d Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:37:01 -0500 Subject: [PATCH 04/18] refactor(dashboard): tighten the engine page save code Build the form defaults with Object.fromEntries, and use configState directly in handleSave in place of a local alias. --- .../components/compression/EngineConfigPage.tsx | 11 +++++------ src/shared/components/compression/engineConfigSave.ts | 5 +---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx index 51973d550bb9..55e7a691e933 100644 --- a/src/shared/components/compression/EngineConfigPage.tsx +++ b/src/shared/components/compression/EngineConfigPage.tsx @@ -203,13 +203,12 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { setSaveError(null); return; } - const edited = configState; // Lite sends its two fields as they are; its server write merges with the stored row. let detail: Record | null = null; if (engineId === "lite") { - const raw = edited.maxToolLength; - const compressToolResults = edited.compressToolResults !== false; - if (!Object.prototype.hasOwnProperty.call(edited, "maxToolLength")) { + const raw = configState.maxToolLength; + const compressToolResults = configState.compressToolResults !== false; + if (!Object.prototype.hasOwnProperty.call(configState, "maxToolLength")) { detail = { compressToolResults }; } else if (typeof raw === "number" && Number.isFinite(raw)) { const n = Math.floor(raw); @@ -236,7 +235,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { setSaveError(t("saveFailed")); return; } - detail = buildEngineDetailUpdate(savedConfig, edited, current[subKey]); + detail = buildEngineDetailUpdate(savedConfig, configState, current[subKey]); } const res = await fetch("/api/settings/compression", { method: "PUT", @@ -244,7 +243,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { body: JSON.stringify({ [subKey]: detail }), }); if (res.ok) { - setSavedConfig(edited); + setSavedConfig(configState); } else { setSaveError(t("saveFailed")); } diff --git a/src/shared/components/compression/engineConfigSave.ts b/src/shared/components/compression/engineConfigSave.ts index 41fdf4838f65..7b8117718e2c 100644 --- a/src/shared/components/compression/engineConfigSave.ts +++ b/src/shared/components/compression/engineConfigSave.ts @@ -18,10 +18,7 @@ export function seedEngineForm( stored: unknown ): FormValues { const current = asRecord(stored); - const defaults: FormValues = {}; - for (const field of schema) { - defaults[field.key] = field.defaultValue; - } + const defaults: FormValues = Object.fromEntries(schema.map((f) => [f.key, f.defaultValue])); // Do not seed lite.maxToolLength from the schema default. Persisting 2000 // would freeze the cap in settings and hide OMNIROUTE_LITE_MAX_TOOL_LENGTH. // The form still shows 2000 via field.defaultValue until the operator edits it. From 7229b43c74b162200e71aeb71c7738e00173b266 Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:49:27 -0500 Subject: [PATCH 05/18] test(dashboard): reproduce stale forms and lost saves After a save, the engine page kept showing the values it loaded, so a field another page changed could not be set back to its old value. A save whose response was lost left the baseline unchanged, so rolling that field back was never sent. The lite branch wrote back its load-time switch. A failed settings read at load left the form showing defaults as if they were saved. The UI tests reproduce each case through the page. The node tests drive the save helpers against the real settings store, so release PRs gate on them. --- .../compression/engine-config-save.test.ts | 129 +++++++++++++++++- .../engine-config-page-partial-save.test.tsx | 121 +++++++++++++++- 2 files changed, 242 insertions(+), 8 deletions(-) diff --git a/tests/unit/compression/engine-config-save.test.ts b/tests/unit/compression/engine-config-save.test.ts index f50cc2f9aeb2..15f59b566cff 100644 --- a/tests/unit/compression/engine-config-save.test.ts +++ b/tests/unit/compression/engine-config-save.test.ts @@ -13,10 +13,15 @@ const { getCompressionSettings, updateCompressionSettings } = await import("../../../src/lib/db/compression.ts"); const { compressionPreviewConfigSchema, compressionSettingsUpdateSchema } = await import("../../../src/shared/validation/compressionConfigSchemas.ts"); -const { aggressiveEngine, ultraEngine } = +const { aggressiveEngine, liteEngine, ultraEngine } = await import("../../../open-sse/services/compression/engines/cavemanAdapter.ts"); -const { seedEngineForm, buildEngineDetailUpdate, withoutEmptyText } = - await import("../../../src/shared/components/compression/engineConfigSave.ts"); +const { + seedEngineForm, + buildEngineDetailUpdate, + formAfterSave, + forgetSentEdits, + withoutEmptyText, +} = await import("../../../src/shared/components/compression/engineConfigSave.ts"); type Settings = Record; type Engine = typeof aggressiveEngine; @@ -61,7 +66,7 @@ async function pageSave( const loaded = seedEngineForm(engine.id, engine.getConfigSchema(), loadedSettings[subKey]); const current = await readSettings(); return put({ - [subKey]: buildEngineDetailUpdate(loaded, { ...loaded, ...edits }, current[subKey]), + [subKey]: buildEngineDetailUpdate(engine.id, loaded, { ...loaded, ...edits }, current[subKey]), }); } @@ -149,11 +154,125 @@ describe("engine config page save", () => { ); assert.deepEqual( - buildEngineDetailUpdate(loaded, loaded, settings.aggressive), + buildEngineDetailUpdate(aggressiveEngine.id, loaded, loaded, settings.aggressive), settings.aggressive ); }); + it("keeps the lite switch another page changed when the lite page edits the cap", async () => { + assert.equal(await put({ lite: { compressToolResults: true, maxToolLength: 8000 } }), 200); + const loadedSettings = await readSettings(); + assert.equal(await put({ lite: { compressToolResults: false } }), 200); + + const status = await pageSave(liteEngine, "lite", loadedSettings, { maxToolLength: 9000 }); + + assert.equal(status, 200); + assert.deepEqual((await readSettings()).lite, { + compressToolResults: false, + maxToolLength: 9000, + }); + }); + + it("clears the stored lite cap when the cap input is emptied", async () => { + assert.equal(await put({ lite: { compressToolResults: true, maxToolLength: 8000 } }), 200); + const loadedSettings = await readSettings(); + + // A cleared number input holds NaN. + const status = await pageSave(liteEngine, "lite", loadedSettings, { + maxToolLength: Number.NaN, + }); + + assert.equal(status, 200); + assert.deepEqual((await readSettings()).lite, { compressToolResults: true }); + }); + + it("leaves the lite cap unset when the lite page saves the switch", async () => { + const loadedSettings = await readSettings(); + const form = seedEngineForm(liteEngine.id, liteEngine.getConfigSchema(), loadedSettings.lite); + assert.equal("maxToolLength" in form, false); + + const status = await pageSave(liteEngine, "lite", loadedSettings, { + compressToolResults: false, + }); + + assert.equal(status, 200); + assert.deepEqual((await readSettings()).lite, { compressToolResults: false }); + }); + + it("sends a field again after a save whose response was lost", async () => { + const loadedSettings = await readSettings(); + const loaded = seedEngineForm( + ultraEngine.id, + ultraEngine.getConfigSchema(), + loadedSettings.ultra + ); + const sent = { ...loaded, compressionRate: 0.4 }; + // The server applies this save, but the response is lost, so the page reports a failure. + const current = (await readSettings()).ultra; + assert.equal( + await put({ ultra: buildEngineDetailUpdate(ultraEngine.id, loaded, sent, current) }), + 200 + ); + const baseline = forgetSentEdits(loaded, sent); + + // The operator puts the loaded value back and saves again. + const restored = { ...sent, compressionRate: loaded.compressionRate }; + const afterLostSave = (await readSettings()).ultra; + assert.equal( + await put({ + ultra: buildEngineDetailUpdate(ultraEngine.id, baseline, restored, afterLostSave), + }), + 200 + ); + + assert.deepEqual((await readSettings()).ultra, loadedSettings.ultra); + }); + + it("shows another page's value after a save, so typing the loaded value back sends it", async () => { + const loadedSettings = await readSettings(); + const schema = ultraEngine.getConfigSchema(); + const loaded = seedEngineForm(ultraEngine.id, schema, loadedSettings.ultra); + assert.equal( + await put({ ultra: { ...(loadedSettings.ultra as Settings), compressionRate: 0.9 } }), + 200 + ); + + // The page saves a different field. The PUT response is the settings read after the write. + const sent = { ...loaded, minScoreThreshold: 0.6 }; + const current = (await readSettings()).ultra; + assert.equal( + await put({ ultra: buildEngineDetailUpdate(ultraEngine.id, loaded, sent, current) }), + 200 + ); + const written = seedEngineForm(ultraEngine.id, schema, (await readSettings()).ultra); + const shown = formAfterSave(written, sent, sent); + assert.equal(shown.compressionRate, 0.9); + + const retyped = { ...shown, compressionRate: loaded.compressionRate }; + const afterSave = (await readSettings()).ultra; + assert.equal( + await put({ ultra: buildEngineDetailUpdate(ultraEngine.id, written, retyped, afterSave) }), + 200 + ); + + assert.deepEqual((await readSettings()).ultra, { + ...(loadedSettings.ultra as Settings), + minScoreThreshold: 0.6, + }); + }); + + it("keeps an edit typed while a save was in flight", () => { + const sent = { compressionRate: 0.4, modelPath: "" }; + const now = { compressionRate: 0.4, modelPath: "/models/ultra" }; + const written = { compressionRate: 0.4, modelPath: "", enabled: true }; + + assert.deepEqual(formAfterSave(written, sent, now), { + compressionRate: 0.4, + modelPath: "/models/ultra", + enabled: true, + }); + }); + it("builds an ultra preview config that the preview schema accepts on a default install", async () => { const settings = await readSettings(); const form = seedEngineForm(ultraEngine.id, ultraEngine.getConfigSchema(), settings.ultra); diff --git a/tests/unit/ui/engine-config-page-partial-save.test.tsx b/tests/unit/ui/engine-config-page-partial-save.test.tsx index e1136753017f..426892a1d64e 100644 --- a/tests/unit/ui/engine-config-page-partial-save.test.tsx +++ b/tests/unit/ui/engine-config-page-partial-save.test.tsx @@ -8,6 +8,7 @@ import { } from "@/shared/validation/compressionConfigSchemas"; import { aggressiveEngine, + liteEngine, ultraEngine, } from "@omniroute/open-sse/services/compression/engines/cavemanAdapter.ts"; import { @@ -19,7 +20,7 @@ type Settings = Record; // The same fields GET /api/compression/engines returns, taken from the real engines. const ENGINES = { - engines: [aggressiveEngine, ultraEngine].map((engine) => ({ + engines: [aggressiveEngine, ultraEngine, liteEngine].map((engine) => ({ id: engine.id, name: engine.name, description: engine.description, @@ -31,14 +32,26 @@ const ENGINES = { })), }; +// Lite rows merge with the stored row, as mergeLiteSettingsForWrite does: an omitted cap stays, +// and a null cap is cleared. +function mergeLite(existing: unknown, incoming: Settings): Settings { + const merged: Settings = { ...(existing as Settings), ...incoming }; + if (merged.maxToolLength === null) delete merged.maxToolLength; + return merged; +} + // Stands in for /api/settings/compression. A PUT is checked against the real update schema, // and each key in its body replaces the stored sub-object whole, as updateCompressionSettings -// does. `readsFail` makes later GETs fail. A preview's config is checked against the preview +// does, except lite, which merges. `readsFail` makes GETs fail. `loseNextResponse` applies the +// next PUT and then fails the request, as a dropped connection would. `holdWrites` keeps PUTs +// waiting until the returned function runs. A preview's config is checked against the preview // route's schema. function startServer(initial: Settings) { let stored: Settings = JSON.parse(JSON.stringify(initial)); + let held: Promise | null = null; const server = { readsFail: false, + loseNextResponse: false, puts: [] as { body: Settings; status: number }[], previews: [] as { config: unknown; status: number }[], get stored() { @@ -48,6 +61,16 @@ function startServer(initial: Settings) { write(patch: Settings) { stored = { ...stored, ...patch }; }, + holdWrites() { + let release = () => {}; + held = new Promise((resolve) => { + release = () => { + held = null; + resolve(); + }; + }); + return release; + }, }; const respond = (data: unknown, status = 200) => new Response(JSON.stringify(data), { @@ -70,7 +93,14 @@ function startServer(initial: Settings) { const parsed = compressionSettingsUpdateSchema.safeParse(body); server.puts.push({ body, status: parsed.success ? 200 : 400 }); if (!parsed.success) return respond({ error: "Invalid request" }, 400); - stored = { ...stored, ...parsed.data }; + if (held) await held; + const { lite, ...rest } = parsed.data as Settings; + stored = { ...stored, ...rest }; + if (lite) stored.lite = mergeLite(stored.lite, lite as Settings); + if (server.loseNextResponse) { + server.loseNextResponse = false; + throw new TypeError("Failed to fetch"); + } return respond(stored); } if (pathname === "/api/compression/preview") { @@ -212,6 +242,91 @@ describe("EngineConfigPage saves only what the operator changed", () => { }); }); + it("shows the stored values after a save, so a value typed back afterwards is sent", async () => { + const server = startServer({ aggressive: DEFAULT_AGGRESSIVE_CONFIG }); + await renderPage("aggressive"); + // Another page changes a field this page shows, after it loaded. + server.write({ aggressive: { ...DEFAULT_AGGRESSIVE_CONFIG, maxTokensPerMessage: 1024 } }); + + fireEvent.change(inputFor("Minimum savings threshold"), { target: { value: "0.2" } }); + await save(); + + expect(inputFor("Maximum tokens per message").value).toBe("1024"); + + fireEvent.change(inputFor("Maximum tokens per message"), { + target: { value: String(DEFAULT_AGGRESSIVE_CONFIG.maxTokensPerMessage) }, + }); + await save(); + + expect(server.stored.aggressive).toEqual({ + ...DEFAULT_AGGRESSIVE_CONFIG, + minSavingsThreshold: 0.2, + }); + }); + + it("sends a field again after a save whose response was lost", async () => { + const server = startServer({ ultra: DEFAULT_ULTRA_CONFIG }); + await renderPage("ultra"); + + // The server applies the write, but the response never arrives. + server.loseNextResponse = true; + fireEvent.change(inputFor("Compression rate"), { target: { value: "0.4" } }); + await save(); + expect(screen.getByText("Failed to save configuration.")).toBeTruthy(); + expect((server.stored.ultra as Settings).compressionRate).toBe(0.4); + + // The operator puts the loaded value back and saves again. + fireEvent.change(inputFor("Compression rate"), { + target: { value: String(DEFAULT_ULTRA_CONFIG.compressionRate) }, + }); + await save(); + + expect(server.puts.at(-1)?.status).toBe(200); + expect(server.stored.ultra).toEqual(DEFAULT_ULTRA_CONFIG); + }); + + it("keeps an edit typed while a save is in flight", async () => { + const server = startServer({ aggressive: DEFAULT_AGGRESSIVE_CONFIG }); + await renderPage("aggressive"); + const release = server.holdWrites(); + + fireEvent.change(inputFor("Maximum tokens per message"), { target: { value: "4096" } }); + await save(); + fireEvent.change(inputFor("Minimum savings threshold"), { target: { value: "0.2" } }); + release(); + await settle(); + + expect(inputFor("Minimum savings threshold").value).toBe("0.2"); + await save(); + + expect(server.stored.aggressive).toEqual({ + ...DEFAULT_AGGRESSIVE_CONFIG, + maxTokensPerMessage: 4096, + minSavingsThreshold: 0.2, + }); + }); + + it("keeps the Lite switch another page changed when this page edits the cap", async () => { + const server = startServer({ lite: { compressToolResults: true, maxToolLength: 8000 } }); + await renderPage("lite"); + server.write({ lite: { compressToolResults: false, maxToolLength: 8000 } }); + + fireEvent.change(inputFor("Maximum tool-result length"), { target: { value: "9000" } }); + await save(); + + expect(server.puts.at(-1)?.status).toBe(200); + expect(server.stored.lite).toEqual({ compressToolResults: false, maxToolLength: 9000 }); + }); + + it("turns Save off when the stored settings cannot be loaded", async () => { + const server = startServer({ aggressive: DEFAULT_AGGRESSIVE_CONFIG }); + server.readsFail = true; + await renderPage("aggressive"); + + expect(screen.getByText("Failed to load engine information.")).toBeTruthy(); + expect((screen.getByRole("button", { name: "Save" }) as HTMLButtonElement).disabled).toBe(true); + }); + it("previews the Ultra engine on a default install that has no model path", async () => { const server = startServer({ ultra: DEFAULT_ULTRA_CONFIG }); await renderPage("ultra"); From 2bcf1d8e9a9ae4dbce0536eafe592ee63b33d2ac Mon Sep 17 00:00:00 2001 From: Lance Woodson <65194841+woodsonl@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:49:38 -0500 Subject: [PATCH 06/18] fix(dashboard): sync engine page forms with the server After a successful save the page re-seeds the form and its baseline from the PUT response, keeping any edit typed while the request was out. After a failed save the baseline drops the fields that save carried, since the server may have applied it, so the next save sends them again. Lite builds its body the same way as the other engines: the row stored at save time plus the fields edited since the last save. The cap is checked against its range before anything is sent, and a cleared cap sends null. When the settings read fails at load, the page shows the load error and keeps Save off, because the form would otherwise show defaults as saved values. --- .../compression/EngineConfigPage.tsx | 80 +++++++++++-------- .../compression/engineConfigSave.ts | 41 ++++++++-- 2 files changed, 81 insertions(+), 40 deletions(-) diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx index 55e7a691e933..75024cd96740 100644 --- a/src/shared/components/compression/EngineConfigPage.tsx +++ b/src/shared/components/compression/EngineConfigPage.tsx @@ -6,6 +6,8 @@ import type { EngineConfigField } from "@omniroute/open-sse/services/compression import { EngineConfigForm } from "@/shared/components/compression/EngineConfigForm"; import { buildEngineDetailUpdate, + forgetSentEdits, + formAfterSave, seedEngineForm, withoutEmptyText, } from "@/shared/components/compression/engineConfigSave"; @@ -124,7 +126,8 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { // ── Data state ────────────────────────────────────────────────────────── const [engine, setEngine] = useState(null); const [configState, setConfigState] = useState>({}); - // Form values as last loaded or saved. A save sends only the fields changed since. + // The stored values as of the last load or save, in form shape. A save sends the fields + // changed since. const [savedConfig, setSavedConfig] = useState>({}); const [analytics, setAnalytics] = useState(null); const [loadError, setLoadError] = useState(null); @@ -171,9 +174,12 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { } // Detailed config lives in the engine's settings sub-object (when it has one); - // the on/off + level moved to the panel. 404/null/missing = schema defaults. + // the on/off + level moved to the panel. A missing sub-object means schema defaults. const subKey = SETTINGS_SUBOBJECT[engineId]; const stored = subKey ? settingsData?.[subKey] : undefined; + // Without the stored settings the form would show defaults as saved values, so the page + // reports the load error and Save stays off. + if (subKey && !settingsData) setLoadError(t("loadFailed")); if (!cancelled) { if (analyticsData) setAnalytics(analyticsData); @@ -203,51 +209,55 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { setSaveError(null); return; } - // Lite sends its two fields as they are; its server write merges with the stored row. - let detail: Record | null = null; - if (engineId === "lite") { - const raw = configState.maxToolLength; - const compressToolResults = configState.compressToolResults !== false; - if (!Object.prototype.hasOwnProperty.call(configState, "maxToolLength")) { - detail = { compressToolResults }; - } else if (typeof raw === "number" && Number.isFinite(raw)) { - const n = Math.floor(raw); - if (n < 256 || n > 1_000_000) { - setSaveError(t("saveFailed")); - return; - } - detail = { compressToolResults, maxToolLength: n }; - } else { - detail = { compressToolResults, maxToolLength: null }; - } + // Lite's cap must be a whole number in range before anything is sent. + const cap = engineId === "lite" ? configState.maxToolLength : undefined; + if ( + typeof cap === "number" && + Number.isFinite(cap) && + (Math.floor(cap) < 256 || Math.floor(cap) > 1_000_000) + ) { + setSaveError(t("saveFailed")); + return; } + const sent = configState; setSaving(true); setSaveError(null); try { - if (!detail) { - // The server replaces the whole sub-object, so the body starts from the copy stored - // now and changes only the fields edited here. A copy this page loaded earlier would - // write back fields another page saved since. - const current = (await fetch("/api/settings/compression").then((r) => - r.ok ? r.json() : null - )) as CompressionSettings | null; - if (!current) { - setSaveError(t("saveFailed")); - return; - } - detail = buildEngineDetailUpdate(savedConfig, configState, current[subKey]); + // The server replaces the whole sub-object, so the body starts from the copy stored + // now and changes only the fields edited here. A copy this page loaded earlier would + // write back fields another page saved since. + const current = (await fetch("/api/settings/compression").then((r) => + r.ok ? r.json() : null + )) as CompressionSettings | null; + if (!current) { + setSaveError(t("saveFailed")); + return; } + const detail = buildEngineDetailUpdate(engineId, savedConfig, sent, current[subKey]); const res = await fetch("/api/settings/compression", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ [subKey]: detail }), }); - if (res.ok) { - setSavedConfig(configState); - } else { + if (!res.ok) { + setSavedConfig((saved) => forgetSentEdits(saved, sent)); setSaveError(t("saveFailed")); + return; } + // Show what the server now holds, which can include fields another page changed, and + // keep anything typed while the save was out. + const settings = (await res.json().catch(() => null)) as CompressionSettings | null; + const written = seedEngineForm( + engineId, + engine?.configSchema ?? [], + settings?.[subKey] ?? detail + ); + setSavedConfig(written); + setConfigState((now) => formAfterSave(written, sent, now)); } catch { + // The save may have reached the server before the request failed, so the next save + // sends these fields again. + setSavedConfig((saved) => forgetSentEdits(saved, sent)); setSaveError(t("saveFailed")); } finally { setSaving(false); @@ -397,7 +407,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) { {persistable ? (