fix(compression): persist Headroom minRows (set 5 and reload keeps 5) (#8058)

* fix(compression): persist Headroom minRows (set 5 and reload keeps 5)

Fixes diegosouzapw/OmniRoute#8056.

Headroom detail settings had a Save-looking form but EngineConfigPage only
persisted aggressive/ultra via SETTINGS_SUBOBJECT, so minRows always reseeded
to the schema default (8) after reload.

- Add HeadroomConfig + DEFAULT_HEADROOM_CONFIG (minRows: 8)
- Accept headroom in compressionSettingsUpdateSchema (minRows 2..10000)
- Normalize/store headroom in get/updateCompressionSettings
- Register headroom in EngineConfigPage SETTINGS_SUBOBJECT so Save works
- Merge settings.headroom into stacked stepConfig for runtime apply
- Thread minRows through preview API + EngineConfigPage preview payload
- Tests: schema/DB round-trip, engine apply, stacked merge, UI Save→PUT 5

* chore(quality): rebaseline compression.ts + strategySelector.ts own-growth (#8056 headroom minRows)

---------

Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
This commit is contained in:
Ravi Tharuma
2026-07-22 22:44:20 +02:00
committed by GitHub
parent 89bad0fa52
commit e7f965c9cc
9 changed files with 334 additions and 15 deletions

View File

@@ -184,7 +184,7 @@
"open-sse/handlers/videoGeneration.ts": 1275,
"_rebaseline_2026_07_22_8010_codex_responses_engine": "PR #8010 (@JxnLexn) own growth: open-sse/mcp-server/schemas/tools.ts 1497->1505 (+8 = threading the new \"codex-responses\" literal into the compressionConfigureInput strategy/autoTriggerMode Zod enums and setCompressionEngineInput engine enum, mirroring the existing rtk/omniglyph enum entries; no new tool). open-sse/services/compression/strategySelector.ts 1043->1054 (+11 = one new `if (mode === \"codex-responses\")` dispatch branch in runCompression that delegates 100% to the new codexResponsesEngine.apply, mirroring the existing rtk single-mode dispatch, plus threading config.codexResponsesConfig.preserveToolNames into the shared adaptBodyForCompression call at the 3 existing call sites). src/lib/db/compression.ts (untracked, new-file cap 800) 794->845 (+51 = normalizeCodexResponsesConfig, mirroring the existing normalizeRtkConfig normalizer, plus registering \"codex-responses\" in the COMPRESSION_MODES/STACKED_PIPELINE_ENGINE_IDS/SINGLE_MODE_ENGINE sets and the getCompressionSettings load/save switch) — added to the baseline at its current size. All three are cohesive dispatch/normalizer wiring at existing chokepoints (mirroring the prior compression-mode rebaselines #6534/#6556), not extractable without hiding the mode-dispatch boundary. Covered by tests/unit/compression/codex-responses.test.ts (6) + omniglyph-registries.test.ts/types.test.ts (22, updated for the new mode).",
"_rebaseline_2026_07_22_8034_compression_exclusions_persistence": "#8034 (compression exclusions) own growth: src/lib/db/compression.ts 845->850 (+5 = threading the new compressionExclusions field through the existing getCompressionSettings/saveCompressionSettings load/save switch over the shared key_value compression namespace — no new table, no raw SQL). Mirrors the prior compression-field rebaselines (#8010 codex-responses normalizer at the same chokepoint); the load/save switch is a single dispatch boundary, not extractable without hiding it. Covered by the PR's 8 node:test + 3 vitest cases.",
"src/lib/db/compression.ts": 850,
"src/lib/db/compression.ts": 866,
"open-sse/mcp-server/schemas/tools.ts": 1505,
"open-sse/mcp-server/server.ts": 1555,
"open-sse/mcp-server/tools/advancedTools.ts": 1120,
@@ -202,7 +202,7 @@
"_rebaseline_2026_06_26_fidelity_gate_extraction": "Milestone-B fidelity-gate wiring residual: bodyToText+gateAdvance extracted to fidelityGateStep.ts (889->854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).",
"_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop — a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (<cap); the residual growth is the duplicated thin-wrapper signatures + the extracted bodies' dispatch boundary, guarded by a byte-identical parity test (riskGateIntegration). Default off (DEFAULT_COMPRESSION_CONFIG unchanged). Not extractable without hiding the dispatch boundary, mirroring prior compression rebaselines. Structural shrink tracked in #3501.",
"_rebaseline_2026_06_29_5286_memoization": "PR #5286 own growth: strategySelector.ts 899->960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync — principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts (<cap); the chokepoint wiring here is not extractable. Structural shrink of this hot-path file tracked in #3501.",
"open-sse/services/compression/strategySelector.ts": 1054,
"open-sse/services/compression/strategySelector.ts": 1060,
"_rebaseline_2026_07_10_gcf_v3_2_decode": "PR #6838 own growth: new vendored file open-sse/services/compression/engines/headroom/gcf/decode_generic.ts frozen at 880 (> 800 cap). It is the vendored GCF generic-profile decoder (spec v3.2 nested flattening plus the prototype-pollution / hasOwnProperty hardening added in this PR's Gemini review). Kept as one file faithful to upstream gcf-typescript so re-vendoring stays a clean copy rather than a re-split each cycle (sibling generic.ts/scalar.ts stay < cap; extraction would also fragment the file's frozen eslint no-explicit-any suppressions). Round-trip + prototype-pollution regression coverage in tests/unit/compression/headroom-smartcrusher.test.ts. Frozen: only shrinks from here.",
"open-sse/services/compression/engines/headroom/gcf/decode_generic.ts": 880,
"open-sse/services/rateLimitManager.ts": 1035,
@@ -314,7 +314,8 @@
"src/lib/localDb.ts": 808,
"_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail (owner-approved): src/lib/localDb.ts NEW>800 (799->805, +6 re-exports countFreeProxies + recordFreeProxySyncErrors/clearFreeProxySyncErrors/getFreeProxySyncErrors + FreeProxySyncErrors type for #6909 free-pool relay-repair; re-export-only per Hard Rule #2, not extractable).",
"_rebaseline_2026_07_21_8034_compression_exclusions_sidebar": "#8034 (compression exclusions dashboard tab) own growth: sections.ts 796->806 (+10, one new COMPRESSION_CONTEXT_GROUP sidebar item linking /dashboard/compression/exclusions). The file was already 796/800 before this PR (organic growth from prior sidebar entries), so a single new nav item pushed it 6 lines over cap. Freezing at 806 (cannot grow further); the sidebar item array is data, not extractable logic.",
"src/shared/constants/sidebarVisibility/sections.ts": 806
"src/shared/constants/sidebarVisibility/sections.ts": 806,
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size."
},
"testCap": 800,
"testFrozen": {
@@ -369,7 +370,8 @@
"tests/unit/vscode-token-routes.test.ts": 1285,
"tests/unit/web-cookie-providers-new.test.ts": 890,
"_rebaseline_2026_07_12_v3847_mergeprs_tail": "v3.8.47 /merge-prs tail: translator-openai-responses-req.test.ts 1172->1195 (+23 = #6807 reasoning-summary-for-effort-only regression tests). Frozen only shrinks.",
"tests/unit/audio-transcription-handler.test.ts": 824
"tests/unit/audio-transcription-handler.test.ts": 824,
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size."
},
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.",
@@ -440,5 +442,6 @@
"_rebaseline_2026_07_18_basereds_test_realignment": "Base-red sweep own growth (post 102-PR campaign, full-suite realignment): tests/unit/combo-routing-engine.test.ts 3209->3243 (+34 = least-used tests now prime usage through real handleComboChat calls so recordComboRequest keys by the resolved executionKey exactly as production does — #7015 keying); tests/unit/db-migration-runner.test.ts 1491->1499 (+8 = withNonTestEnvironment now also strips node --test tokens from process.execArgv, matching the #7359 isAutomatedTestProcess widening); tests/unit/executor-default-base.test.ts 1523->1527 (+4 = 1M-beta assertion updated for claude-sonnet-4-6 GA #7129). All three are test-fidelity realignments, not extractable.",
"_rebaseline_2026_07_21_7930_pplx_quota_cooldown": "PR #7930 (@artickc) own growth, reconstructed against release/v3.8.49 base-drift: tests/unit/perplexity-web.test.ts 1192->1355 (+163 = two new regression cases — 'Live multi-step: reconstructs answer without status COMPLETED' proving RFC-6902 diff-patched plan_block goals now surface as reasoning_content the same as a materialized plan_block, and 'Advanced-model quota upsell with empty answer surfaces clear error' proving the new advanced_models_quota_low upsell_information detection maps to HTTP 429 + reset_seconds + Retry-After instead of a silent empty-content 502). Most of the PR's original 'multi-step empty content' claims were already independently fixed on release via a different mechanism (extractAnswerFromFinalText + longestMarkdownAnswer); only the two genuinely new, non-conflicting pieces (diff-block plan-goal extraction + quota cooldown) were ported. Covered by the two new tests; not extractable without splitting the whole executor test file.",
"_rebaseline_2026_07_22_v3849_ownGrowth_merge_batch": "OAuthModal(#7735 grok chooser), muse-spark-web(#7528 WS), combo.ts+combo-routing-engine.test(#7301 cooldown-retry) — pre-existing on tip; PricingTab(#7972), ComboDefaultsTab(#8008/#7973) — this train batch. Legitimate own-growth, owner-approved rebaseline.",
"_rebaseline_2026_07_22_providerLimits_webcookie_chain": "providerLimits.ts 1003->1005: own-growth from web-cookie provider usage-fetcher entries (#7994/#8006/#8027 chain) landing after the prior rebaseline."
"_rebaseline_2026_07_22_providerLimits_webcookie_chain": "providerLimits.ts 1003->1005: own-growth from web-cookie provider usage-fetcher entries (#7994/#8006/#8027 chain) landing after the prior rebaseline.",
"_rebaseline_2026_07_22_8056_headroom_minrows": "#8056 (@RaviTharuma, persist Headroom minRows) own growth: src/lib/db/compression.ts 850->866 (+16 HeadroomConfig+DEFAULT_HEADROOM_CONFIG+normalize/store in get/updateCompressionSettings) and open-sse/services/compression/strategySelector.ts 1054->1060 (+6 merge settings.headroom into stacked stepConfig). Cohesive settings-persistence + stacked-merge wiring at existing chokepoints, frozen at new size."
}

