Files
hermes-webui/tests/test_issue1539_provider_removal_dropdown_invalidation.py
hermes-agentanddobby-d-elf 9d95ba0b92 Stage 404: PR #2716 — Performance optimizations by @dobby-d-elf
nesquena APPROVED 2026-05-22. Cherry-picked onto post-v0.51.127
master via 3-way apply. Resolved api/routes.py conflict: master had
the inline correctness fix from the deep-review iteration; PR
refactors it into _metadata_only_message_summary() helper. Took the
helper AND added profile= threading (post-#2827 master adds
profile-aware state.db reads). Kept master's pre-existing
test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant
alongside the PR's new test_metadata_fast_path_matches_reconciliation_for_restamped_replays.

Co-authored-by: dobby-d-elf <dobby.the.agent@gmail.com>
2026-05-24 18:08:41 +00:00

235 lines
11 KiB
Python

"""Regression tests for #1539 — removing a provider in Settings must invalidate
every dropdown surface that caches /api/models, so the removed provider
disappears immediately without a server restart or page reload.
The bug
-------
Pre-fix, ``_removeProviderKey()`` in ``static/panels.js`` only called
``loadProvidersPanel()`` after deletion. That refreshed the providers card
list but left these JS-side caches stale:
* ``_slashModelCache`` / ``_slashModelCachePromise`` (``static/commands.js``) —
cache for the ``/model`` slash-command suggestions.
* ``_dynamicModelLabels`` / ``window._configuredModelBadges`` (``static/ui.js``) —
populated by ``populateModelDropdown()`` on boot and on profile switch.
Layered server-side cache via ``api/config.invalidate_models_cache`` was
already flushed (``set_provider_key`` calls it on both add + remove), so the
next ``/api/models`` request would return the correct list — but no consumer
was triggering one.
The fix
-------
``static/commands.js`` exposes an ``_invalidateSlashModelCache()`` helper on
``window``. ``static/panels.js`` calls it from a shared
``_refreshModelDropdownsAfterProviderChange()`` helper after both the save
and the remove paths, plus invokes ``populateModelDropdown()`` to rebuild
the composer / Settings dropdowns and ``_configuredModelBadges`` map.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parent.parent
def _read_static(name: str) -> str:
return (REPO / "static" / name).read_text(encoding="utf-8")
def _extract_function_body(src: str, signature: str) -> str:
"""Return the source of a top-level ``async function NAME(...)`` /
``function NAME(...)`` declaration via brace-balance — robust to nested
blocks (try/catch/await) and not dependent on indentation.
"""
idx = src.find(signature)
if idx == -1:
raise AssertionError(f"signature {signature!r} not found in source")
open_idx = src.find("{", idx)
if open_idx == -1:
raise AssertionError(f"could not find opening brace after {signature!r}")
depth = 0
for i in range(open_idx, len(src)):
c = src[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return src[idx : i + 1]
raise AssertionError(f"unbalanced braces in {signature!r}")
class TestSlashModelCacheInvalidator:
"""``static/commands.js`` must export the helper to ``window`` so
``static/panels.js`` can drop the slash-command cache without poking
module-local lets across module boundaries."""
def test_invalidator_helper_defined(self):
src = _read_static("commands.js")
assert "function _invalidateSlashModelCache(" in src, (
"_invalidateSlashModelCache helper missing from static/commands.js. "
"Without it static/panels.js cannot drop the /model slash-command "
"cache when a provider is added/removed (#1539)."
)
def test_invalidator_clears_both_cache_slots(self):
src = _read_static("commands.js")
body = _extract_function_body(src, "function _invalidateSlashModelCache(")
# Cache slots from static/commands.js:84-85 — keep both null'd.
assert "_slashModelCache=null" in body, (
"_invalidateSlashModelCache must null _slashModelCache so the next "
"/model autocomplete refetches /api/models."
)
assert "_slashModelCachePromise=null" in body, (
"_invalidateSlashModelCache must null _slashModelCachePromise so an "
"in-flight load doesn't resolve into the stale cache slot after "
"invalidation."
)
def test_invalidator_exposed_on_window(self):
src = _read_static("commands.js")
# Exposed on window via a typeof-guarded assignment so the module is
# also importable in headless test contexts (vm.runInContext) that
# don't define a window global.
assert "window._invalidateSlashModelCache=_invalidateSlashModelCache" in src, (
"_invalidateSlashModelCache must be exposed on window so static/panels.js "
"can invoke it across module boundaries."
)
assert "typeof window!=='undefined'" in src, (
"The window-export assignment must be guarded by `typeof window!=='undefined'` "
"so static/commands.js stays importable in headless vm contexts (the "
"tests/test_cli_only_slash_commands.py harness has no window global)."
)
class TestProviderRemoveInvalidatesDropdowns:
"""The remove path in ``static/panels.js`` must trigger the dropdown-cache
flush and rebuild — otherwise the dropped provider lingers in every
/model dropdown until the page reloads (#1539)."""
def test_remove_path_invokes_dropdown_flush(self):
src = _read_static("panels.js")
body = _extract_function_body(src, "async function _removeProviderKey(")
assert "_refreshModelDropdownsAfterProviderChange()" in body, (
"_removeProviderKey must call _refreshModelDropdownsAfterProviderChange() "
"after a successful delete. Without this, the JS-side caches "
"(_slashModelCache, _dynamicModelLabels, _configuredModelBadges) "
"still offer the deleted provider's models until reload (#1539)."
)
def test_save_path_invokes_dropdown_flush(self):
"""Defense-in-depth: adding a key has the same staleness shape — the
new provider's models won't show up until reload without this call.
Bundled in #1539."""
src = _read_static("panels.js")
body = _extract_function_body(src, "async function _saveProviderKey(")
assert "_refreshModelDropdownsAfterProviderChange()" in body, (
"_saveProviderKey must also call _refreshModelDropdownsAfterProviderChange() "
"so a newly-configured provider's models appear in every dropdown "
"without a reload. Same staleness shape as the remove path (#1539)."
)
def test_dropdown_flush_helper_defined(self):
src = _read_static("panels.js")
assert "function _refreshModelDropdownsAfterProviderChange(" in src, (
"_refreshModelDropdownsAfterProviderChange must be defined in "
"static/panels.js (single helper used by both save + remove paths)."
)
def test_dropdown_flush_calls_slash_cache_invalidator(self):
src = _read_static("panels.js")
body = _extract_function_body(src, "function _refreshModelDropdownsAfterProviderChange(")
# Must invoke the commands.js helper — directly poking module-local
# lets across module boundaries is brittle.
assert "_invalidateSlashModelCache" in body, (
"_refreshModelDropdownsAfterProviderChange must call "
"window._invalidateSlashModelCache() so the /model slash-command "
"cache is dropped (covers the slash-command surface from #1539)."
)
def test_dropdown_flush_calls_populate_model_dropdown(self):
src = _read_static("panels.js")
body = _extract_function_body(src, "function _refreshModelDropdownsAfterProviderChange(")
assert "populateModelDropdown" in body, (
"_refreshModelDropdownsAfterProviderChange must call "
"populateModelDropdown() so the composer model picker, Settings → "
"Default Model dropdown, _dynamicModelLabels, and "
"_configuredModelBadges all rebuild from a fresh /api/models "
"response (covers the dropdown + badge surfaces from #1539)."
)
def test_dropdown_flush_reuses_shared_model_ready_promise(self):
src = _read_static("panels.js")
body = _extract_function_body(src, "function _refreshModelDropdownsAfterProviderChange(")
ensure_pos = body.index("typeof window._ensureModelDropdownReady")
reset_pos = body.index("window._modelDropdownReady=null", ensure_pos)
call_pos = body.index("window._ensureModelDropdownReady()", reset_pos)
assert ensure_pos < reset_pos < call_pos
def test_dropdown_flush_is_resilient_to_missing_modules(self):
"""If commands.js or ui.js failed to load, the providers panel must
still update — the dropdown flush is best-effort (#1539)."""
src = _read_static("panels.js")
body = _extract_function_body(src, "function _refreshModelDropdownsAfterProviderChange(")
# Outer try/catch wraps the whole helper so a runtime error inside
# populateModelDropdown / cache flush cannot surface as an unhandled
# rejection that breaks the surrounding save/remove flow.
assert re.search(r"\btry\s*\{", body), (
"_refreshModelDropdownsAfterProviderChange must wrap its work in "
"try/catch — if commands.js or ui.js failed to load, a missing "
"function should not break the providers panel update (#1539)."
)
# And the populateModelDropdown call must be guarded by typeof — the
# dropdown rebuild is best-effort.
assert "typeof populateModelDropdown" in body, (
"populateModelDropdown lookup must use typeof so it gracefully "
"skips when ui.js hasn't loaded yet."
)
def test_dropdown_flush_does_not_block_panel_refresh(self):
"""populateModelDropdown is async; its result must not be awaited
synchronously inside the helper — otherwise a slow /api/models would
delay the providers panel re-render (#1539)."""
src = _read_static("panels.js")
body = _extract_function_body(src, "function _refreshModelDropdownsAfterProviderChange(")
# The helper itself is non-async (signature checked indirectly: the
# source begins with 'function _refresh...', not 'async function').
# Anything async is fired with Promise.resolve(...).catch(...) so the
# provider panel re-render is not blocked.
assert body.startswith("function _refreshModelDropdownsAfterProviderChange"), (
"_refreshModelDropdownsAfterProviderChange should be a sync helper "
"that fires-and-forgets populateModelDropdown — not an async one "
"the save/remove paths await."
)
class TestServerSideInvariantPreserved:
"""Server-side ``invalidate_models_cache()`` is the load-bearing invariant
that lets the next /api/models request return correct data; #1539 was a
pure frontend bug, but pin the server-side wiring so a refactor of
``set_provider_key`` cannot silently regress it."""
def test_set_provider_key_invalidates_cache(self):
src = (REPO / "api" / "providers.py").read_text(encoding="utf-8")
# set_provider_key is the canonical write path — both add and remove
# flow through it (remove_provider_key calls set_provider_key(pid, None)).
m = re.search(
r"def set_provider_key\([^)]*\).*?(?=\ndef |\Z)",
src,
re.DOTALL,
)
assert m, "set_provider_key not found in api/providers.py"
body = m.group(0)
assert "invalidate_models_cache()" in body, (
"set_provider_key must call invalidate_models_cache() so the "
"server-side TTL cache is flushed on every add/remove. Without "
"this, even a perfectly-cached frontend would receive stale data."
)