Files
hermes-webui/tests/test_issue1014_model_not_found.py
T
nesquena-hermes ad8e10304c v0.50.207: batch of 10 PRs — TPS stat, SSE guard, session polish, cron UX, folder create, model errors, session speed, title gen (#1031)
* fix: remove orphaned i18n keys from top-level LOCALES object

Three Traditional Chinese translation keys (cmd_status, memory_saved,
profile_delete_title) were placed outside any locale block between the
en and ru blocks in static/i18n.js. They became top-level properties
of the LOCALES object, causing them to appear as invalid language
options in the Settings > Preferences dropdown.

The correct translations already exist in the zh-Hant locale block.

Fixes #1008

* fix: block stale SSE events from polluting new session's DOM

- appendThinking(): guard with !S.session||!S.activeStreamId to drop
  events from a previous session's SSE stream during a session switch
- appendLiveToolCard(): same guard for consistency
- finalizeThinkingCard(): scroll thinking-card-body to top when
  scroll is pinned, so completed response is immediately visible
- appendThinking(): auto-scroll thinking card body to bottom while
  streaming if user is watching (scroll pinned)

* Fix empty agent sessions in sidebar

* fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status

Fixes #995 — three sub-issues in the Cron Jobs UI:

1. Dual play icons ambiguous: Resume button now shows a distinct
   play+bar icon (play triangle + vertical line) instead of the
   identical triangle used by Run now.

2. Toast notification overlapping header buttons: Added
   position:relative; z-index:10 to .main-view-header so it
   stacks above the fixed toast (z-index:100 within its layer).

3. No running status after trigger: After triggering a job, the
   status badge immediately shows 'running…' with a CSS spinner
   animation, and polls the cron list every 3s (up to 30s) to
   refresh when the job completes.

- Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant)
- Added .detail-badge.running CSS class with spinner animation
- New functions: _setCronDetailStatus(), _startCronRunningPoll()

* fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback

- _clearCronDetail() now clears _cronRunningPoll interval on navigation
- Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker)
- When poll ends (30s max), detail re-renders with actual status as fallback