View File

@@ -736,7 +736,13 @@ function buildStepOptions(
step: CompressionPipelineStep,
options?: StackOptions
): CompressionEngineApplyOptions {
// Headroom detail (minRows) lives on settings.headroom, not only on step.config.
// Merge it so the stacked runner honors the dashboard value (#8056). Explicit
// step.config still wins so combo pipelines can override per step.
const headroomDetail =
step.engine === "headroom" ? (options?.config?.headroom ?? {}) : {};
const stepConfig: Record<string, unknown> = {
...headroomDetail,
...(step.config ?? {}),
...(step.intensity ? { intensity: step.intensity } : {}),
};

View File

@@ -218,6 +218,8 @@ export interface CompressionConfig {
languageConfig?: CompressionLanguageConfig;
aggressive?: AggressiveConfig;
ultra?: UltraConfig;
/** Headroom SmartCrusher detail settings (minRows gate). */
headroom?: HeadroomConfig;
/** Provider-delegated context editing (Claude/Anthropic only). */
contextEditing?: ContextEditingConfig;
/** Opt-in cache-aligned live-zone compression (default disabled). */
@@ -543,6 +545,24 @@ export const DEFAULT_ULTRA_CONFIG: UltraConfig = {
maxTokensPerMessage: 0,
};
// ─── Headroom SmartCrusher detail settings ───────────────────────────────────
// Persisted under compression settings key `headroom`. Engine apply reads
// minRows from stepConfig, which the stacked runner merges from this sub-object.
/** Configuration for the Headroom SmartCrusher engine detail page. */
export interface HeadroomConfig {
/**
* Minimum number of rows in a homogeneous JSON array to trigger tabular
* compaction. Default 8 (matches DEFAULT_MIN_ROWS in smartcrusher.ts).
* Operators may lower this (e.g. 5) for denser compaction on smaller arrays.
*/
minRows: number;
}
export const DEFAULT_HEADROOM_CONFIG: HeadroomConfig = {
minRows: 8,
};
export type { McpAccessibilityConfig } from "./engines/mcpAccessibility/constants.ts";
export {
DEFAULT_MCP_ACCESSIBILITY_CONFIG,

View File

@@ -93,10 +93,17 @@ function messagesToText(messages: Array<{ role: string; content: unknown }>): st
.join("\n");
}
function buildStep(engine: string, fuzzy?: { enabled: boolean }) {
return engine === "session-dedup" && fuzzy?.enabled
? { engine, config: { fuzzy: { enabled: true } } }
: { engine };
function buildStep(
engine: string,
fuzzy?: { enabled: boolean },
/** Optional detail bag (e.g. headroom.minRows from saved settings). */
detail?: Record<string, unknown>
) {
const config: Record<string, unknown> = { ...(detail ?? {}) };
if (engine === "session-dedup" && fuzzy?.enabled) {
config.fuzzy = { enabled: true };
}
return Object.keys(config).length > 0 ? { engine, config } : { engine };
}
function headroomParticipates(
@@ -131,11 +138,29 @@ async function dispatchCompression(
// (CompressionConfig.riskGate) — uniform across all three branches and type-safe.
// QuantumLock uses the same pattern: when enabled the studio forces cachingContext so the dry-run
// badge shows what WOULD be stabilized in production (real caching gains show in telemetry only).
// When the client/settings carry a headroom detail sub-object, thread it so
// buildStepOptions can merge minRows into the headroom engine stepConfig (#8056).
const headroomDetail =
opts.config && typeof opts.config === "object" && opts.config !== null
? (opts.config as CompressionConfig).headroom
: undefined;
const headroomStepDetail =
headroomDetail && typeof headroomDetail.minRows === "number"
? { minRows: headroomDetail.minRows }
: undefined;
if (opts.engineId) {
const q = quantumExtras(opts.quantumLock);
return applyCompressionAsync(requestBody, "stacked", {
config: {
stackedPipeline: [buildStep(opts.engineId, opts.fuzzyDedup)],
stackedPipeline: [
buildStep(
opts.engineId,
opts.fuzzyDedup,
opts.engineId === "headroom" ? headroomStepDetail : undefined
),
],
...(headroomDetail ? { headroom: headroomDetail } : {}),
...(opts.fidelityGate ? { fidelityGate: opts.fidelityGate } : {}),
...(opts.riskGate ? { riskGate: opts.riskGate } : {}),
...q.configPatch,
@@ -147,7 +172,10 @@ async function dispatchCompression(
const q = quantumExtras(opts.quantumLock);
return applyCompressionAsync(requestBody, "stacked", {
config: {
stackedPipeline: opts.pipeline.map((engine) => buildStep(engine, opts.fuzzyDedup)),
stackedPipeline: opts.pipeline.map((engine) =>
buildStep(engine, opts.fuzzyDedup, engine === "headroom" ? headroomStepDetail : undefined)
),
...(headroomDetail ? { headroom: headroomDetail } : {}),
...(opts.fidelityGate ? { fidelityGate: opts.fidelityGate } : {}),
...(opts.riskGate ? { riskGate: opts.riskGate } : {}),
...q.configPatch,
@@ -236,8 +264,12 @@ export async function POST(req: Request) {
heatmapMode as HeatmapMode | undefined
);
const headroomMinRows =
typeof config?.headroom?.minRows === "number" && Number.isFinite(config.headroom.minRows)
? config.headroom.minRows
: DEFAULT_MIN_ROWS;
const encoderComparison = headroomParticipates(engineId, pipeline, effectiveMode)
? summarizeEncoderCandidates(messages, DEFAULT_MIN_ROWS, countTextTokens)
? summarizeEncoderCandidates(messages, headroomMinRows, countTextTokens)
: null;
// #6461: when fallbackApplied=true, synthesize a deduped reason list from data the

View File

@@ -10,6 +10,7 @@ import {
DEFAULT_COMPRESSION_LANGUAGE_CONFIG,
DEFAULT_COMPRESSION_CONFIG,
DEFAULT_CONTEXT_EDITING_CONFIG,
DEFAULT_HEADROOM_CONFIG,
DEFAULT_MCP_ACCESSIBILITY_CONFIG,
DEFAULT_RTK_CONFIG,
DEFAULT_ULTRA_CONFIG,
@@ -26,6 +27,7 @@ import {
type CodexResponsesConfig,
type ContextEditingConfig,
type EngineToggle,
type HeadroomConfig,
type McpAccessibilityConfig,
type RtkConfig,
type UltraConfig,
@@ -426,6 +428,15 @@ function normalizeAggressiveConfig(value: unknown): AggressiveConfig {
};
}
function normalizeHeadroomConfig(value: unknown): HeadroomConfig {
const record = toRecord(value);
return {
...DEFAULT_HEADROOM_CONFIG,
// Align with engine schema (min 2) and smartcrusher DEFAULT_MIN_ROWS (8).
minRows: boundedInt(record.minRows, DEFAULT_HEADROOM_CONFIG.minRows, 2, 10000),
};
}
function normalizeUltraConfig(value: unknown): UltraConfig {
const record = toRecord(value);
const modelPath = typeof record.modelPath === "string" ? record.modelPath.trim() : "";
@@ -600,6 +611,7 @@ export async function getCompressionSettings(): Promise<CompressionConfig> {
stackedPipeline: normalizeStackedPipeline(undefined),
aggressive: normalizeAggressiveConfig(undefined),
ultra: normalizeUltraConfig(undefined),
headroom: normalizeHeadroomConfig(undefined),
contextBudget: normalizeContextBudgetConfig(undefined),
contextEditing: { ...DEFAULT_CONTEXT_EDITING_CONFIG },
liveZone: { enabled: false },
@@ -708,6 +720,10 @@ export async function getCompressionSettings(): Promise<CompressionConfig> {
case "ultraConfig":
config.ultra = normalizeUltraConfig(parsed);
break;
case "headroom":
case "headroomConfig":
config.headroom = normalizeHeadroomConfig(parsed);
break;
case "contextBudget":
config.contextBudget = normalizeContextBudgetConfig(parsed);
break;

View File

@@ -21,12 +21,14 @@ interface EngineEntry {
// Engines whose detailed config has a dedicated sub-object in the compression
// settings store. The on/off + level for ALL engines now live in the panel
// (/dashboard/context/settings, the `engines` map); only these have a place to
// persist the extra per-engine fields edited on this page. Structural engines
// (lite, headroom, session-dedup, ccr, llmlingua) have no sub-object yet — their
// page keeps the detail form + preview but has nothing extra to persist this phase.
// persist the extra per-engine fields edited on this page. Other structural
// engines (lite, session-dedup, ccr, llmlingua, relevance) still have no
// dedicated sub-object — their page keeps the detail form + preview but has
// nothing extra to persist yet.
const SETTINGS_SUBOBJECT: Record<string, string> = {
aggressive: "aggressive",
ultra: "ultra",
headroom: "headroom",
};
interface CompressionSettings {
@@ -220,12 +222,29 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
setPreviewError(null);
setPreview(null);
try {
// Pass the form's current detail (e.g. headroom.minRows) so preview honors
// unsaved edits and the persisted sub-object after save (#8056).
const detailConfig =
engineId === "headroom"
? {
headroom: {
...(typeof configState.minRows === "number"
? { minRows: configState.minRows }
: {}),
},
}
: engineId === "aggressive"
? { aggressive: { ...configState } }
: engineId === "ultra"
? { ultra: { ...configState } }
: undefined;
const res = await fetch("/api/compression/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
engineId,
messages: [{ role: "user", content: previewText }],
...(detailConfig ? { config: detailConfig } : {}),
}),
});
if (res.ok) {

View File

@@ -153,6 +153,14 @@ export const ultraConfigSchema = z
})
.strict();
/** Headroom SmartCrusher detail settings (persisted under settings.headroom). */
export const headroomConfigSchema = z
.object({
// Matches engine schema min 2 / max 10000 and DEFAULT_MIN_ROWS=8.
minRows: z.number().int().min(2).max(10000).optional(),
})
.strict();
const noConfigSchema = z.object({}).strict();
// Structural engines (session-dedup / ccr / headroom / relevance / llmlingua) do not
@@ -335,6 +343,7 @@ export const compressionSettingsUpdateSchema = z
languageConfig: languageConfigSchema.optional(),
aggressive: aggressiveConfigSchema.optional(),
ultra: ultraConfigSchema.optional(),
headroom: headroomConfigSchema.optional(),
contextBudget: contextBudgetConfigSchema.optional(),
contextEditing: contextEditingConfigSchema.optional(),
liveZone: z.object({ enabled: z.boolean() }).strict().optional(),

View File

@@ -0,0 +1,134 @@
/**
* #8056 — Headroom engine detail settings (minRows) must persist end-to-end.
*
* Covers:
* 1. compressionSettingsUpdateSchema accepts headroom:{minRows:5}
* 2. updateCompressionSettings / getCompressionSettings round-trip minRows=5
* 3. headroom engine apply honors stepConfig.minRows=5 (array of 5 rows compact)
* 4. stacked apply merges settings.headroom into stepConfig
*/
import { describe, it, beforeEach, afterEach, 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";
import { compressionSettingsUpdateSchema } from "../../../src/shared/validation/compressionConfigSchemas.ts";
import {
DEFAULT_COMPRESSION_CONFIG,
DEFAULT_HEADROOM_CONFIG,
type CompressionConfig,
} from "../../../open-sse/services/compression/types.ts";
import { applyStackedCompression } from "../../../open-sse/services/compression/strategySelector.ts";
import { headroomEngine } from "../../../open-sse/services/compression/engines/headroom/index.ts";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-headroom-minrows-"));
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");
beforeEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
afterEach(() => {
core.resetDbInstance();
});
after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});
function makeRows(n: number): Array<Record<string, unknown>> {
return Array.from({ length: n }, (_, i) => ({ id: i, name: `row-${i}`, value: i * 10 }));
}
function baseConfig(overrides: Partial<CompressionConfig> = {}): CompressionConfig {
return {
...DEFAULT_COMPRESSION_CONFIG,
enabled: true,
defaultMode: "stacked",
engines: { headroom: { enabled: true } },
enginesExplicit: true,
stackedPipeline: [{ engine: "headroom" }],
...overrides,
};
}
describe("#8056 headroom minRows persistence", () => {
it("schema accepts headroom.minRows=5", () => {
const result = compressionSettingsUpdateSchema.safeParse({
headroom: { minRows: 5 },
});
assert.equal(result.success, true, JSON.stringify(result.error?.issues));
});
it("schema rejects minRows below 2", () => {
const result = compressionSettingsUpdateSchema.safeParse({
headroom: { minRows: 1 },
});
assert.equal(result.success, false);
});
it("getCompressionSettings defaults headroom.minRows to 8", async () => {
const settings = await getCompressionSettings();
assert.equal(settings.headroom?.minRows, DEFAULT_HEADROOM_CONFIG.minRows);
assert.equal(settings.headroom?.minRows, 8);
});
it("updateCompressionSettings persists minRows=5 across reload", async () => {
await updateCompressionSettings({ headroom: { minRows: 5 } });
const reread = await getCompressionSettings();
assert.equal(reread.headroom?.minRows, 5);
});
it("headroom engine applies with stepConfig.minRows=5 on a 5-row array", () => {
const json = JSON.stringify(makeRows(5));
const body = {
messages: [{ role: "user", content: json }],
};
// Default minRows=8 must NOT compact a 5-row array.
const withDefault = headroomEngine.apply(body);
assert.equal(withDefault.compressed, false, "default minRows=8 should skip 5-row array");
// With minRows=5 it MUST compact.
const withFive = headroomEngine.apply(body, { stepConfig: { minRows: 5 } });
assert.equal(withFive.compressed, true, "minRows=5 should compact a 5-row array");
});
it("stacked pipeline merges settings.headroom.minRows into headroom stepConfig", () => {
const json = JSON.stringify(makeRows(5));
const body = {
messages: [{ role: "user", content: json }],
};
const defaulted = applyStackedCompression(body, [{ engine: "headroom" }], {
config: baseConfig(),
});
assert.equal(
defaulted.compressed,
false,
"without headroom settings, 5-row array should not compact"
);
const withSettings = applyStackedCompression(body, [{ engine: "headroom" }], {
config: baseConfig({ headroom: { minRows: 5 } }),
});
assert.equal(
withSettings.compressed,
true,
"settings.headroom.minRows=5 must merge into stepConfig and compact 5-row array"
);
});
});

View File

@@ -417,4 +417,84 @@ describe("EngineConfigPage", () => {
expect(container).toBeTruthy();
expect(container.parentNode).toBeTruthy();
});
it("#8056: headroom minRows is persistable — Save PUTs headroom:{minRows:5}", async () => {
const settingsPuts: { body: Record<string, unknown> }[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url.includes("/api/compression/engines")) {
return new Response(JSON.stringify(ENGINE_PAYLOAD), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.includes("/api/settings/compression")) {
if (init?.method === "PUT") {
settingsPuts.push({ body: JSON.parse(init.body as string) });
}
return new Response(
JSON.stringify({
enabled: true,
engines: { headroom: { enabled: true } },
headroom: { minRows: 8 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
}
if (url.includes("/api/context/analytics/engine")) {
return new Response(JSON.stringify(ANALYTICS_PAYLOAD), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}
);
const { EngineConfigPage } =
await import("../../../src/shared/components/compression/EngineConfigPage");
let container!: HTMLElement;
await act(async () => {
container = mountInContainer(<EngineConfigPage engineId="headroom" />);
});
await act(async () => {
await Promise.resolve();
});
// Persistable engines show Save (not the "no per-engine override" notice).
expect(container.querySelector("[data-testid='no-detail-store-notice']")).toBeNull();
const saveBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.includes("Save") || b.textContent?.includes("Salvar")
);
expect(saveBtn).toBeTruthy();
// Change minRows 8 → 5 in the number input (React 19 needs native setter).
const numberInput = container.querySelector("input[type='number']") as HTMLInputElement | null;
expect(numberInput).toBeTruthy();
await act(async () => {
if (!numberInput) return;
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value"
)?.set;
nativeInputValueSetter?.call(numberInput, "5");
numberInput.dispatchEvent(new Event("input", { bubbles: true }));
numberInput.dispatchEvent(new Event("change", { bubbles: true }));
});
await act(async () => {
saveBtn?.click();
});
await act(async () => {
await Promise.resolve();
});
expect(settingsPuts.length).toBeGreaterThan(0);
const headroomPut = settingsPuts.find(
(c) => typeof c.body.headroom === "object" && c.body.headroom !== null
);
expect(headroomPut).toBeDefined();
expect((headroomPut!.body.headroom as { minRows?: number }).minRows).toBe(5);
});
});