diff --git a/CHANGELOG.md b/CHANGELOG.md index e6c02638..64d784ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +## [v0.50.252] — 2026-05-01 + +### Fixed +- **CLI session import no longer crashes when metadata row is missing** — `_handle_session_import_cli` only assigned `model` inside the `for cs in get_cli_sessions(): if cs["session_id"] == sid` loop. Sessions that existed in the messages store but were missing from the metadata index (post-pruning, race during cron job export, etc.) reached the downstream `import_cli_session(sid, title, msgs, model, ...)` call with `model` unbound and crashed with `UnboundLocalError`. The fix initializes `model = "unknown"` before the loop so the import proceeds with a sensible default. Added a regression test that asserts the init lives before the loop. (`api/routes.py`, `tests/test_session_import_cli_fallback_model.py`) @trucuit — PR #1386 +- **Streaming scroll no longer yanks the viewport when tool/queue cards insert** (#1360) — three independent paths could re-pin a user mid-read while the agent streamed: (a) browser scroll-anchoring on `#messages` shifted the scroller when card heights changed, (b) the queue-card render `setTimeout` called unconditional `scrollToBottom()` regardless of stream state, and (c) the queue-pill click handler did the same. Now `#messages` has `overflow-anchor:none`, the near-bottom re-pin dead zone widens from 150px to 250px (small macOS-app windows + trackpad momentum no longer re-pin too eagerly), and both queue-card paths respect `S.activeStreamId` — using `scrollIfPinned()` mid-stream and falling back to `scrollToBottom()` only after the stream ends. 4 regression tests pin all four invariants. (`static/style.css`, `static/ui.js`, `tests/test_issue1360_streaming_scroll_hardening.py`) @NocGeek — PR #1377, fixes #1360 +- **API credential redaction no longer regresses for `ghp_*` / `sk-*` / `hf_*` / `AKIA*` tokens** — `_build_redact_fn()` previously returned the agent's `redact_sensitive_text` directly whenever `agent.redact` imported. The agent redactor missed several common credential prefixes that the WebUI's local fallback already knew how to mask, so session/search/memory API responses could leak plaintext credentials. Now both run in series — agent first (handles broader patterns when `HERMES_REDACT_SECRETS` is enabled), local fallback second (always-on, catches the common token shapes). The chained order is safe: agent masking shortens tokens to a `prefix...suffix` form that the fallback regex's character class no longer matches, so no double-redaction. The agent-broader patterns (Stripe `sk_live_`, Google `AIza…`, JWT `eyJ…`) still depend on the env var; opening a follow-up to switch the WebUI call to `force=True`. (`api/helpers.py`) @NocGeek — PR #1379 +- **`/status` slash command shows the resolved Hermes home directory** (refs #463) — the WebUI `/status` card already showed model, profile, workspace, timestamps, and token counts but was missing the profile-aware Hermes home path that the CLI's `hermes status` displays. `session_status()` now returns `profile` and `hermes_home` keys (resolved via `get_hermes_home_for_profile()` so named profiles resolve to their dedicated dirs), and `commands.js cmdStatus` renders the new `Hermes home:` line. New `status_hermes_home` i18n key added across all 8 locales (en/ru/es/de/zh/zh-Hant/pt/ko). (`api/session_ops.py`, `static/commands.js`, `static/i18n.js`, `tests/test_session_ops.py`) @NocGeek — PR #1380, refs #463 + +### Added +- **`/api/models/live` now caches results for 60 seconds** — repeated model-list refreshes (every panel open, every workspace switch) hit upstream provider APIs every time. The new in-memory TTL cache keyed by `(active_profile, provider)` returns deep copies so callers can't mutate the cache, expires after 60s, and is guarded by `threading.RLock` for thread-safety. The cache lives next to `_handle_live_models` and is cleared via `_clear_live_models_cache()` in tests. 4 regression tests cover hit-within-TTL, expiry, profile-scoping (default vs research stay separate), and mutation isolation. (`api/routes.py`, `tests/test_live_models_ttl_cache.py`) @NocGeek — PR #1378 +- **WebUI explains CLI-only slash commands instead of forwarding them to the model** — typing `/browser connect` or any other Hermes CLI-only command in the WebUI used to fall through as plain text, so the model would explain the command instead of the app. The frontend now lazy-fetches `/api/commands` metadata, matches by name and aliases, and intercepts any command flagged `cli_only` with a local assistant message that explains the command is CLI-only. Special note for `/browser` about how WebUI's browser tools must be configured server-side (CLI-only `/browser` itself does not work in the WebUI). Built on the existing `cli_only` field that `/api/commands` already exposed; no agent-side changes. (`static/commands.js`, `static/messages.js`, `tests/test_cli_only_slash_commands.py`) @NocGeek — PR #1382 + +### Changed +- **API credential redaction now uses `force=True`** — `_combined_redact` (introduced by #1379) now passes `force=True` to `redact_sensitive_text` so the agent's broader patterns (Stripe `sk_live_`, Google `AIza…`, JWT `eyJ…`, DB connection strings, Telegram bot tokens) run regardless of the user's `HERMES_REDACT_SECRETS` opt-in. The local fallback then handles the short-prefix shapes the agent omits (`ghp_`, `sk-`, `hf_`, `AKIA`). WebUI API responses are a hard safety boundary — no opt-in should be required. (`api/helpers.py`) — Opus pre-release follow-up +- **`_active_profile_for_live_models_cache` logs the fallback path** — when `get_active_profile_name()` raises (transient state, mid-switch, etc.) the live-models cache (#1378) falls back to `"default"`, mis-scoping the cache for up to 60s. Now logs at debug so we can detect this in production logs without changing the blast radius (TTL still caps the bad-cache window). (`api/routes.py`) — Opus pre-release follow-up + ## [v0.50.251] — 2026-04-30 ### Fixed diff --git a/api/helpers.py b/api/helpers.py index d5091023..130b37eb 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -110,14 +110,10 @@ MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies # ── Credential redaction ────────────────────────────────────────────────────── def _build_redact_fn(): - """Return redact_sensitive_text from hermes-agent if available, else a fallback.""" - try: - from agent.redact import redact_sensitive_text - return redact_sensitive_text - except ImportError: - pass - - # Minimal fallback covering the most common credential prefixes + """Return a redactor backed by hermes-agent plus local fallback patterns.""" + # Minimal fallback covering the most common credential prefixes. + # Keep this active even when hermes-agent is importable so API responses do + # not regress if the agent redactor misses a token shape. _CRED_RE = _re.compile( r"(? str: + if not isinstance(text, str) or not text: + return text + # WebUI API responses are a hard safety boundary — pass force=True so the + # agent's broader patterns (Stripe sk_live_, Google AIza…, JWT eyJ…, DB + # connection strings, Telegram bot tokens) run regardless of the user's + # HERMES_REDACT_SECRETS opt-in. The local fallback then handles the + # common short-prefix shapes the agent omits (ghp_, sk-, hf_, AKIA). + return _fallback_redact(redact_sensitive_text(text, force=True)) + + return _combined_redact _redact_text = _build_redact_fn() diff --git a/api/routes.py b/api/routes.py index 8d5bb386..70a6be90 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4,6 +4,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell. """ import html as _html +import copy import json import logging import os @@ -143,9 +144,49 @@ _OPENAI_COMPAT_ENDPOINTS = { # the openai provider is already wired through provider_model_ids(); codex- # specific model filtering happens downstream in hermes_cli. # -# TODO: Add TTL-based caching (e.g. 60s) so repeated model-list requests -# don't hit provider APIs. The frontend already caches via _liveModelCache -# but the backend re-fetches on every /api/models/live call. +_LIVE_MODELS_CACHE_TTL = 60.0 +_LIVE_MODELS_CACHE: dict[tuple[str, str], tuple[float, dict]] = {} +_LIVE_MODELS_CACHE_LOCK = threading.RLock() + + +def _active_profile_for_live_models_cache() -> str: + try: + from api.profiles import get_active_profile_name + + return get_active_profile_name() or "default" + except Exception as _e: + # A transient profile-resolution error mis-scopes the cache for up to + # 60s ("default" gets the wrong payload). Log so we can detect it; the + # blast radius stays small because the TTL caps the bad-cache window. + logger.debug("_active_profile_for_live_models_cache fell back to 'default': %s", _e) + return "default" + + +def _live_models_cache_key(provider: str) -> tuple[str, str]: + return (_active_profile_for_live_models_cache(), provider) + + +def _get_cached_live_models(key: tuple[str, str]) -> dict | None: + now = time.monotonic() + with _LIVE_MODELS_CACHE_LOCK: + cached = _LIVE_MODELS_CACHE.get(key) + if not cached: + return None + ts, payload = cached + if now - ts >= _LIVE_MODELS_CACHE_TTL: + _LIVE_MODELS_CACHE.pop(key, None) + return None + return copy.deepcopy(payload) + + +def _set_cached_live_models(key: tuple[str, str], payload: dict) -> None: + with _LIVE_MODELS_CACHE_LOCK: + _LIVE_MODELS_CACHE[key] = (time.monotonic(), copy.deepcopy(payload)) + + +def _clear_live_models_cache() -> None: + with _LIVE_MODELS_CACHE_LOCK: + _LIVE_MODELS_CACHE.clear() from api.config import ( STATE_DIR, @@ -3193,6 +3234,15 @@ def _handle_live_models(handler, parsed): from api.config import _resolve_provider_alias provider = _resolve_provider_alias(provider) + cache_key = _live_models_cache_key(provider) + cached = _get_cached_live_models(cache_key) + if cached is not None: + return j(handler, cached) + + def _finish(payload: dict): + _set_cached_live_models(cache_key, payload) + return j(handler, payload) + # Delegate to the agent's live-fetch + fallback resolver. # provider_model_ids() tries live endpoints first and falls back to # the static _PROVIDER_MODELS list — it never raises. @@ -3310,7 +3360,7 @@ def _handle_live_models(handler, parsed): from api.config import _PROVIDER_MODELS as _pm ids = [m["id"] for m in _pm.get(provider, [])] if not ids: - return j(handler, {"provider": provider, "models": [], "count": 0}) + return _finish({"provider": provider, "models": [], "count": 0}) # Normalise to {id, label} — provider_model_ids() returns plain string IDs. # For ollama-cloud use the shared Ollama formatter (handles `:variant` suffix). @@ -3343,8 +3393,8 @@ def _handle_live_models(handler, parsed): return label models_out = [{"id": mid, "label": _make_label(mid)} for mid in ids if mid] - return j(handler, {"provider": provider, "models": models_out, - "count": len(models_out)}) + return _finish({"provider": provider, "models": models_out, + "count": len(models_out)}) except Exception as _e: logger.debug("_handle_live_models failed for %s: %s", provider, _e) @@ -4635,6 +4685,7 @@ def _handle_session_import_cli(handler, body): updated_at = None cli_title = None cli_source_tag = None + model = "unknown" for cs in get_cli_sessions(): if cs["session_id"] == sid: profile = cs.get("profile") diff --git a/api/session_ops.py b/api/session_ops.py index be51ec1b..5fc7a256 100644 --- a/api/session_ops.py +++ b/api/session_ops.py @@ -137,10 +137,18 @@ def session_status(session_id: str) -> dict[str, Any]: s = get_session(session_id) inp = int(s.input_tokens or 0) out = int(s.output_tokens or 0) + profile = getattr(s, 'profile', None) or 'default' + try: + from api.profiles import get_hermes_home_for_profile + hermes_home = str(get_hermes_home_for_profile(profile)) + except Exception: + hermes_home = '' return { 'session_id': s.session_id, 'title': s.title, 'model': s.model, + 'profile': profile, + 'hermes_home': hermes_home, 'workspace': s.workspace, 'personality': s.personality, 'message_count': len(s.messages or []), diff --git a/static/commands.js b/static/commands.js index 431c079d..4199ac8d 100644 --- a/static/commands.js +++ b/static/commands.js @@ -84,6 +84,8 @@ let _slashModelCache=null; let _slashModelCachePromise=null; let _slashPersonalityCache=null; let _slashPersonalityCachePromise=null; +let _agentCommandCache=null; +let _agentCommandCachePromise=null; function _normalizeSlashSubArg(value){ return String(value||'').trim(); @@ -162,6 +164,44 @@ function _getSlashSubArgOptions(spec){ return Promise.resolve([]); } +async function loadAgentCommandMetadata(force=false){ + if(_agentCommandCache&&!force) return _agentCommandCache; + if(_agentCommandCachePromise&&!force) return _agentCommandCachePromise; + _agentCommandCachePromise=(async()=>{ + try{ + const data=await api('/api/commands'); + _agentCommandCache=Array.isArray(data&&data.commands)?data.commands:[]; + }catch(_){ + _agentCommandCache=[]; + }finally{ + _agentCommandCachePromise=null; + } + return _agentCommandCache; + })(); + return _agentCommandCachePromise; +} + +async function getAgentCommandMetadata(name){ + const needle=String(name||'').trim().toLowerCase(); + if(!needle) return null; + const commands=await loadAgentCommandMetadata(); + return commands.find(cmd=>{ + if(String(cmd&&cmd.name||'').toLowerCase()===needle) return true; + return Array.isArray(cmd&&cmd.aliases)&&cmd.aliases.some(a=>String(a||'').toLowerCase()===needle); + })||null; +} + +function cliOnlyCommandResponse(cmdName, meta){ + const name=String((meta&&meta.name)||cmdName||'').trim(); + const desc=String((meta&&meta.description)||'').trim(); + const detail=desc?`\n\n${desc}`:''; + let extra=''; + if(name==='browser'){ + extra='\n\nBrowser tools in WebUI must be configured server-side with the agent/browser environment. Once configured, ask the model to use browser tools directly; `/browser` itself only works in `hermes chat`.'; + } + return `\`/${name}\` is a Hermes CLI-only command and cannot run inside the WebUI.${detail}${extra}`; +} + function _parseSlashAutocomplete(text){ if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null; const raw=text.slice(1); @@ -759,7 +799,7 @@ async function cmdStatus(){ if(r&&r.error){showToast(r.error);return;} // Build status card lines matching CLI /status output const provider=window._activeProvider||''; - const profile=S.activeProfile||'default'; + const profile=r.profile||S.activeProfile||'default'; const started=r.created_at?new Date(r.created_at).toLocaleString():t('status_unknown'); const fmtNum=n=>typeof n==='number'?n.toLocaleString():'0'; const tokens=r.total_tokens?`${fmtNum(r.input_tokens)} in / ${fmtNum(r.output_tokens)} out`:t('status_no_tokens'); @@ -770,6 +810,7 @@ async function cmdStatus(){ `**${t('status_title')}:** ${r.title||t('untitled')}`, `**${t('status_model')}:** ${r.model||t('usage_default_model')}${provider?' ('+provider+')':''}`, `**${t('status_profile')}:** ${profile}`, + `**${t('status_hermes_home')}:** ${r.hermes_home||t('status_unknown')}`, `**${t('status_workspace')}:** ${r.workspace}`, `**${t('status_personality')}:** ${r.personality||t('usage_personality_none')}`, `**${t('status_started')}:** ${started}`, diff --git a/static/i18n.js b/static/i18n.js index 8d0abb55..ca6b841c 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -209,6 +209,7 @@ const LOCALES = { status_messages:'Messages', status_agent_running:'Agent running', status_profile: 'Profile', + status_hermes_home: 'Hermes home', status_started: 'Started', status_tokens: 'Tokens', status_no_tokens: 'No tokens used', @@ -1479,6 +1480,7 @@ const LOCALES = { settings_tab_system: 'System', status_no_tokens: 'No token data', status_profile: 'Profile', + status_hermes_home: 'Hermes home', status_started: 'Started', status_tokens: 'Tokens', status_unknown: 'Unknown', @@ -2212,6 +2214,7 @@ const LOCALES = { settings_tab_system: 'System', status_no_tokens: 'No token data', status_profile: 'Profile', + status_hermes_home: 'Hermes home', status_started: 'Started', status_tokens: 'Tokens', status_unknown: 'Unknown', @@ -2718,6 +2721,7 @@ const LOCALES = { settings_tab_system: 'System', status_no_tokens: 'No token data', status_profile: 'Profile', + status_hermes_home: 'Hermes home', status_started: 'Started', status_tokens: 'Tokens', status_unknown: 'Unknown', @@ -3679,6 +3683,7 @@ const LOCALES = { settings_tab_system: 'System', status_no_tokens: 'No token data', status_profile: 'Profile', + status_hermes_home: 'Hermes home', status_started: 'Started', status_tokens: 'Tokens', status_unknown: 'Unknown', @@ -4438,6 +4443,7 @@ const LOCALES = { providers_tab_title: '供應商', status_agent_running: 'Agent 執行中', status_profile: '個人資料', + status_hermes_home: 'Hermes 主目錄', status_started: '開始時間', status_tokens: 'Token', status_no_tokens: '未使用 Token', @@ -4739,6 +4745,7 @@ const LOCALES = { status_messages: 'Mensagens', status_agent_running: 'Agente rodando', status_profile: 'Perfil', + status_hermes_home: 'Diretório Hermes', status_started: 'Iniciado', status_tokens: 'Tokens', status_no_tokens: 'Nenhum token usado', @@ -5424,6 +5431,7 @@ const LOCALES = { status_messages: '메시지', status_agent_running: '에이전트 실행 중', status_profile: '프로필', + status_hermes_home: 'Hermes 홈', status_started: '시작 시간', status_tokens: '토큰', status_no_tokens: '사용된 토큰 없음', diff --git a/static/messages.js b/static/messages.js index 8974b4b5..a7a25e17 100644 --- a/static/messages.js +++ b/static/messages.js @@ -132,6 +132,18 @@ async function send(){ $('msg').value='';autoResize();hideCmdDropdown();return; } } + if(_parsedCmd&&!_cmd){ + const _agentCmd=typeof getAgentCommandMetadata==='function' + ? await getAgentCommandMetadata(_parsedCmd.name) + : null; + if(_agentCmd&&_agentCmd.cli_only){ + if(!S.session){await newSession();await renderSessionList();} + S.messages.push({role:'user',content:text,_ts:Date.now()/1000}); + S.messages.push({role:'assistant',content:cliOnlyCommandResponse(_parsedCmd.name,_agentCmd),_ts:Date.now()/1000}); + renderMessages(); + $('msg').value='';autoResize();hideCmdDropdown();return; + } + } } if(!S.session){await newSession();await renderSessionList();} diff --git a/static/style.css b/static/style.css index e9a19cd7..08a4dc07 100644 --- a/static/style.css +++ b/static/style.css @@ -681,7 +681,7 @@ .workspace-toggle-btn.active{color:var(--accent-text);border-color:var(--accent-bg);background:var(--accent-bg);} .workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;} .chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);} - .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;} + .messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;} /* sticky-first-child: button is first child of .messages so its natural position is above viewport; sticky+bottom:16px pins it there when visible */ .scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s;} .scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);} diff --git a/static/ui.js b/static/ui.js index f274a279..fd1c9743 100644 --- a/static/ui.js +++ b/static/ui.js @@ -885,13 +885,13 @@ document.addEventListener('click',function(e){ // ── Scroll pinning ────────────────────────────────────────────────────────── // When streaming, auto-scroll only if the user hasn't manually scrolled up. -// Once the user scrolls back to within 150px of the bottom, re-pin. +// Once the user scrolls back to within 250px of the bottom, re-pin. let _scrollPinned=true; (function(){ const el=document.getElementById('messages'); if(!el) return; el.addEventListener('scroll',()=>{ - const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<150; + const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250; _scrollPinned=nearBottom; const btn=$('scrollToBottomBtn'); if(btn) btn.style.display=_scrollPinned?'none':'flex'; @@ -1818,7 +1818,8 @@ function _renderQueueChips(sid){ if(!card.classList.contains('visible')) return; const h=card.getBoundingClientRect().height; if(h>0) _msgs.style.setProperty('--queue-card-height', h+'px'); - if(typeof scrollToBottom==='function') scrollToBottom(); + if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned(); + else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom(); }, 360); } @@ -2008,7 +2009,8 @@ function _updateQueuePill(sid,count){ }, 360); } if(pillOuter) pillOuter.classList.remove('show'); - if(typeof scrollToBottom==='function') scrollToBottom(); + if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned(); + else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom(); }; } else { if(pillOuter) pillOuter.classList.remove('show'); diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py new file mode 100644 index 00000000..b1449d32 --- /dev/null +++ b/tests/test_cli_only_slash_commands.py @@ -0,0 +1,194 @@ +"""Regression tests for WebUI handling of Hermes CLI-only slash commands.""" + +import json +from pathlib import Path +import subprocess +import textwrap +from types import SimpleNamespace + +from api.commands import list_commands + + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMMANDS_JS = (REPO_ROOT / "static" / "commands.js").read_text(encoding="utf-8") +MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8") + + +def test_api_commands_exposes_cli_only_metadata_for_webui_intercept(): + """CLI-only commands must remain visible so the frontend can explain them.""" + registry = [ + SimpleNamespace( + name="browser", + description="Attach browser tools", + category="tools", + aliases=["browse"], + args_hint="connect", + subcommands=["connect"], + cli_only=True, + gateway_only=False, + ) + ] + + body = list_commands(registry) + + assert body == [ + { + "name": "browser", + "description": "Attach browser tools", + "category": "tools", + "aliases": ["browse"], + "args_hint": "connect", + "subcommands": ["connect"], + "cli_only": True, + "gateway_only": False, + } + ] + + +def test_frontend_fetches_agent_command_metadata_lazily(): + assert "async function loadAgentCommandMetadata" in COMMANDS_JS + assert "api('/api/commands')" in COMMANDS_JS + assert "_agentCommandCache" in COMMANDS_JS + + +def test_frontend_matches_agent_command_aliases(): + helper_idx = COMMANDS_JS.find("async function getAgentCommandMetadata") + assert helper_idx != -1 + helper = COMMANDS_JS[helper_idx : helper_idx + 700] + assert "cmd.aliases" in helper + assert "some(a=>String(a||'').toLowerCase()===needle)" in helper + + +def test_cli_only_response_mentions_webui_and_cli_scope(): + assert "function cliOnlyCommandResponse" in COMMANDS_JS + assert "Hermes CLI-only command" in COMMANDS_JS + assert "cannot run inside the WebUI" in COMMANDS_JS + + +def test_browser_cli_only_response_explains_server_side_browser_tools(): + response_idx = COMMANDS_JS.find("function cliOnlyCommandResponse") + response = COMMANDS_JS[response_idx : response_idx + 900] + assert "if(name==='browser')" in response + assert "configured server-side" in response + assert "`/browser` itself only works in `hermes chat`" in response + + +def _run_commands_js(script_body: str) -> dict: + script = textwrap.dedent( + f""" + const vm = require('vm'); + const ctx = {{ + console, + localStorage: {{ getItem(){{return null;}}, setItem(){{}}, removeItem(){{}} }}, + t: (key) => key, + api: async (path) => {{ + if (path !== '/api/commands') throw new Error('unexpected api path: ' + path); + return {{ + commands: [ + {{ + name: 'browser', + description: 'Attach browser tools', + aliases: ['browse'], + cli_only: true, + gateway_only: false + }}, + {{ + name: 'model', + description: 'Change model', + aliases: [], + cli_only: false, + gateway_only: false + }} + ] + }}; + }} + }}; + vm.createContext(ctx); + vm.runInContext({json.dumps(COMMANDS_JS)}, ctx); + (async () => {{ + const result = await vm.runInContext(`(async () => {{ {script_body} }})()`, ctx); + process.stdout.write(JSON.stringify(result)); + }})().catch(err => {{ + console.error(err && err.stack || err); + process.exit(1); + }}); + """ + ) + proc = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True) + return json.loads(proc.stdout) + + +def test_agent_command_metadata_helper_resolves_name_and_alias(): + result = _run_commands_js( + """ + const byName = await getAgentCommandMetadata('browser'); + const byAlias = await getAgentCommandMetadata('browse'); + const unknown = await getAgentCommandMetadata('does-not-exist'); + return { + by_name: byName && byName.name, + by_alias: byAlias && byAlias.name, + cli_only: byAlias && byAlias.cli_only === true, + unknown: unknown === null + }; + """ + ) + + assert result == { + "by_name": "browser", + "by_alias": "browser", + "cli_only": True, + "unknown": True, + } + + +def test_cli_only_response_helper_uses_canonical_command_name(): + result = _run_commands_js( + """ + const meta = await getAgentCommandMetadata('browse'); + return { + response: cliOnlyCommandResponse('browse', meta) + }; + """ + ) + + assert "`/browser` is a Hermes CLI-only command" in result["response"] + assert "Attach browser tools" in result["response"] + assert "configured server-side" in result["response"] + + +def test_send_intercepts_cli_only_commands_before_agent_round_trip(): + intercept_idx = MESSAGES_JS.find("Slash command intercept") + assert intercept_idx != -1 + normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) + assert normal_send_idx != -1 + intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + + assert "await getAgentCommandMetadata(_parsedCmd.name)" in intercept + assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept + assert "cliOnlyCommandResponse(_parsedCmd.name,_agentCmd)" in intercept + assert "return;" in intercept + + +def test_unknown_slash_commands_still_fall_through_to_agent(): + """Only known cli_only commands should be intercepted.""" + intercept_idx = MESSAGES_JS.find("Slash command intercept") + normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) + intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + + assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept + assert "if(_parsedCmd&&!_cmd)" in intercept + assert "if(!_agentCmd" not in intercept + assert "else" not in intercept[intercept.find("if(_agentCmd&&_agentCmd.cli_only)") :] + + +def test_builtin_command_opt_outs_do_not_hit_agent_metadata_lookup(): + """Built-in fall-through commands like /reasoning high keep their old path.""" + intercept_idx = MESSAGES_JS.find("Slash command intercept") + normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) + intercept = MESSAGES_JS[intercept_idx:normal_send_idx] + optout_idx = intercept.find("if(_cmd.fn(_parsedCmd.args)===false)") + metadata_idx = intercept.find("await getAgentCommandMetadata(_parsedCmd.name)") + + assert optout_idx != -1 + assert metadata_idx != -1 + assert "if(_parsedCmd&&!_cmd)" in intercept[optout_idx:metadata_idx + 120] diff --git a/tests/test_issue1360_streaming_scroll_hardening.py b/tests/test_issue1360_streaming_scroll_hardening.py new file mode 100644 index 00000000..39f57c1f --- /dev/null +++ b/tests/test_issue1360_streaming_scroll_hardening.py @@ -0,0 +1,62 @@ +"""Regression tests for #1360: streaming must not re-pin user scroll.""" + +from pathlib import Path + +REPO = Path(__file__).parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") + + +def _extract_function(src: str, name: str) -> str: + marker = f"function {name}(" + idx = src.find(marker) + assert idx != -1, f"{name} not found" + depth = 0 + for i, ch in enumerate(src[idx:], idx): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[idx:i + 1] + raise AssertionError(f"Could not extract {name}") + + +def test_messages_scroller_disables_browser_scroll_anchoring(): + assert "overflow-anchor:none" in STYLE_CSS, ( + "#messages must disable browser scroll anchoring so tool/card inserts " + "cannot yank the transcript while the user reads earlier content." + ) + + +def test_scroll_repin_dead_zone_is_wider_for_mac_app_windows(): + assert "clientHeight<250" in UI_JS, ( + "The near-bottom re-pin threshold should be at least 250px so small " + "macOS app windows and trackpad momentum do not re-pin too eagerly." + ) + + +def test_queue_card_measurement_does_not_force_repin_during_streaming(): + fn = _extract_function(UI_JS, "_renderQueueChips") + measurement_idx = fn.find("setTimeout(()=>") + assert measurement_idx != -1, "queue card measurement timeout not found" + measurement_block = fn[measurement_idx:measurement_idx + 500] + + assert "S.activeStreamId" in measurement_block + assert "scrollIfPinned()" in measurement_block + assert "!S.activeStreamId" in measurement_block + assert "scrollToBottom()" in measurement_block + assert measurement_block.find("scrollIfPinned()") < measurement_block.find("scrollToBottom()") + + +def test_queue_pill_click_does_not_force_repin_during_streaming(): + fn = _extract_function(UI_JS, "_updateQueuePill") + click_idx = fn.find("pill.onclick=()=>") + assert click_idx != -1, "queue pill click handler not found" + click_block = fn[click_idx:click_idx + 700] + + assert "S.activeStreamId" in click_block + assert "scrollIfPinned()" in click_block + assert "!S.activeStreamId" in click_block + assert "scrollToBottom()" in click_block + assert click_block.find("scrollIfPinned()") < click_block.find("scrollToBottom()") diff --git a/tests/test_live_models_ttl_cache.py b/tests/test_live_models_ttl_cache.py new file mode 100644 index 00000000..aa1c9fc9 --- /dev/null +++ b/tests/test_live_models_ttl_cache.py @@ -0,0 +1,114 @@ +"""Regression tests for /api/models/live backend TTL caching.""" + +import sys +import types +from urllib.parse import urlparse + + +def _install_provider_model_ids(monkeypatch, fn): + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.__path__ = [] + models = types.ModuleType("hermes_cli.models") + models.provider_model_ids = fn + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.models", models) + + +def _patch_live_models_basics(monkeypatch, routes, profile="default"): + import api.config as config + import api.profiles as profiles + + routes._clear_live_models_cache() + monkeypatch.setattr(routes, "j", lambda _handler, payload, status=200, extra_headers=None: payload) + monkeypatch.setattr(config, "get_config", lambda: {"model": {"provider": "openai"}}) + monkeypatch.setattr(config, "_resolve_provider_alias", lambda provider: provider) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: profile) + + +def test_live_models_cache_hits_within_ttl(monkeypatch): + import api.routes as routes + + calls = [] + + def provider_model_ids(provider): + calls.append(provider) + return ["openai/gpt-test"] + + _install_provider_model_ids(monkeypatch, provider_model_ids) + _patch_live_models_basics(monkeypatch, routes) + + parsed = urlparse("/api/models/live?provider=openai") + first = routes._handle_live_models(object(), parsed) + second = routes._handle_live_models(object(), parsed) + + assert calls == ["openai"] + assert first == second + assert first["models"] == [{"id": "openai/gpt-test", "label": "GPT Test"}] + + +def test_live_models_cache_expires(monkeypatch): + import api.routes as routes + + now = [1000.0] + calls = [] + + def provider_model_ids(provider): + calls.append(provider) + return [f"{provider}/model-{len(calls)}"] + + _install_provider_model_ids(monkeypatch, provider_model_ids) + _patch_live_models_basics(monkeypatch, routes) + monkeypatch.setattr(routes.time, "monotonic", lambda: now[0]) + + parsed = urlparse("/api/models/live?provider=openai") + first = routes._handle_live_models(object(), parsed) + now[0] += routes._LIVE_MODELS_CACHE_TTL + 1 + second = routes._handle_live_models(object(), parsed) + + assert calls == ["openai", "openai"] + assert first["models"][0]["id"] == "openai/model-1" + assert second["models"][0]["id"] == "openai/model-2" + + +def test_live_models_cache_is_profile_scoped(monkeypatch): + import api.routes as routes + import api.profiles as profiles + + active_profile = ["default"] + calls = [] + + def provider_model_ids(provider): + calls.append((active_profile[0], provider)) + return [f"{provider}/{active_profile[0]}-model"] + + _install_provider_model_ids(monkeypatch, provider_model_ids) + _patch_live_models_basics(monkeypatch, routes) + monkeypatch.setattr(profiles, "get_active_profile_name", lambda: active_profile[0]) + + parsed = urlparse("/api/models/live?provider=openai") + default_payload = routes._handle_live_models(object(), parsed) + active_profile[0] = "research" + research_payload = routes._handle_live_models(object(), parsed) + again_payload = routes._handle_live_models(object(), parsed) + + assert calls == [("default", "openai"), ("research", "openai")] + assert default_payload["models"][0]["id"] == "openai/default-model" + assert research_payload["models"][0]["id"] == "openai/research-model" + assert again_payload == research_payload + + +def test_live_models_cache_returns_deep_copies(monkeypatch): + import api.routes as routes + + _install_provider_model_ids(monkeypatch, lambda provider: ["openai/gpt-test"]) + _patch_live_models_basics(monkeypatch, routes) + + parsed = urlparse("/api/models/live?provider=openai") + first = routes._handle_live_models(object(), parsed) + first["models"].clear() + first["provider"] = "mutated" + + second = routes._handle_live_models(object(), parsed) + + assert second["provider"] == "openai" + assert second["models"] == [{"id": "openai/gpt-test", "label": "GPT Test"}] diff --git a/tests/test_session_import_cli_fallback_model.py b/tests/test_session_import_cli_fallback_model.py new file mode 100644 index 00000000..03105d49 --- /dev/null +++ b/tests/test_session_import_cli_fallback_model.py @@ -0,0 +1,69 @@ +"""Regression test for #1386: CLI session import must not crash when the +session is missing from `get_cli_sessions()` metadata at the time of import. + +Before the fix, `_handle_session_import_cli` only assigned `model` inside +the `for cs in get_cli_sessions(): if cs["session_id"] == sid` loop. If +the session existed in the messages store but had no metadata row (or had +been pruned after `get_cli_session_messages()` was called), `model` was +unbound and `import_cli_session(sid, title, msgs, model, ...)` raised +`UnboundLocalError`. + +The fix initializes `model = "unknown"` before the loop so the import +proceeds with a sensible default rather than crashing. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8") + + +def _extract_handler(name: str) -> str: + """Return the source of the handler function `name` from api/routes.py.""" + marker = f"def {name}(" + idx = ROUTES_PY.find(marker) + assert idx != -1, f"{name} not found in api/routes.py" + # Walk forward until a top-level `def ` (col 0) appears. + next_def = ROUTES_PY.find("\ndef ", idx + len(marker)) + return ROUTES_PY[idx : next_def if next_def != -1 else len(ROUTES_PY)] + + +def test_import_cli_initializes_model_before_metadata_loop(): + """The fallback `model = 'unknown'` must be set BEFORE the + `for cs in get_cli_sessions()` loop so that a metadata-less session + cannot leave `model` unbound.""" + handler = _extract_handler("_handle_session_import_cli") + init_idx = handler.find('model = "unknown"') + if init_idx == -1: + # Allow single quotes too. + init_idx = handler.find("model = 'unknown'") + assert init_idx != -1, ( + "Expected `model = \"unknown\"` initialization in " + "_handle_session_import_cli before the metadata loop. Without it, " + "import crashes when the session has messages but no metadata row." + ) + loop_idx = handler.find("for cs in get_cli_sessions()") + assert loop_idx != -1, "Expected `for cs in get_cli_sessions()` loop" + assert init_idx < loop_idx, ( + "`model` must be initialized BEFORE the `for cs in get_cli_sessions()` " + "loop, otherwise a session without a metadata row leaves `model` " + "unbound and `import_cli_session(..., model, ...)` raises " + "UnboundLocalError." + ) + + +def test_import_cli_passes_model_to_import_helper(): + """Sanity: the handler still passes the resolved model down to + `import_cli_session` — the regression test would not catch a refactor + that drops the argument entirely.""" + handler = _extract_handler("_handle_session_import_cli") + assert "import_cli_session(" in handler + # The model variable should appear as a positional or keyword arg in + # the import_cli_session call. + call_idx = handler.find("import_cli_session(") + call_block = handler[call_idx : call_idx + 400] + assert "model" in call_block, ( + "import_cli_session() call should still receive the `model` argument." + ) diff --git a/tests/test_session_ops.py b/tests/test_session_ops.py index 0a480c9f..e57d8140 100644 --- a/tests/test_session_ops.py +++ b/tests/test_session_ops.py @@ -11,7 +11,7 @@ import urllib.error import pytest -from tests.conftest import TEST_BASE, _post, make_session_tracked +from tests.conftest import TEST_BASE, TEST_STATE_DIR, _post, make_session_tracked def _get(path): @@ -218,6 +218,8 @@ def test_status_returns_summary(cleanup_test_sessions): assert r['title'] == 'test' assert r['message_count'] == 3 assert 'model' in r + assert r['profile'] == 'default' + assert r['hermes_home'] == str(TEST_STATE_DIR) assert 'workspace' in r assert 'created_at' in r assert 'updated_at' in r @@ -233,6 +235,17 @@ def test_status_returns_summary(cleanup_test_sessions): assert r['total_tokens'] == 0 +def test_status_returns_profile_specific_hermes_home(cleanup_test_sessions): + data = _post(TEST_BASE, '/api/session/new', {'profile': 'research'}) + sid = data['session']['session_id'] + cleanup_test_sessions.append(sid) + + r = _get(f'/api/session/status?session_id={sid}') + + assert r['profile'] == 'research' + assert r['hermes_home'] == str(TEST_STATE_DIR / 'profiles' / 'research') + + def test_status_unknown_returns_404(): try: _get('/api/session/status?session_id=nonexistent_zzz')