* feat: create folder and add space directly from UI (#782)

- After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog
- Add Create folder if it doesnt exist checkbox in the New Space form
- Backend: support create flag in /api/workspaces/add to mkdir before validation
- i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales

* fix: validate workspace path before mkdir to prevent orphan directories

Review feedback (critical): the previous code called mkdir() before
validate_workspace_to_add(), which meant a rejected path (e.g. system dir)
would leave an orphan directory on disk.

New flow:
1. Resolve path and check against blocked system roots BEFORE any mutation
2. mkdir() only if path passes the blocklist check
3. Full validation (exists, is_dir) after mkdir

Also imports _workspace_blocked_roots for the pre-mutation blocklist check.

* fix(#1014): classify model-not-found errors with helpful message

- Add model_not_found error type to streaming.py exception classifier
- Detect 404, 'not found', 'does not exist', 'invalid model' patterns
- Strip HTML tags from provider error messages (nginx 404 pages, etc.)
- Add model_not_found branch to apperror handler in messages.js
- Add i18n key model_not_found_label in all 6 locales
- 15 tests covering detection, sanitization, frontend, and i18n

* feat(ui): add live TPS stat to header

Adds a TPS (Tokens Per Second) chip to the right of the header title bar
that updates live while AI output is streaming.

Metering (api/metering.py)
- Tracks per-session output + reasoning tokens via GlobalMeter singleton
- Per-session TPS = total_tokens / elapsed_time
- Global TPS = average of active sessions' TPS values
- HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling
  window (only recorded when > 0, so idle periods are excluded)
- Thread-safe with a single lock

Metering events emitted from streaming.py
- Throttled at 100ms from token/reasoning/tool callbacks so the display
  updates rapidly during fast token streams
- 1Hz ticker as fallback for slow streams (exits when no active sessions)
- Final stats emitted on stream end

Routes (api/routes.py)
- Removed POST /api/metering/interval endpoint (dynamic interval via
  focus/blur was replaced with simple always-1s-when-active approach)

UI (static/messages.js, index.html, style.css)
- TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low'
- Default: '0.0 t/s . 0.0 high' when idle
- Display updates on every metering SSE event (throttled to 100ms)

* feat: session restore speed + title gen reasoning hardening (#1025, #1026)

PR #1025 (@franksong2702): Speed up large session restore paths
- GET /api/session?messages=0 now parses only metadata before the messages array
- Metadata-only loads no longer populate the full-session LRU cache
- Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup
- Hard reload no longer waits for populateModelDropdown() before restoring session

PR #1026 (@franksong2702): Harden auto title generation for reasoning models
- Raises title-gen completion budget to 512 tokens (reasoning-safe)
- Retries once with 1024 tokens on empty content / finish_reason:length
- Applies retry to both auxiliary and active-agent fallback routes
- Preserves underlying failure reason in title_status on local fallback

Co-authored-by: Frank Song <franksong2702@gmail.com>

* feat: session attention indicators in right slot + last_message_at timestamps (#1024)

PR #1024 (@franksong2702): Polish session attention indicators

- Streaming spinners and unread dots now reuse the right-side actions slot
- Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps
- Date group carets point down when expanded, right when collapsed
- Pinned group no longer repeats pinned-star icon per row
- Running indicators appear immediately after send (local busy state while /api/sessions catches up)
- Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message)
  so metadata-only saves don't make old sessions appear under Today

Co-authored-by: Frank Song <franksong2702@gmail.com>

* docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36)

---------

Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
Co-authored-by: Josh <josh@fyul.link>
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
2026-04-25 13:07:35 -07:00

199 lines
8.2 KiB
Python

"""
Tests for issue #1014 — model-not-found error classification.
Covers:
1. streaming.py: 404/model-not-found errors detected and classified as 'model_not_found'
2. streaming.py: HTML tags stripped from provider error messages before classification
3. static/messages.js: apperror handler has model_not_found branch
4. static/i18n.js: model_not_found_label key present in all locales
5. streaming.py: model_not_found checked after auth but before generic error
"""
import pathlib
import re
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
def _read(rel_path: str) -> str:
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
# ── 1. streaming.py: model-not-found error detection ─────────────────────────
class TestStreamingModelNotFoundDetection:
"""streaming.py must classify 404/model-not-found errors as model_not_found."""
def test_model_not_found_type_defined_in_streaming(self):
"""'model_not_found' type must be emitted for 404 errors."""
src = _read("api/streaming.py")
assert "model_not_found" in src, (
"model_not_found type not found in streaming.py — "
"404 errors will not be surfaced with a helpful message"
)
def test_is_not_found_flag_defined(self):
"""_exc_is_not_found variable must exist in the exception handler."""
src = _read("api/streaming.py")
assert "_exc_is_not_found" in src, (
"_exc_is_not_found flag not found in streaming.py"
)
def test_not_found_detects_404(self):
"""'404' must be part of the model-not-found detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
assert idx != -1, "_exc_is_not_found not found"
block = src[idx:idx + 600]
assert "'404'" in block or '"404"' in block, (
"'404' not in model-not-found detection block"
)
def test_not_found_detects_not_found_string(self):
"""'not found' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "not found" in block.lower(), (
"'not found' not in model-not-found detection block"
)
def test_not_found_detects_does_not_exist(self):
"""'does not exist' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "does not exist" in block.lower(), (
"'does not exist' not in model-not-found detection block"
)
def test_not_found_detects_invalid_model(self):
"""'invalid model' must be part of the detection logic."""
src = _read("api/streaming.py")
idx = src.find("_exc_is_not_found")
block = src[idx:idx + 600]
assert "invalid model" in block.lower(), (
"'invalid model' not in model-not-found detection block"
)
def test_not_found_hint_mentions_settings(self):
"""The model_not_found hint must mention Settings or hermes model."""
src = _read("api/streaming.py")
idx = src.find("model_not_found")
block = src[idx:idx + 500]
assert "Settings" in block or "hermes model" in block, (
"model_not_found hint must mention Settings or hermes model command"
)
def test_not_found_check_order_after_auth(self):
"""model_not_found must be checked after auth_mismatch (auth first)."""
src = _read("api/streaming.py")
auth_idx = src.find("elif _exc_is_auth")
nf_idx = src.find("elif _exc_is_not_found")
assert auth_idx != -1, "_exc_is_auth not found"
assert nf_idx != -1, "_exc_is_not_found not found"
assert auth_idx < nf_idx, (
"auth_mismatch should be checked before model_not_found — "
"auth errors must not be mistaken for not-found errors"
)
# ── 2. streaming.py: HTML sanitization ───────────────────────────────────────
class TestStreamingHtmlSanitization:
"""Provider error messages containing HTML must be stripped."""
def test_html_strip_before_classification(self):
"""HTML tags must be stripped before error classification."""
src = _read("api/streaming.py")
# Find the HTML sanitization block in the exception handler
# It should appear before _exc_lower = err_str.lower()
sanitize_idx = src.find("re.sub(r'<[^>]+>'")
exc_lower_idx = src.find("_exc_lower = err_str.lower()")
assert sanitize_idx != -1, (
"HTML tag stripping (re.sub) not found in streaming.py exception handler"
)
assert exc_lower_idx != -1, "_exc_lower not found"
assert sanitize_idx < exc_lower_idx, (
"HTML sanitization must happen before error classification"
)
def test_whitespace_normalization(self):
"""Stripped HTML must have whitespace collapsed."""
src = _read("api/streaming.py")
sanitize_idx = src.find("re.sub(r'<[^>]+>'")
block = src[sanitize_idx:sanitize_idx + 300]
assert r"\s+" in block, (
"Whitespace normalization (\\s+) not found after HTML strip"
)
# ── 3. static/messages.js: apperror handler ──────────────────────────────────
class TestApperrorModelNotFound:
"""messages.js apperror handler must handle model_not_found type."""
def test_model_not_found_type_handled(self):
"""apperror handler must check for type='model_not_found'."""
src = _read("static/messages.js")
assert "model_not_found" in src, (
"model_not_found type not handled in messages.js apperror handler"
)
def test_model_not_found_label(self):
"""'Model not found' label must appear in the error handling."""
src = _read("static/messages.js")
assert "Model not found" in src, (
"'Model not found' label not found in messages.js"
)
def test_is_model_not_found_variable(self):
"""isModelNotFound variable must be defined."""
src = _read("static/messages.js")
assert "isModelNotFound" in src, (
"isModelNotFound variable not found in messages.js apperror handler"
)
# ── 4. static/i18n.js: all locales ───────────────────────────────────────────
class TestI18nModelNotFound:
"""All locales must have model_not_found_label."""
REQUIRED_KEY = "model_not_found_label"
def _locale_names(self, src: str) -> list:
pattern = re.compile(
r"^\s{2}(?:'(?P<quoted>[A-Za-z0-9-]+)'|(?P<plain>[A-Za-z0-9-]+))\s*:\s*\{",
re.MULTILINE,
)
names = []
for match in pattern.finditer(src):
names.append(match.group("quoted") or match.group("plain"))
return names
def _count_key(self, src: str, key: str) -> int:
return len(re.findall(r'\b' + re.escape(key) + r'\b', src))
def test_all_locales_have_model_not_found_label(self):
"""model_not_found_label must appear in all locales."""
src = _read("static/i18n.js")
locale_count = len(self._locale_names(src))
count = self._count_key(src, self.REQUIRED_KEY)
assert count >= locale_count, (
f"model_not_found_label found {count} times, expected >= {locale_count} "
f"(one per locale)"
)
def test_english_label_is_plain_string(self):
"""English model_not_found_label must be a plain string, not a function."""
src = _read("static/i18n.js")
en_start = src.find("\n en: {")
es_start = src.find("\n es: {")
en_block = src[en_start:es_start]
assert self.REQUIRED_KEY in en_block, "Key not in en block"
idx = en_block.find(self.REQUIRED_KEY)
line = en_block[idx:idx + 200]
assert "=>" not in line, (
"model_not_found_label should be a plain string, not an arrow function"
)