diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cc08503..f48fee4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ - Add a default-off, read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki when explicitly enabled with `webui_external_notes_sources` or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`, while leaving automatic session recall unchanged. +## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored) + +### Fixed + +- **PR #2773** by @nesquena-hermes — fix(chat): rename `_inflightStateLimits()` in `static/ui.js` to `_getInflightStateLimits()` so it no longer collides with the `window._inflightStateLimits` config object set in `static/boot.js`. Closes #2771. The v0.51.117 in-flight-recovery quota fix (#2766) declared a top-level helper with the same name as a window-attached config object; because top-level `function foo(){…}` declarations in classic (non-module) scripts attach to `window`, boot.js's `window._inflightStateLimits = {…}` assignment overwrote the function reference before any session could send. Every new chat broke on first `send()` with `TypeError: _inflightStateLimits is not a function`, leaving v0.51.117 effectively unusable. Renamed the function only (the public-ish window key is unchanged) and updated all 4 call sites. **New regression test `tests/test_window_function_collision.py` scans every static JS file for top-level `function NAME()` declarations whose name is also the target of `window.NAME = {…}` / `= `, the exact shape that broke #2715 (`_pinnedSessionsLimit` in v0.51.106) and #2771 (`_inflightStateLimits` in v0.51.117). The test fails loudly with a precise file:name diagnostic if the bug class returns. Verified end-to-end against the live browser before merge: `_getInflightStateLimits()` returns the limits object and `saveInflightState()` persists to localStorage without throwing. + ## [v0.51.117] — 2026-05-22 — Release CO (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe) ### Fixed diff --git a/static/ui.js b/static/ui.js index 8e123c3f..3737fff8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4081,7 +4081,7 @@ function _boundedInflightInt(value, fallback, min, max){ if(!Number.isFinite(n)) return fallback; return Math.max(min, Math.min(max, n)); } -function _inflightStateLimits(){ +function _getInflightStateLimits(){ const configured=(typeof window!=='undefined'&&window._inflightStateLimits&&typeof window._inflightStateLimits==='object')?window._inflightStateLimits:{}; return { maxSessions:_boundedInflightInt(configured.maxSessions, INFLIGHT_STATE_DEFAULT_LIMITS.maxSessions, 1, 25), @@ -4110,7 +4110,7 @@ function _isStorageQuotaError(err){ ); } function _truncateInflightValue(value, maxChars){ - const limits=_inflightStateLimits(); + const limits=_getInflightStateLimits(); const stringLimit=_boundedInflightInt(maxChars, limits.stringChars, 1000, 500000); if(typeof value==='string'){ if(value.length<=stringLimit) return value; @@ -4125,7 +4125,7 @@ function _truncateInflightValue(value, maxChars){ return value; } function _compactInflightState(state){ - const limits=_inflightStateLimits(); + const limits=_getInflightStateLimits(); const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[]; const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[]; return _truncateInflightValue({ @@ -4136,7 +4136,7 @@ function _compactInflightState(state){ }, limits.stringChars); } function _writeInflightStateMap(all){ - const limits=_inflightStateLimits(); + const limits=_getInflightStateLimits(); const entries=Object.entries(all||{}) .sort((a,b)=>Number(b[1]&&b[1].updated_at||0)-Number(a[1]&&a[1].updated_at||0)) .slice(0,limits.maxSessions); diff --git a/tests/test_inflight_storage_quota.py b/tests/test_inflight_storage_quota.py index 615863c7..deba33d0 100644 --- a/tests/test_inflight_storage_quota.py +++ b/tests/test_inflight_storage_quota.py @@ -28,7 +28,7 @@ def test_inflight_state_is_compacted_before_localstorage_write(): compact_body = _function_body(UI_JS, "_compactInflightState") assert "const entry={..._compactInflightState(state),updated_at:Date.now()};" in save_body - assert "const limits=_inflightStateLimits();" in compact_body + assert "const limits=_getInflightStateLimits();" in compact_body assert ".slice(-limits.messages)" in compact_body assert ".slice(-limits.toolCalls)" in compact_body assert "limits.jsonChars" in UI_JS @@ -49,7 +49,16 @@ def test_inflight_state_limits_are_configurable_from_settings(): assert "window._inflightStateLimits={" in BOOT_JS assert "maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8" in BOOT_JS assert "messages:parseInt(s.inflight_state_max_messages||24,10)||24" in BOOT_JS - assert "function _inflightStateLimits()" in UI_JS + # The reader function MUST use a different name than the window-attached + # config object — top-level `function foo(){}` in non-module scripts + # attaches to `window`, so a collision causes boot.js to overwrite the + # function with the config object and every later call throws + # `_inflightStateLimits is not a function`. See #2771. + assert "function _getInflightStateLimits()" in UI_JS + assert "function _inflightStateLimits()" not in UI_JS, ( + "Function name must not collide with window._inflightStateLimits " + "config object (#2771)." + ) assert "window._inflightStateLimits" in UI_JS assert "INFLIGHT_STATE_MAX_SESSIONS = 3" not in UI_JS assert "INFLIGHT_STATE_MAX_MESSAGES = 8" not in UI_JS diff --git a/tests/test_window_function_collision.py b/tests/test_window_function_collision.py new file mode 100644 index 00000000..4c5dfaac --- /dev/null +++ b/tests/test_window_function_collision.py @@ -0,0 +1,151 @@ +"""Regression coverage for the function-name × window-attached-config collision class. + +This test guards against a specific failure mode that has caused two +brick-class regressions (v0.51.106 #2715 `_pinnedSessionsLimit`, v0.51.117 +#2771 `_inflightStateLimits`): + + - Some module declares `function foo(){...}` at top level. Since the + WebUI ships classic (non-module) scripts via `