diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 6ae0ed1190..a2f376cf9f 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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 (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 ( 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." } diff --git a/open-sse/services/compression/strategySelector.ts b/open-sse/services/compression/strategySelector.ts index 336a982624..38c2779777 100644 --- a/open-sse/services/compression/strategySelector.ts +++ b/open-sse/services/compression/strategySelector.ts @@ -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 = { + ...headroomDetail, ...(step.config ?? {}), ...(step.intensity ? { intensity: step.intensity } : {}), }; diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index ed843cd972..66e318371d 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -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, diff --git a/src/app/api/compression/preview/route.ts b/src/app/api/compression/preview/route.ts index c656f0ced3..9a9cd929c9 100644 --- a/src/app/api/compression/preview/route.ts +++ b/src/app/api/compression/preview/route.ts @@ -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 +) { + const config: Record = { ...(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 diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index d3c8e74753..3b39ccd9e4 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -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 { 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 { case "ultraConfig": config.ultra = normalizeUltraConfig(parsed); break; + case "headroom": + case "headroomConfig": + config.headroom = normalizeHeadroomConfig(parsed); + break; case "contextBudget": config.contextBudget = normalizeContextBudgetConfig(parsed); break; diff --git a/src/shared/components/compression/EngineConfigPage.tsx b/src/shared/components/compression/EngineConfigPage.tsx index 4cca345006..79b5859ae5 100644 --- a/src/shared/components/compression/EngineConfigPage.tsx +++ b/src/shared/components/compression/EngineConfigPage.tsx @@ -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 = { 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) { diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts index af59616566..2b26585f97 100644 --- a/src/shared/validation/compressionConfigSchemas.ts +++ b/src/shared/validation/compressionConfigSchemas.ts @@ -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(), diff --git a/tests/unit/compression/headroom-minrows-persist-8056.test.ts b/tests/unit/compression/headroom-minrows-persist-8056.test.ts new file mode 100644 index 0000000000..651ef316cf --- /dev/null +++ b/tests/unit/compression/headroom-minrows-persist-8056.test.ts @@ -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> { + return Array.from({ length: n }, (_, i) => ({ id: i, name: `row-${i}`, value: i * 10 })); +} + +function baseConfig(overrides: Partial = {}): 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" + ); + }); +}); diff --git a/tests/unit/ui/engineConfigPage.test.tsx b/tests/unit/ui/engineConfigPage.test.tsx index 94f19e614b..b3769b8875 100644 --- a/tests/unit/ui/engineConfigPage.test.tsx +++ b/tests/unit/ui/engineConfigPage.test.tsx @@ -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 }[] = []; + 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(); + }); + 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); + }); });