"""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." )