Files
hermes-webui/tests/test_inflight_storage_quota.py
nesquena-hermes 12becd1f4b fix(chat): rename _inflightStateLimits() to _getInflightStateLimits() to fix v0.51.117 collision
Closes #2771.

v0.51.117 (PR #2766) introduced a top-level function _inflightStateLimits()
in static/ui.js that collided with the window._inflightStateLimits config
object set in static/boot.js. Because top-level function declarations in
classic (non-module) scripts attach to window, boot.js's assignment
overwrote the function reference, and every later _inflightStateLimits()
call threw TypeError. _compactInflightState() runs on every send(), so
no new chat session could be created — v0.51.117 is effectively unusable.

Reported by @jahilldev, with multiple users (@isma3iloiso, @theDanielJLewis,
@JHVenn) confirming the bug or reverting to v0.51.116.

Fix: rename the function to _getInflightStateLimits() — the window-attached
config key stays under its original name (unchanged for any downstream
code that reads it). Updates all 4 call sites in static/ui.js.

Tests:

  - Update tests/test_inflight_storage_quota.py — the existing test
    asserted 'function _inflightStateLimits()' in UI_JS as a positive
    presence check, which certified the bug. Now asserts the renamed
    function name is present AND the old colliding name is absent AND
    no stale call sites remain.
  - Add tests/test_window_function_collision.py — generalized regression
    that scans every static JS file for top-level function declarations
    whose name also appears as the target of 'window.X = {...}' or
    'window.X = <number>'. This is the exact shape that broke #2715
    (_pinnedSessionsLimit in v0.51.106) and #2771. Test fails with a
    precise diagnostic naming the file and symbol if the bug class
    returns. Confirmed test FAILS on current master (unfixed) and PASSES
    on this branch.

Verified end-to-end against the live browser before commit:
  - typeof window._inflightStateLimits === 'object' (config preserved)
  - typeof window._getInflightStateLimits === 'function'
  - _getInflightStateLimits() returns the limits object
  - saveInflightState() persists to localStorage without throwing

Full pytest suite: 6308 passed, 6 skipped, 3 xpassed, 8 subtests passed.
Opus advisor: SHIP.
2026-05-22 23:17:00 +00:00

88 lines
4.1 KiB
Python

"""Regression coverage for browser in-flight localStorage quota handling."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 1
i = brace + 1
while depth and i < len(src):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
return src[brace + 1 : i - 1]
def test_inflight_state_is_compacted_before_localstorage_write():
"""Persisted recovery state must stay bounded instead of storing full long sessions."""
save_body = _function_body(UI_JS, "saveInflightState")
compact_body = _function_body(UI_JS, "_compactInflightState")
assert "const entry={..._compactInflightState(state),updated_at:Date.now()};" in save_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
def test_inflight_state_limits_are_configurable_from_settings():
"""Recovery snapshots should be bounded by settings, not hardcoded at 3 sessions / 8 messages."""
assert '\"inflight_state_max_sessions\": 8' in CONFIG_PY
assert '\"inflight_state_max_messages\": 24' in CONFIG_PY
assert '\"inflight_state_max_tool_calls\": 48' in CONFIG_PY
assert '\"inflight_state_max_string_chars\": 60000' in CONFIG_PY
assert '\"inflight_state_max_json_chars\": 1500000' in CONFIG_PY
assert '\"inflight_state_max_sessions\": (1, 25)' in CONFIG_PY
assert '\"inflight_state_max_messages\": (1, 100)' in CONFIG_PY
assert '\"inflight_state_max_tool_calls\": (1, 200)' in CONFIG_PY
assert '\"inflight_state_max_string_chars\": (1000, 500000)' in CONFIG_PY
assert '\"inflight_state_max_json_chars\": (100000, 4000000)' in CONFIG_PY
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
# 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
def test_inflight_marker_write_handles_quota_by_dropping_recovery_snapshots():
"""The tiny active-stream marker must not crash submit when recovery snapshots fill quota."""
mark_body = _function_body(UI_JS, "markInflight")
assert "try{" in mark_body
assert "localStorage.setItem(INFLIGHT_KEY, payload);" in mark_body
assert "_isStorageQuotaError(err)" in mark_body
assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in mark_body
assert mark_body.index("localStorage.removeItem(INFLIGHT_STATE_KEY);") < mark_body.rindex(
"localStorage.setItem(INFLIGHT_KEY, payload);"
)
def test_save_inflight_state_clears_snapshots_when_quota_retry_fails():
"""Quota failures should degrade recovery, not preserve a storage-filling blob."""
save_body = _function_body(UI_JS, "saveInflightState")
assert "catch(err)" in save_body
assert "if(!_isStorageQuotaError(err)) return;" in save_body
assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in save_body
assert "_writeInflightStateMap({[sid]:entry});" in save_body