diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 49abbf7b93..ddcadd7376 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -13,6 +13,7 @@ "_rebaseline_2026_06_18_qg9_combo_split_d8": "QG v2 Fase 9 T5 D8 (reduced): combo.ts 3819->3432 — auto-strategy scoring/intent/tag-routing extracted byte-identically to the new open-sse/services/combo/autoStrategy.ts (434, autoStrategy import cycle (a later task can move the reset-window block out of combo.ts without breaking autoStrategy). autoStrategy.ts never imports from the combo barrel. dedupeTargetsByExecutionKey was already in comboData.ts (D6) and is NOT re-moved. QUOTA_SOFT_DEPRIORITIZE_FACTOR + setCandidateQuotaSoftPenalty stay re-exported from combo.ts for chatCore.ts's dynamic import(\"../services/combo\"); scoreAutoTargets/expandAutoComboCandidatePool keep their public re-export too. Orphaned combo.ts imports dropped where moved-out symbols stopped being referenced; ProviderCandidate/AutoProviderCandidate/HistoricalLatencyStatsEntry types are imported into combo.ts for buildAutoCandidates. Pure move, no logic change.", "_rebaseline_2026_06_18_qg9_combo_split_d7a": "QG v2 Fase 9 T5 D7a: combo.ts 3440->3398 (wc -l 3397 + 1, stacked on #4194 which added the passthrough-quota guards) — round-robin sticky state extracted byte-identically to the new open-sse/services/combo/rrState.ts (71, ) + rrStickyTargets, and the three helpers clampStickyRoundRobinTargetLimit/getStickyRoundRobinStartIndex/recordStickyRoundRobinSuccess. STATE COHESION: rrCounters and rrStickyTargets remain SINGLE instances defined once in rrState.ts; combo.ts imports the same references back and keeps mutating them directly in orderTargetsByResetAwareQuota/orderTargetsByResetWindow/handleRoundRobinCombo (no Map duplicated — sticky-3 round-robin behavior preserved, guarded by combo-routing-e2e.test.ts). The quota state left behind (MAX_RESET_AWARE_CACHE/resetAwareConnectionCache/resetAwareQuotaCache) stays in combo.ts for a later task (D7b). None of the 6 were ever public, so combo.ts imports all six back (no re-export). rrState.ts never imports from the combo barrel; it imports only ResolvedComboTarget from ./types.ts. Pure move, no logic change.", "_rebaseline_2026_06_18_qg9_combo_split_d7b": "QG v2 Fase 9 T5 D7b: combo.ts 3398->2597 (wc -l 2596 + 1) — the reset-aware / reset-window quota block extracted byte-identically into two new leaves under open-sse/services/combo/ (both 800 cap): quotaScoring.ts (311) holds the PURE half (config consts/resolvers + window-math/scoring helpers, no state, no async); quotaStrategies.ts (568) holds the STATEFUL/async half — the two mutable caches resetAwareConnectionCache + resetAwareQuotaCache (new Map) + MAX_RESET_AWARE_CACHE kept as SINGLE instances next to their only readers/writers getQuotaAwareConnectionsForTarget + fetchResetAwareQuotaWithCache (STATE COHESION; grep 'resetAware*Cache = new Map' in combo.ts == 0), plus normalizeConnectionIds/filterAllowedConnectionIds/getTargetConnectionIds/mapWithConcurrency/preScreenTargets/orderTargetsByResetAwareQuota/orderTargetsByResetWindow. quotaStrategies imports rrCounters + MAX_RR_COUNTERS from ./rrState.ts (D7a) and the pure helpers from ./quotaScoring.ts; neither leaf imports the combo barrel. combo.ts imports back the 3 reset-window helpers buildAutoCandidates needs (resolveResetWindowConfig/fetchResetAwareQuotaWithCache/calculateResetWindowAffinity) + resolveSlaRoutingPolicy + the 3 orderers used by the orchestrator; preScreenTargets stays re-exported. Orphaned imports dropped (clamp01/PRE_SCREEN_CONCURRENCY/ProviderProfile/SlaRoutingPolicy/IsModelAvailable); hasPerModelQuota KEPT (used by the #4194 passthrough-quota guards that stay). buildAutoCandidates + calculateTargetContextAffinity/getBootstrapLatencyMs stay (D8). Pure move, no logic change.", + "_rebaseline_2026_06_18_4206_mcpaccess_bounds": "PR #4206 own growth: open-sse/mcp-server/server.ts 1457->1458 (+1 = one named import `clampMcpAccessibilityConfig` added to the existing constants.ts import block in readMcpAccessibilityConfig). The fix bounds the LIVE read path (a persisted out-of-range maxTextChars made smartFilterText truncate the whole tool result away); all real logic lives in the new clampMcpAccessibilityConfig helper in engines/mcpAccessibility/constants.ts (1022 (+5 = one import + one `await awaitProviderDefaultSlot(...)` call + a 2-line comment at the existing withRateLimit chokepoint). All sliding-window logic was extracted to the new open-sse/services/providerDefaultRateLimit.ts + open-sse/services/slidingWindowLimiter.ts (both 1440 (+5 = appendNoThinkingVariants(finalModels) call + comment at the existing finalModels chokepoint) and chat.ts 1458->1471 (+13 = applyNoThinkingAlias(body) call + comment right after body.model is read, before model resolution). All real logic lives in the new open-sse/utils/noThinkingAlias.ts (6009 (+29 at the existing streaming-return chokepoint = capture streamRecovery.continueMidStream alongside .enabled; refactor the early-retry reopen thunk into a shared runUpstreamStream(body) helper — net DRY — and add the gated continueStream(assistantSoFar) thunk that re-runs the upstream with makeContinuationBody(bodyToSend, …), plus the onContinue log). All continuation logic (scanOpenAiSseText, makeContinuationBody, trimContinuationOverlap, the createRecoverableStream continuation path) lives in open-sse/services/streamRecovery.ts (= min + ? Math.floor(value) + : fallback; +} + +/** + * Bound a raw/persisted mcpAccessibility config into a safe, fully-populated config. Centralizes + * the numeric floors so both the DB normalizer and the live MCP-server read path agree (a small + * `maxTextChars` would otherwise make `smartFilterText` truncate the whole text away). + */ +export function clampMcpAccessibilityConfig(raw: unknown): McpAccessibilityConfig { + const record = (raw && typeof raw === "object" ? raw : {}) as Record; + const d = DEFAULT_MCP_ACCESSIBILITY_CONFIG; + return { + enabled: record["enabled"] !== false, + maxTextChars: boundedInt(record["maxTextChars"], MCP_ACCESSIBILITY_MIN_MAX_TEXT_CHARS, d.maxTextChars), + collapseThreshold: boundedInt(record["collapseThreshold"], 1, d.collapseThreshold), + collapseKeepHead: boundedInt(record["collapseKeepHead"], 0, d.collapseKeepHead), + collapseKeepTail: boundedInt(record["collapseKeepTail"], 0, d.collapseKeepTail), + minLengthToProcess: boundedInt(record["minLengthToProcess"], 1, d.minLengthToProcess), + }; +} diff --git a/open-sse/services/compression/engines/mcpAccessibility/index.ts b/open-sse/services/compression/engines/mcpAccessibility/index.ts index caf709d4ec..47ede74949 100644 --- a/open-sse/services/compression/engines/mcpAccessibility/index.ts +++ b/open-sse/services/compression/engines/mcpAccessibility/index.ts @@ -1,5 +1,5 @@ import { collapseRepeated } from "./collapseRepeated.ts"; -import type { McpAccessibilityConfig } from "./constants.ts"; +import { MCP_ACCESSIBILITY_TAIL_RESERVE, type McpAccessibilityConfig } from "./constants.ts"; const NOISE_PATTERNS: RegExp[] = [/^\s*-\s*generic:?\s*$/gm, /^\s*-\s*text:\s*""\s*$/gm]; @@ -19,10 +19,11 @@ export function smartFilterText(text: string, config: McpAccessibilityConfig): s ); if (out.length > config.maxTextChars) { - // Clamp to >=0: a maxTextChars below the 300-char tail reservation would make headSize - // negative, and slice(0, negative) counts from the END — silently keeping a wrong, - // oversized fragment instead of the intended head. - const headSize = Math.max(0, config.maxTextChars - 300); + // Clamp to >=0: a maxTextChars below the tail reservation would make headSize negative, and + // slice(0, negative) counts from the END — silently keeping a wrong, oversized fragment + // instead of the intended head. (clampMcpAccessibilityConfig keeps maxTextChars sane, but + // smartFilterText is also called with raw configs in tests, so the clamp stays here too.) + const headSize = Math.max(0, config.maxTextChars - MCP_ACCESSIBILITY_TAIL_RESERVE); const head = out.slice(0, headSize); // Measure omitted against the FILTERED text (out), not the raw input (text), which may // have shrunk via noise removal / collapse above. @@ -35,4 +36,8 @@ export function smartFilterText(text: string, config: McpAccessibilityConfig): s } export type { McpAccessibilityConfig } from "./constants.ts"; -export { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "./constants.ts"; +export { + DEFAULT_MCP_ACCESSIBILITY_CONFIG, + clampMcpAccessibilityConfig, + MCP_ACCESSIBILITY_TAIL_RESERVE, +} from "./constants.ts"; diff --git a/open-sse/services/compression/types.ts b/open-sse/services/compression/types.ts index 0a84874976..06af2d192a 100644 --- a/open-sse/services/compression/types.ts +++ b/open-sse/services/compression/types.ts @@ -340,4 +340,7 @@ export const DEFAULT_ULTRA_CONFIG: UltraConfig = { }; export type { McpAccessibilityConfig } from "./engines/mcpAccessibility/constants.ts"; -export { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "./engines/mcpAccessibility/constants.ts"; +export { + DEFAULT_MCP_ACCESSIBILITY_CONFIG, + clampMcpAccessibilityConfig, +} from "./engines/mcpAccessibility/constants.ts"; diff --git a/src/lib/db/compression.ts b/src/lib/db/compression.ts index 5b8ed98af4..354d947763 100644 --- a/src/lib/db/compression.ts +++ b/src/lib/db/compression.ts @@ -11,6 +11,7 @@ import { DEFAULT_MCP_ACCESSIBILITY_CONFIG, DEFAULT_RTK_CONFIG, DEFAULT_ULTRA_CONFIG, + clampMcpAccessibilityConfig, type AggressiveConfig, type CavemanConfig, type CavemanOutputModeConfig, @@ -519,32 +520,10 @@ export function getDefaultRtkConfig(): RtkConfig { } function normalizeMcpAccessibilityConfig(value: unknown): McpAccessibilityConfig { - const record = toRecord(value); - return { - ...DEFAULT_MCP_ACCESSIBILITY_CONFIG, - ...record, - enabled: record.enabled !== false, - maxTextChars: - typeof record.maxTextChars === "number" && record.maxTextChars > 0 - ? Math.floor(record.maxTextChars) - : DEFAULT_MCP_ACCESSIBILITY_CONFIG.maxTextChars, - collapseThreshold: - typeof record.collapseThreshold === "number" && record.collapseThreshold > 0 - ? Math.floor(record.collapseThreshold) - : DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseThreshold, - collapseKeepHead: - typeof record.collapseKeepHead === "number" && record.collapseKeepHead >= 0 - ? Math.floor(record.collapseKeepHead) - : DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseKeepHead, - collapseKeepTail: - typeof record.collapseKeepTail === "number" && record.collapseKeepTail >= 0 - ? Math.floor(record.collapseKeepTail) - : DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseKeepTail, - minLengthToProcess: - typeof record.minLengthToProcess === "number" && record.minLengthToProcess > 0 - ? Math.floor(record.minLengthToProcess) - : DEFAULT_MCP_ACCESSIBILITY_CONFIG.minLengthToProcess, - }; + // clampMcpAccessibilityConfig (engine layer) owns the numeric floors so the DB normalizer and + // the live MCP-server read path agree — in particular it floors maxTextChars to a sane minimum + // (a value below the tail reservation would make smartFilterText truncate the whole text away). + return clampMcpAccessibilityConfig(value); } export async function getMcpAccessibilityConfig(): Promise { diff --git a/tests/unit/compression/mcpAccessibility-config-bounds.test.ts b/tests/unit/compression/mcpAccessibility-config-bounds.test.ts new file mode 100644 index 0000000000..a13436aa08 --- /dev/null +++ b/tests/unit/compression/mcpAccessibility-config-bounds.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + smartFilterText, + clampMcpAccessibilityConfig, +} from "../../../open-sse/services/compression/engines/mcpAccessibility/index.ts"; +import { DEFAULT_MCP_ACCESSIBILITY_CONFIG } from "../../../open-sse/services/compression/engines/mcpAccessibility/constants.ts"; + +// smartFilterText reserves 300 chars for the truncation tail/notice, so any maxTextChars below +// that leaves headSize <= 0 and the whole tool result is replaced by the notice (total data +// loss). The DB normalizer floored maxTextChars only at > 0, and the production read path in +// server.ts bypassed bounding entirely. clampMcpAccessibilityConfig is the shared guard. +test("clamps maxTextChars below the tail reserve to the default", () => { + for (const bad of [1, 50, 300, 599]) { + assert.equal( + clampMcpAccessibilityConfig({ maxTextChars: bad }).maxTextChars, + DEFAULT_MCP_ACCESSIBILITY_CONFIG.maxTextChars, + `maxTextChars=${bad} must fall back to default` + ); + } +}); + +test("keeps a sane maxTextChars (>= 600)", () => { + assert.equal(clampMcpAccessibilityConfig({ maxTextChars: 600 }).maxTextChars, 600); + assert.equal(clampMcpAccessibilityConfig({ maxTextChars: 1000 }).maxTextChars, 1000); + assert.equal(clampMcpAccessibilityConfig({ maxTextChars: 1234.9 }).maxTextChars, 1234); +}); + +test("bounds the other numeric fields and honors enabled", () => { + const c = clampMcpAccessibilityConfig({ + collapseThreshold: -5, + minLengthToProcess: 0, + collapseKeepHead: -1, + enabled: false, + }); + assert.equal(c.collapseThreshold, DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseThreshold); + assert.equal(c.minLengthToProcess, DEFAULT_MCP_ACCESSIBILITY_CONFIG.minLengthToProcess); + assert.equal(c.collapseKeepHead, DEFAULT_MCP_ACCESSIBILITY_CONFIG.collapseKeepHead); + assert.equal(c.enabled, false); +}); + +test("a clamped config never lets smartFilterText truncate the whole text away", () => { + // The previously-dangerous stored value: maxTextChars=50 → clamps to default, so a + // 1000-char tool result is NOT replaced wholesale by the truncation notice. + const cfg = clampMcpAccessibilityConfig({ maxTextChars: 50, minLengthToProcess: 1 }); + const out = smartFilterText("A".repeat(1000), cfg); + assert.ok(out.includes("A".repeat(500)), "content preserved (not nuked by a tiny maxTextChars)"); +});