Files
hermes-webui/tests/test_issue572.py
T
nesquena-hermes 6c343aff84 v0.50.210: gpt-5.5, cron titles, agent cache, bfcache fix, onboarding fix, mermaid CSP, PWA auth (#1056)
* feat(models): add gpt-5.5 to openai, openai-codex, copilot catalogs

Adds GPT-5.5 and GPT-5.5 Mini entries to the static _PROVIDER_MODELS
catalog so they appear in the model picker for the openai, openai-codex,
and copilot providers.

Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent

* fix(models): add gpt-5.5-mini to copilot provider catalog

* fix(renderer): suppress Mermaid Google Fonts CSP violation via fontFamily inherit (#1044)

Mermaid's built-in 'dark' and 'default' themes inject an @import for
fonts.googleapis.com/Manrope into every generated SVG. The CSP style-src
only allows cdn.jsdelivr.net, so this request is blocked on every diagram
render, filling the console with CSP errors.

Fix: pass fontFamily:'inherit' (and fontSize:'14px') in the themeVariables
block of mermaid.initialize() in renderMermaidBlocks(). This suppresses
Mermaid's external font import and uses the page's existing font stack.

Avoids adding fonts.googleapis.com to the CSP — no new external dependency,
no font FOUT, consistent with the rest of the UI typography.

3 regression tests added in tests/test_1044_mermaid_csp_font.py.
2215/2215 tests passing.

* fix(onboarding): non-standard provider/path cluster (#1029)

* fix(bfcache): restore full layout on tab/session restore — rail, topbar, panels (#1045)

The pageshow handler added for #822 only cleared the session search filter
and re-rendered the session list. This left the rest of the layout chrome
(topbar, rail icons, workspace panel, resize handles, gateway SSE) in the
stale bfcache DOM state, causing a broken layout (oversized search icon,
uninitialized rail) that required a hard refresh to fix.

Fix: extend the pageshow handler to re-run the full set of layout sync calls
that the boot IIFE runs on a fresh page load:

  syncTopbar()              — restores model chip, title, topbar state
  syncWorkspacePanelState() — restores workspace panel open/closed
  _initResizePanels()       — reattaches panel resize drag listeners
  startGatewaySSE()         — reconnects the gateway SSE watcher
                              (bfcache-persisted connections are dead)

All four calls are typeof-guarded for safe degradation if a helper is not
yet defined. The existing #822 fixes (sessionSearch clear +
renderSessionListFromCache) are preserved unchanged.

loadSession() is intentionally NOT re-called — it would cause message
flicker; the sync calls above are sufficient to restore visual state.

7 regression tests added in tests/test_1045_bfcache_layout_restore.py.
2219/2219 tests passing.

* fix(bfcache): also close open dropdowns on bfcache restore (#1045)

Additional symptom noted in issue #1045: bfcache freezes the DOM including
any open dropdown/popover state. The thinking-level selector (and other
composer dropdowns) left open when navigating away would appear open without
user interaction on tab restore.

Extend the pageshow handler to call all four named close functions before
the layout sync:
  closeModelDropdown()     — composer model selector
  closeReasoningDropdown() — thinking/reasoning effort selector
  closeWsDropdown()        — workspace chip dropdown
  closeProfileDropdown()   — profile switcher dropdown

All calls are typeof-guarded, matching the style of the layout sync calls
already in the handler.

2 new tests (9 total in test_1045_bfcache_layout_restore.py):
- pageshow closes all four named dropdowns
- dropdown closes appear before layout sync calls (clean state first)

2221/2221 tests passing.

* fix(bfcache): remove _initResizePanels() — bfcache preserves listeners

* fix(bfcache): remove _initResizePanels from pageshow — bfcache preserves listeners; update test

* fix(sessions): use cron job name as session title when available (#1032)

* fix(test): add id column to messages table in cron title test fixture

* fix(merge): inject cron title lookup into read_importable loop, remove stale sqlite3 block

* fix(pwa): redirect to /login client-side on 401 — fixes iOS PWA auth expiry trap (#1038)

When an auth session expires, the server returns a 302→/login for page
requests. In a normal browser this works fine, but in an iOS PWA running
in standalone mode the redirect navigates out of the PWA shell into Safari,
leaving the app permanently stuck on 'Authentication required' with no
recovery path.

Fix: intercept 401 responses client-side before surfacing any error.

- workspace.js api(): check res.status===401 first; call
  window.location.href='/login' and return immediately (no throw)
- ui.js: add _redirectIfUnauth() helper; wire into all direct fetch()
  calls that bypass api() — api/models, api/models/live, api/upload

All fetch paths that could receive a 401 now redirect cleanly within
the PWA frame rather than opening Safari.

6 regression tests added in tests/test_1038_pwa_auth_redirect.py.
2175/2175 tests passing.

* fix(pwa): preserve current URL in ?next= param on 401 redirect

* fix(test): update 401-redirect assertion to accept ?next= URL format

* feat(pwa): add _safeNextPath() to login.js so ?next= param is honored after re-login

Addresses reviewer suggestion: the ?next= URL set on 401 redirect was ignored by
the login success handler (always redirected to ./). _safeNextPath() validates and
returns the ?next= param with open-redirect guards: rejects non-path-absolute inputs,
// protocol-relative URLs, backslash variants, and control characters.
4 new regression tests added.

* Implement session agent cache for AIAgent reuse

Added session agent cache to reuse AIAgent across messages.

* Implement agent caching for session management

* Implement session agent eviction on session deletion

Added session agent eviction to prevent turn count leakage in recycled sessions.

* docs: v0.50.210 release notes — 7 PRs, 2239 tests (+27)

* docs(changelog): drop stale [Unreleased] entries duplicated by v0.50.210

Three entries in the [Unreleased] section are duplicates of items now
listed under v0.50.210:

  - Mermaid CSP font fix (#1044)        → v0.50.210 / Mermaid Google Fonts CSP
  - bfcache layout restore (#1045)      → v0.50.210 / bfcache layout and dropdown restore
  - iOS PWA auth redirect (#1038)       → v0.50.210 / Login redirects back to original URL

The original drafts landed in [Unreleased] when individual PRs (#1047,
#1048, #1043) were approved; the v0.50.210 release-notes commit then
added the same items under the version section without removing the
[Unreleased] copies. Drop the duplicates so users reading the CHANGELOG
don't see the same fix listed twice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: Pix (PiClaw, claude-opus-4-7) via Hermes Agent
Co-authored-by: Pix (Hermes) <aliceisjustplaying@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: qxxaa <mrhanoi@outlook.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:47:44 -07:00

218 lines
10 KiB
Python

"""Tests for issue #572: onboarding must not fire or overwrite config for
providers not in the quick-setup list (minimax-cn, deepseek, xai, etc.).
Root cause: _provider_api_key_present() only knew about the four providers in
_SUPPORTED_PROVIDER_SETUPS. For any other provider it returned False, causing
chat_ready=False, which made the wizard fire even when the user was fully
configured. The second part of the fix ensures _saveOnboardingProviderSetup()
in the frontend also skips the POST when current_is_oauth is set.
Covers:
1. _provider_api_key_present returns True for minimax-cn when
MINIMAX_CN_API_KEY is in env (via hermes_cli.auth.get_auth_status)
2. _status_from_runtime gives chat_ready=True for minimax-cn with a key set
3. get_onboarding_status returns completed=True for a fully-configured
unsupported provider when config.yaml exists
4. The hermes_cli import failure path is safe (falls back gracefully)
"""
from __future__ import annotations
import os
import pathlib
import sys
import types
from unittest import mock
import pytest
def _inject_hermes_cli_auth(get_auth_status_return):
"""Inject a minimal hermes_cli.auth stub into sys.modules.
CI doesn't install hermes_cli (it's a separate package). Tests that
exercise the hermes_cli fallback path must inject the module themselves
rather than relying on mock.patch('hermes_cli.auth.get_auth_status')
which fails with ModuleNotFoundError when the module isn't installed.
"""
mock_auth = types.ModuleType("hermes_cli.auth")
mock_auth.get_auth_status = mock.MagicMock(return_value=get_auth_status_return)
mock_hermes_cli = types.ModuleType("hermes_cli")
return mock.patch.dict(sys.modules, {
"hermes_cli": mock_hermes_cli,
"hermes_cli.auth": mock_auth,
})
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _call_provider_api_key_present(provider: str, cfg: dict = None, env_values: dict = None):
from api.onboarding import _provider_api_key_present
return _provider_api_key_present(provider, cfg or {}, env_values or {})
# ---------------------------------------------------------------------------
# 1. _provider_api_key_present via hermes_cli fallback
# ---------------------------------------------------------------------------
class TestProviderApiKeyPresentFallback:
def test_minimax_cn_logged_in_returns_true(self):
"""minimax-cn: if hermes_cli.auth.get_auth_status returns logged_in, must be True."""
with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", {
"openrouter": {}, "anthropic": {}, "openai": {}, "custom": {}
}):
with _inject_hermes_cli_auth({"logged_in": True}):
result = _call_provider_api_key_present("minimax-cn")
assert result is True
def test_unsupported_provider_logged_out_returns_false(self):
"""Unsupported provider with no key → False, no crash."""
with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", {
"openrouter": {}, "anthropic": {}, "openai": {}, "custom": {}
}):
with _inject_hermes_cli_auth({"logged_in": False}):
result = _call_provider_api_key_present("deepseek")
assert result is False
def test_hermes_cli_import_failure_is_safe(self):
"""If hermes_cli is unavailable, falls back silently to False."""
import builtins
real_import = builtins.__import__
def _block_hermes_cli(name, *args, **kwargs):
if name.startswith("hermes_cli"):
raise ImportError("hermes_cli not available")
return real_import(name, *args, **kwargs)
with mock.patch("api.onboarding._SUPPORTED_PROVIDER_SETUPS", {
"openrouter": {}, "anthropic": {}, "openai": {}, "custom": {}
}):
with mock.patch("builtins.__import__", side_effect=_block_hermes_cli):
result = _call_provider_api_key_present("minimax-cn")
assert result is False # safe fallback
def test_supported_provider_still_works_without_fallback(self):
"""openrouter with env key must still succeed via the original path."""
from api.onboarding import _provider_api_key_present, _SUPPORTED_PROVIDER_SETUPS
env_values = {"OPENROUTER_API_KEY": "sk-test"}
result = _provider_api_key_present("openrouter", {}, env_values)
assert result is True
def test_inline_api_key_in_cfg_still_works(self):
"""model.api_key in config.yaml must be recognized for any provider."""
cfg = {"model": {"provider": "minimax-cn", "default": "MiniMax-M2.7", "api_key": "key123"}}
result = _call_provider_api_key_present("minimax-cn", cfg)
assert result is True
# ---------------------------------------------------------------------------
# 2. _status_from_runtime: unsupported provider with key → chat_ready=True
# ---------------------------------------------------------------------------
class TestStatusFromRuntimeUnsupportedProvider:
def _run(self, provider: str, model: str, api_key_present: bool, oauth_present: bool = False):
from api.onboarding import _status_from_runtime
cfg = {"model": {"provider": provider, "default": model}}
with (
mock.patch("api.onboarding._HERMES_FOUND", True),
mock.patch("api.onboarding._load_env_file", return_value={}),
mock.patch("api.onboarding._get_active_hermes_home", return_value=pathlib.Path("/tmp")),
mock.patch("api.onboarding._provider_api_key_present", return_value=api_key_present),
mock.patch("api.onboarding._provider_oauth_authenticated", return_value=oauth_present),
):
return _status_from_runtime(cfg, True)
def test_minimax_cn_with_key_gives_chat_ready(self):
"""minimax-cn + api key present → chat_ready must be True."""
result = self._run("minimax-cn", "MiniMax-M2.7", api_key_present=True)
assert result["chat_ready"] is True, f"Expected chat_ready=True, got: {result}"
assert result["provider_ready"] is True
assert result["setup_state"] == "ready"
def test_deepseek_with_key_gives_chat_ready(self):
"""deepseek + api key → chat_ready."""
result = self._run("deepseek", "deepseek-chat", api_key_present=True)
assert result["chat_ready"] is True
def test_unsupported_provider_no_key_no_oauth_gives_not_ready(self):
"""No key, no oauth → provider_ready=False."""
result = self._run("minimax-cn", "MiniMax-M2.7", api_key_present=False, oauth_present=False)
assert result["chat_ready"] is False
assert result["provider_ready"] is False
def test_oauth_provider_still_works_via_oauth_path(self):
"""openai-codex (OAuth) with no api_key but oauth present → ready."""
result = self._run("openai-codex", "codex-model", api_key_present=False, oauth_present=True)
assert result["chat_ready"] is True
# ---------------------------------------------------------------------------
# 3. get_onboarding_status: minimax-cn fully configured → completed=True
# ---------------------------------------------------------------------------
class TestOnboardingStatusUnsupportedProvider:
def _make_status(self, chat_ready: bool, provider: str = "minimax-cn"):
import api.onboarding as mod
fake_config_path = pathlib.Path("/tmp/_test_572_config.yaml")
cfg = {"model": {"provider": provider, "default": "MiniMax-M2.7"}}
runtime = {
"chat_ready": chat_ready,
"provider_configured": True,
"provider_ready": chat_ready,
"setup_state": "ready" if chat_ready else "provider_incomplete",
"provider_note": "test",
"current_provider": provider,
"current_model": "MiniMax-M2.7",
"current_base_url": None,
"env_path": "/tmp/.env",
}
with (
mock.patch.object(mod, "load_settings", return_value={}),
mock.patch.object(mod, "get_config", return_value=cfg),
mock.patch.object(mod, "verify_hermes_imports", return_value=(True, [], {})),
mock.patch.object(mod, "_status_from_runtime", return_value=runtime),
mock.patch.object(mod, "load_workspaces", return_value=[]),
mock.patch.object(mod, "get_last_workspace", return_value=None),
mock.patch.object(mod, "get_available_models", return_value=[]),
mock.patch.object(mod, "_get_config_path", return_value=fake_config_path),
mock.patch.object(pathlib.Path, "exists", return_value=True),
):
return mod.get_onboarding_status()
def test_minimax_cn_chat_ready_skips_wizard(self):
"""minimax-cn + chat_ready=True + config.yaml exists → wizard must NOT fire."""
result = self._make_status(chat_ready=True)
assert result["completed"] is True, (
"Wizard fired for minimax-cn user with valid config! "
"config.yaml + chat_ready=True must auto-complete onboarding regardless of provider."
)
def test_minimax_cn_not_ready_skips_wizard(self):
"""minimax-cn + chat_ready=False → wizard still skipped for non-wizard providers.
The onboarding wizard has no minimax-cn option — showing it would only confuse
the user or let them accidentally overwrite their config with an OpenAI/Anthropic
provider. For any provider not in _SUPPORTED_PROVIDER_SETUPS, onboarding is
auto-completed as long as provider_configured is True, regardless of chat_ready.
Users on non-wizard providers with no API key should fix credentials via
Settings → Providers, not via the first-run wizard. (#1020)
"""
result = self._make_status(chat_ready=False)
assert result["completed"] is True, (
"Wizard fired for minimax-cn user with provider_configured=True! "
"Non-wizard providers must auto-complete onboarding because the wizard "
"cannot configure them and would silently overwrite their config."
)
def test_current_is_oauth_set_for_unsupported_provider(self):
"""setup.current_is_oauth must be True for minimax-cn (not in quick-setup list)."""
result = self._make_status(chat_ready=True)
assert result["setup"]["current_is_oauth"] is True, (
"current_is_oauth should be True for providers not in _SUPPORTED_PROVIDER_SETUPS"
)