diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ddd1845..431011f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,40 @@ # Hermes Web UI -- Changelog +## [v0.51.4] — 2026-05-05 — 10-PR full-sweep batch + +### Added + +- **PR #1685** by @Michaelyklam — Surface Codex spark models in `/api/models` (closes #1680). New `_read_visible_codex_cache_model_ids()` reads visible non-hidden slugs from `CODEX_HOME/models_cache.json`. The OpenAI Codex group now layers three sources: `hermes_cli.models.provider_model_ids("openai-codex")` first, visible cache slugs second, static `_PROVIDER_MODELS` fallback last. Users see newly available Codex models (including `gpt-5.3-codex-spark`) without waiting for WebUI catalog updates. +- **PR #1644** by @bergeouss — Inline provider chip + group model count in composer model picker (closes #1425). Same-name models across providers are now visually distinguishable: per-row provider chip on every model option, count `(N)` next to group headings when more than one model matches, subtle border-top divider between provider groups. 13 LOC total — pattern-extension within existing dropdown. +- **PR #1684** by @Michaelyklam — Clarify update network failures (closes #1321). Frontend detects raw fetch failures (`Failed to fetch`, `NetworkError`, `Load failed`) on `POST /api/updates/apply` and replaces the cryptic browser text with recovery-oriented guidance ("the WebUI may have restarted or the connection was interrupted; wait, reload, and check the server if needed"). Added an in-flight guard so repeated Update Now clicks don't send duplicate apply requests during restart-race windows. + +### Fixed + +- **PR #1689** by @Michaelyklam — Normalize named profile base homes (refs #749). Prevents the doubled `/base/profiles/foo/profiles/foo` path that occurred when both `HERMES_BASE_HOME=/base/profiles/foo` and the browser cookie `hermes_profile=foo` were set. New `_unwrap_profile_home_to_base()` helper normalizes either env-var path through the same base-home resolver, then routes active-profile and explicit per-request lookups through one shared profile-home resolver. Doesn't touch the broader profile UX umbrella. +- **PR #1693** by @ai-ag2026 — Avoid adaptive title refresh session lock deadlock. `_run_background_title_refresh()` previously updated a session title while holding the global session `LOCK`, then called `Session.save()` — which itself updates the session index via `_write_session_index()` requiring the same non-reentrant `LOCK` (self-deadlock). Now the in-memory title mutation stays under `LOCK`, but `Session.save()` runs with the global lock released and only the per-session agent lock held. Plus Latin-Unicode-aware fallback title tokenization so `führe` no longer becomes `f` + `hre`. +- **PR #1701** by @Michaelyklam — Normalize update banner repository URLs (closes #1691). The "What's new?" link previously pointed at `https://github.com/nesquena/hermes-webu/` instead of `hermes-webui`. Root cause: `.git` was treated as a character set (`[.git]`) instead of a literal suffix, and trailing slashes prevented suffix removal. New `_normalize_remote_url()` in `api/updates.py` centralizes the normalization with regression coverage on the edge case. +- **PR #1703** by @Michaelyklam — Invalidate models cache on auth-store drift (closes #1699). When a user runs `hermes setup` in a terminal and the auth store switches the active provider outside WebUI, the in-memory + disk model caches could keep showing the previous provider's PRIMARY badge for up to the 24h TTL. New non-secret source fingerprint covers `config.yaml` and `auth.json` path/mtime/size; cache rebuilds when either changes outside WebUI. Disk cache schema bumped to reject older cache files cleanly. +- **PR #1702** by @Michaelyklam — Fix workspace tree double-click rename (closes #1698). The right workspace panel advertised double-click rename on file names, but file-name single-click bubbled to the row's preview handler before the dblclick rename path could take over. Added a `nameEl.onclick` propagation guard before the existing `nameEl.ondblclick` handler in `static/ui.js` while leaving row/icon/whitespace clicks available for preview. Right-click context-menu rename remains as before. +- **PR #1704** by @Michaelyklam — Honor markdown fence lengths (closes #1696). The `renderMd()` regex hard-coded triple-backtick closers, so 4/5-backtick markdown examples closed at inner triple fences. Updated fenced-code matching to capture `{3,}` backtick opener runs and require the same character + at least as many backticks on close (per CommonMark §4.5). Same fence-length rule applied to user-message fenced rendering and to the blockquote pre-pass fence-state walker. Empty-fence handling unchanged. +- **PR #1706** by @Michaelyklam — Paste multiple images at once attaches all of them (closes #1697). `static/boot.js` paste handler called `Date.now()` inside a synchronous `.map()` callback over `imageItems`. All N synthesized `File` objects ended up with identical filenames (same millisecond), and `addFiles()` deduped by name and silently dropped images 2..N. Fix captures `pasteTs = Date.now()` once outside the map and adds deterministic `-1`, `-2`, … suffixes only when the paste contains multiple images. Single-image paste filename shape unchanged for compatibility. Functional Node-driven test extracts and executes the real paste handler. + +### Tests + +4477 → **4503 passing** (+26 regression tests across the 10 PRs). 0 regressions. Full suite ~135s. + +### Pre-release verification + +- Stage-301 build: 10 PRs merged with zero conflicts (each rebased clean against current master). +- All JS files syntax-clean (`node -c static/boot.js && node -c static/ui.js`). +- All Python files syntax-clean (py_compile on every changed file). +- Live browser walkthrough on port 8789: model picker chip + group count rendering, all `/api/wiki/status`, `/api/logs`, `/api/provider/quota`, `/api/health/agent` endpoints respond 200, sidebar scroll fix preserved, `boot.js` PR #1706 fix verified live (pasteTs captured outside map, index parameter present, Date.now() removed from inside .map()). +- Opus advisor pass on 9-PR variant (with #1705 in slot 10): SHIP, 7/7 verification questions resolved cleanly. Late swap to #1706 keeps identical fix shape (same `pasteTs` outside map + index suffix); Opus's verification answers carry over because the production diff is unchanged. + +### Notes on the 1705 → 1706 swap + +@Michaelyklam filed PR #1706 with a functional Node-driven regression test (extracts the real paste handler and asserts two pasted image items become two pending attachments) replacing my own #1705 which used static-source-string assertions. Same code fix, better test approach. Closed #1705 and absorbed #1706 into stage-301. + + ## [v0.51.3] — 2026-05-04 — 3-PR follow-up batch (#1671, #1673, #1676) + test-fragility fix ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 77d3aefe..43bf7ac0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ > Web companion to the Hermes Agent CLI. Same workflows, browser-native. > -> Last updated: v0.51.3 (May 04, 2026) — 4477 tests collected — 3-PR follow-up batch (#1671, #1673, #1676) +> Last updated: v0.51.4 (May 5, 2026) — 4503 tests collected — 3-PR follow-up batch (#1671, #1673, #1676) > Test source: `pytest tests/ --collect-only -q` > Per-version detail: see [CHANGELOG.md](./CHANGELOG.md) diff --git a/TESTING.md b/TESTING.md index f029ea25..f01fed9f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -1835,8 +1835,8 @@ Bridged CLI sessions: --- -*Last updated: v0.51.3, May 04, 2026 — 3-PR follow-up batch (#1671, #1673, #1676)* -*Total automated tests collected: 4477* +*Last updated: v0.51.4, May 5, 2026* +*Total automated tests collected: 4503* *Regression gate: tests/test_regressions.py* *Run: pytest tests/ -v --timeout=60* *Source: /* diff --git a/api/config.py b/api/config.py index 2ebabc5f..972ee61f 100644 --- a/api/config.py +++ b/api/config.py @@ -1585,6 +1585,7 @@ def set_hermes_default_model(model_id: str) -> dict: # ── TTL cache for get_available_models() ───────────────────────────────────── _available_models_cache: dict | None = None _available_models_cache_ts: float = 0.0 +_available_models_cache_source_fingerprint: dict | None = None _AVAILABLE_MODELS_CACHE_TTL: float = 86400.0 # 24 hours _available_models_cache_lock = threading.RLock() # must be RLock: cold path refactoring moved slow work inside this lock, requiring re-entry _cache_build_cv = threading.Condition(_available_models_cache_lock) # shares underlying RLock so notify_all() is safe inside with _available_models_cache_lock @@ -1641,12 +1642,48 @@ def _current_webui_version() -> str | None: # guarantees that even if a future release accidentally reuses the same # WebUI version string (or a debug build doesn't have a version), a structural # change still invalidates the cache. -_MODELS_CACHE_SCHEMA_VERSION = 2 +_MODELS_CACHE_SCHEMA_VERSION = 3 _models_cache_path = STATE_DIR / "models_cache.json" +def _get_auth_store_path() -> Path: + """Return the auth.json path for the active Hermes profile.""" + try: + from api.profiles import get_active_hermes_home as _gah + + return _gah() / "auth.json" + except ImportError: + return HOME / ".hermes" / "auth.json" + + +def _models_cache_file_fingerprint(path: Path) -> dict: + """Return non-secret identity metadata for a cache dependency file. + + The /api/models response depends on config.yaml (model/provider defaults) + and auth.json (active_provider + credential_pool). The cache only needs + cheap invalidation signals here, not file contents; never include secrets. + """ + fingerprint = {"path": str(Path(path).expanduser())} + try: + st = Path(path).stat() + except OSError: + fingerprint["missing"] = True + return fingerprint + fingerprint["mtime_ns"] = st.st_mtime_ns + fingerprint["size"] = st.st_size + return fingerprint + + +def _models_cache_source_fingerprint() -> dict: + """Return the current config/auth-store fingerprint for /api/models cache.""" + return { + "config_yaml": _models_cache_file_fingerprint(_get_config_path()), + "auth_json": _models_cache_file_fingerprint(_get_auth_store_path()), + } + + def _delete_models_cache_on_disk() -> None: try: os.unlink(str(_models_cache_path)) @@ -1717,6 +1754,15 @@ def _is_loadable_disk_cache(cache: object) -> bool: cached_version, runtime_version, ) return False + cached_sources = cache.get("_source_fingerprint") + runtime_sources = _models_cache_source_fingerprint() + if cached_sources != runtime_sources: + logger.debug( + "models cache rejected: source_fingerprint=%r vs runtime=%r", + cached_sources, + runtime_sources, + ) + return False return True @@ -1772,6 +1818,7 @@ def _save_models_cache_to_disk(cache: dict) -> None: return payload = { "_schema_version": _MODELS_CACHE_SCHEMA_VERSION, + "_source_fingerprint": _models_cache_source_fingerprint(), "active_provider": cache["active_provider"], "default_model": cache["default_model"], "configured_model_badges": cache["configured_model_badges"], @@ -1790,15 +1837,27 @@ def _save_models_cache_to_disk(cache: dict) -> None: def _get_fresh_memory_models_cache(now: float) -> dict | None: """Return a valid fresh in-memory /api/models cache, or clear stale shapes.""" - global _available_models_cache, _available_models_cache_ts + global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint if _available_models_cache is None: return None if (now - _available_models_cache_ts) >= _AVAILABLE_MODELS_CACHE_TTL: return None + current_sources = _models_cache_source_fingerprint() + if _available_models_cache_source_fingerprint != current_sources: + logger.debug( + "models memory cache rejected: source_fingerprint=%r vs runtime=%r", + _available_models_cache_source_fingerprint, + current_sources, + ) + _available_models_cache = None + _available_models_cache_ts = 0.0 + _available_models_cache_source_fingerprint = None + return None if _is_valid_models_cache(_available_models_cache): return copy.deepcopy(_available_models_cache) _available_models_cache = None _available_models_cache_ts = 0.0 + _available_models_cache_source_fingerprint = None return None @@ -1816,10 +1875,11 @@ def invalidate_models_cache(): result from the disk cache because the disk hit is checked before the memory cache rebuild runs. """ - global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _cache_build_cv + global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _cache_build_cv with _available_models_cache_lock: _available_models_cache = None _available_models_cache_ts = 0.0 + _available_models_cache_source_fingerprint = None _cache_build_in_progress = False _cache_build_cv.notify_all() # Clear the credential pool cache too. The cache key is provider_id @@ -1856,10 +1916,11 @@ def invalidate_provider_models_cache(provider_id: str): Args: provider_id: canonical provider id (e.g. 'openai', 'anthropic', 'custom:my-key') """ - global _available_models_cache, _available_models_cache_ts, _CREDENTIAL_POOL_CACHE + global _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _CREDENTIAL_POOL_CACHE with _available_models_cache_lock: _available_models_cache = None _available_models_cache_ts = 0.0 + _available_models_cache_source_fingerprint = None _provider_models_invalidated_ts[provider_id] = time.time() # Also evict the credential pool so the next cold path re-loads it. # Must evict both the original key and its canonical form (load_pool @@ -1902,6 +1963,47 @@ def _get_label_for_model(model_id: str, existing_groups: list) -> str: ) +def _read_visible_codex_cache_model_ids() -> list[str]: + """Return visible model slugs from Codex's local models_cache.json. + + The agent's provider_model_ids('openai-codex') intentionally filters IDs + with ``supported_in_api: false``. Codex CLI still lists some of those models + in its picker (notably ``gpt-5.3-codex-spark`` from #1680), so the WebUI + merges this visible local catalog to stay in sync with Codex itself. + """ + codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser() + cache_path = codex_home / "models_cache.json" + try: + payload = json.loads(cache_path.read_text(encoding="utf-8")) + except Exception: + return [] + + entries = payload.get("models") if isinstance(payload, dict) else None + if not isinstance(entries, list): + return [] + + sortable: list[tuple[int, str]] = [] + for item in entries: + if not isinstance(item, dict): + continue + slug = item.get("slug") + if not isinstance(slug, str) or not slug.strip(): + continue + visibility = item.get("visibility", "") + if isinstance(visibility, str) and visibility.strip().lower() in ("hide", "hidden"): + continue + priority = item.get("priority") + rank = int(priority) if isinstance(priority, (int, float)) else 10_000 + sortable.append((rank, slug.strip())) + + sortable.sort(key=lambda item: (item[0], item[1])) + ordered: list[str] = [] + for _, slug in sortable: + if slug not in ordered: + ordered.append(slug) + return ordered + + def get_available_models() -> dict: """ Return available models grouped by provider. @@ -1918,7 +2020,7 @@ def get_available_models() -> dict: 'groups': [{'provider': str, 'models': [{'id': str, 'label': str}]}] } """ - global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _cache_build_cv + global _cache_build_in_progress, _available_models_cache, _available_models_cache_ts, _available_models_cache_source_fingerprint, _cache_build_cv # Config mtime check — must come before any config reads. # (Test #585 verifies _current_mtime appears before active_provider = None) try: @@ -2053,12 +2155,7 @@ def get_available_models() -> dict: # 2. Read auth store (active_provider fallback + credential_pool inspection) auth_store = {} - try: - from api.profiles import get_active_hermes_home as _gah - - auth_store_path = _gah() / "auth.json" - except ImportError: - auth_store_path = HOME / ".hermes" / "auth.json" + auth_store_path = _get_auth_store_path() if auth_store_path.exists(): try: import json as _j @@ -2671,6 +2768,43 @@ def get_available_models() -> dict: except Exception: logger.warning("Failed to load Ollama Cloud models from hermes_cli") + if raw_models: + models = _apply_provider_prefix(raw_models, pid, active_provider) + groups.append( + { + "provider": provider_name, + "provider_id": pid, + "models": models, + } + ) + elif pid == "openai-codex": + # Codex account catalogs drift faster than WebUI releases + # (for example gpt-5.3-codex-spark in #1680). Ask the + # agent's Codex resolver first so /api/models inherits the + # live Codex API / local ~/.codex cache / static fallback + # chain instead of freezing the picker to WebUI's curated + # _PROVIDER_MODELS snapshot. + raw_models = [] + codex_ids = [] + try: + from hermes_cli.models import provider_model_ids as _provider_model_ids + + codex_ids = [mid for mid in (_provider_model_ids("openai-codex") or []) if mid] + except Exception: + logger.warning("Failed to load OpenAI Codex models from hermes_cli") + + for mid in _read_visible_codex_cache_model_ids(): + if mid not in codex_ids: + codex_ids.append(mid) + + raw_models = [ + {"id": mid, "label": _get_label_for_model(mid, [])} + for mid in codex_ids + ] + + if not raw_models: + raw_models = copy.deepcopy(_PROVIDER_MODELS.get("openai-codex", [])) + if raw_models: models = _apply_provider_prefix(raw_models, pid, active_provider) groups.append( @@ -2939,6 +3073,7 @@ def get_available_models() -> dict: reload_config() _available_models_cache = None _available_models_cache_ts = 0.0 + _available_models_cache_source_fingerprint = None disk_groups = None # Serve from memory cache if fresh @@ -2951,6 +3086,7 @@ def get_available_models() -> dict: if disk_groups is not None: _available_models_cache = disk_groups _available_models_cache_ts = now + _available_models_cache_source_fingerprint = _models_cache_source_fingerprint() _save_models_cache_to_disk(disk_groups) return copy.deepcopy(disk_groups) @@ -2968,6 +3104,7 @@ def get_available_models() -> dict: with _cache_build_cv: _available_models_cache = result _available_models_cache_ts = time.monotonic() + _available_models_cache_source_fingerprint = _models_cache_source_fingerprint() _cache_build_in_progress = False _cache_build_cv.notify_all() _save_models_cache_to_disk(result) diff --git a/api/profiles.py b/api/profiles.py index b56868a1..52c552b2 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -37,6 +37,13 @@ _loaded_profile_env_keys: set[str] = set() # process-global _active_profile. _tls = threading.local() +def _unwrap_profile_home_to_base(home: Path) -> Path: + """Return the base Hermes home when *home* is already a named profile dir.""" + if home.parent.name == 'profiles': + return home.parent.parent + return home + + def _resolve_base_hermes_home() -> Path: """Return the BASE ~/.hermes directory — the root that contains profiles/. @@ -56,20 +63,22 @@ def _resolve_base_hermes_home() -> Path: reading it here would make _DEFAULT_HERMES_HOME point to that subdir, causing switch_profile('webui') to look for /home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist. + + HERMES_BASE_HOME normally points at the base home already, but isolated + single-profile WebUI deployments can provide /base/profiles/ there as + well. Normalize both env vars through the same helper so active-profile + and per-request resolution share one base-root contract (#749). """ # Explicit override for tests or unusual setups base_override = os.getenv('HERMES_BASE_HOME', '').strip() if base_override: - return Path(base_override).expanduser() + return _unwrap_profile_home_to_base(Path(base_override).expanduser()) hermes_home = os.getenv('HERMES_HOME', '').strip() if hermes_home: p = Path(hermes_home).expanduser() # If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base - if p.parent.name == 'profiles': - return p.parent.parent - # Otherwise trust it (e.g. test isolation sets HERMES_HOME to TEST_STATE_DIR) - return p + return _unwrap_profile_home_to_base(p) return Path.home() / '.hermes' @@ -193,19 +202,29 @@ def clear_request_profile() -> None: _tls.profile = None +def _resolve_profile_home_for_name(name: str) -> Path: + """Resolve a logical profile name to its Hermes home path. + + Root/default aliases resolve to _DEFAULT_HERMES_HOME. Valid named profiles + resolve to _DEFAULT_HERMES_HOME/profiles/ even when the directory has + not been created yet; the agent layer may create it on first use. Invalid + names fall back to the base home so traversal-shaped cookie values cannot + influence filesystem paths. + """ + if not name or _is_root_profile(name): + return _DEFAULT_HERMES_HOME + if not _PROFILE_ID_RE.fullmatch(name): + return _DEFAULT_HERMES_HOME + return _resolve_named_profile_home(name) + + def get_active_hermes_home() -> Path: """Return the HERMES_HOME path for the currently active profile. Uses get_active_profile_name() so per-request TLS context (issue #798) is respected, not just the process-level global. """ - name = get_active_profile_name() - if _is_root_profile(name): - return _DEFAULT_HERMES_HOME - profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name - if profile_dir.is_dir(): - return profile_dir - return _DEFAULT_HERMES_HOME + return _resolve_profile_home_for_name(get_active_profile_name()) @@ -393,12 +412,7 @@ def get_hermes_home_for_profile(name: str) -> Path: empty, 'default', or does not match the profile-name format (rejects path traversal such as '../../etc'). """ - if not name or _is_root_profile(name): - return _DEFAULT_HERMES_HOME - if not _PROFILE_ID_RE.fullmatch(name): - return _DEFAULT_HERMES_HOME - profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name - return profile_dir + return _resolve_profile_home_for_name(name) _TERMINAL_ENV_MAPPINGS = { diff --git a/api/streaming.py b/api/streaming.py index 679f03af..6accd184 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -938,7 +938,12 @@ def _fallback_title_from_exchange(user_text: str, assistant_text: str) -> Option 'need', 'needs', 'want', 'wants', 'user', 'assistant', 'could', 'would', 'should', 'about', 'there', 'here', 'test', 'testing', 'title', 'summary', } - tokens = re.findall(r'[A-Za-z0-9][A-Za-z0-9_./+-]*', head) + # Unicode-aware Latin tokenization: keep the old "no leading underscore" + # and non-Latin placeholder behavior while allowing letters such as ä/ö/ü/ß. + # The previous ASCII-only pattern turned "führe" into "f" + "hre"; the short + # "f" was filtered and the broken "hre" became part of the title. + latin_word = r'A-Za-z0-9À-ÖØ-öø-ÿ' + tokens = re.findall(rf'[{latin_word}][{latin_word}_./+-]*', head) if not tokens: return 'Conversation topic' @@ -1092,8 +1097,12 @@ def _run_background_title_refresh(session_id: str, user_text: str, assistant_tex return s.title = next_title s.llm_title_generated = True - s.save(touch_updated_at=False) effective_title = s.title + # Session.save() calls _write_session_index(), which acquires LOCK. + # Keep the per-session agent lock for mutation serialization, but + # release the global session LOCK before persisting to avoid a + # self-deadlock in the background title-refresh thread. + s.save(touch_updated_at=False) _put_title_status(put_event, session_id, 'refreshed', llm_status, effective_title, raw_preview) put_event('title', {'session_id': session_id, 'title': effective_title}) logger.info("Adaptive title refresh: session=%s new_title=%r", session_id, effective_title) diff --git a/api/updates.py b/api/updates.py index 9e1d9f4e..e3e025c2 100644 --- a/api/updates.py +++ b/api/updates.py @@ -150,6 +150,25 @@ WEBUI_VERSION: str = _detect_webui_version() AGENT_VERSION: str = _detect_agent_version() +def _normalize_remote_url(remote_url): + """Return the browser-facing repository URL for update compare links. + + Git remotes may be HTTPS or SSH and may include a literal ``.git`` suffix. + Strip only that literal suffix — never use ``str.rstrip('.git')`` because it + treats the argument as a character set and can truncate ``hermes-webui`` to + ``hermes-webu``. + """ + if not remote_url: + return remote_url + remote_url = remote_url.strip() + if remote_url.startswith('git@'): + remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1) + remote_url = remote_url.rstrip('/') + if remote_url.endswith('.git'): + remote_url = remote_url[:-4] + return remote_url.rstrip('/') + + def _split_remote_ref(ref): """Split 'origin/branch-name' into ('origin', 'branch-name'). @@ -234,11 +253,7 @@ def _check_repo(path, name): # Get repo URL for "What's new?" link remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) - # Convert SSH URLs (git@github.com:org/repo.git) to HTTPS - if remote_url and remote_url.startswith('git@'): - remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1) - if remote_url and remote_url.endswith('.git'): - remote_url = remote_url[:-4] + remote_url = _normalize_remote_url(remote_url) return { 'name': name, diff --git a/docs/pr-media/1321/update-network-error.png b/docs/pr-media/1321/update-network-error.png new file mode 100644 index 00000000..7c438a14 Binary files /dev/null and b/docs/pr-media/1321/update-network-error.png differ diff --git a/docs/pr-media/1698/workspace-double-click-rename.png b/docs/pr-media/1698/workspace-double-click-rename.png new file mode 100644 index 00000000..fb1dd9e9 Binary files /dev/null and b/docs/pr-media/1698/workspace-double-click-rename.png differ diff --git a/docs/pr-media/1699/model-cache-auth-store-refresh.png b/docs/pr-media/1699/model-cache-auth-store-refresh.png new file mode 100644 index 00000000..beb552f6 Binary files /dev/null and b/docs/pr-media/1699/model-cache-auth-store-refresh.png differ diff --git a/static/boot.js b/static/boot.js index d4e8bdd0..62f3c8ab 100644 --- a/static/boot.js +++ b/static/boot.js @@ -975,10 +975,12 @@ $('msg').addEventListener('paste',e=>{ const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/')); if(!imageItems.length||hasText)return; e.preventDefault(); - const files=imageItems.map(i=>{ + const pasteTs=Date.now(); + const files=imageItems.map((i,idx)=>{ const blob=i.getAsFile(); const ext=i.type.split('/')[1]||'png'; - return new File([blob],`screenshot-${Date.now()}.${ext}`,{type:i.type}); + const suffix=imageItems.length>1?`-${idx+1}`:''; + return new File([blob],`screenshot-${pasteTs}${suffix}.${ext}`,{type:i.type}); }); addFiles(files); setStatus(t('image_pasted')+files.map(f=>f.name).join(', ')); diff --git a/static/style.css b/static/style.css index c08141d1..6a9c70d0 100644 --- a/static/style.css +++ b/static/style.css @@ -1420,7 +1420,7 @@ .model-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:280px;max-width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;} .model-dropdown.open{display:block;} .model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);} -.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;} +.model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;} .model-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:3px;align-items:flex-start;} .model-opt:hover{background:rgba(255,255,255,.07);} .model-opt.active{background:var(--accent-bg);} @@ -1430,6 +1430,7 @@ .model-opt-badge--primary{background:rgba(50,184,198,.16);border-color:rgba(50,184,198,.32);color:#8fe7ef;} .model-opt-badge--fallback{background:rgba(255,184,77,.14);border-color:rgba(255,184,77,.28);color:#ffd18a;} .model-opt-id{display:block;font-size:10px;color:var(--muted);line-height:1.3;opacity:.72;word-break:break-word;} +.model-opt-provider{display:inline-flex;align-items:center;padding:1px 6px;border-radius:4px;font-size:9px;font-weight:600;letter-spacing:.03em;color:var(--muted);background:rgba(255,255,255,.05);border:1px solid var(--border2);margin-left:auto;white-space:nowrap;flex-shrink:0;} .model-custom-sep{padding-top:4px;border-top:1px solid var(--border);margin-top:4px;} .model-custom-row{display:flex;align-items:center;gap:6px;padding:6px 10px 8px;} .model-custom-input{flex:1;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;font-family:inherit;min-width:0;} diff --git a/static/ui.js b/static/ui.js index 4b93eb29..d3329899 100644 --- a/static/ui.js +++ b/static/ui.js @@ -50,6 +50,15 @@ function _setCompressionSessionLock(sid){ window._compressionLockSid=sid||null; } const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +function _matchBacktickFenceLine(line){ + const m=String(line||'').match(/^[ ]{0,3}(`{3,})([^`]*)$/); + if(!m) return null; + return {fence:m[1],len:m[1].length,info:(m[2]||'').trim()}; +} +function _isBacktickFenceClose(line,minLen){ + const m=String(line||'').match(/^[ ]{0,3}(`{3,})[ \t]*$/); + return !!(m&&m[1].length>=minLen); +} /** * Render fenced code blocks inside user messages. * Extracts ```…``` fences, replaces them with placeholders, @@ -62,9 +71,12 @@ function _renderUserFencedBlocks(text){ const stash=[]; let s=String(text||''); // Extract fenced code blocks → stash, replace with null-token placeholder - // CommonMark line-anchored fence (fixes #1438): inner ``` inside content no longer truncates the block. - s=s.replace(/(^|\n)[ ]{0,3}```([a-zA-Z0-9_+-]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)/g,(_,lead,lang,code)=>{ - lang=(lang||'').trim().toLowerCase(); + // CommonMark §4.5 line-anchored fence: the closing run must use at least + // as many backticks as the opener, so inner triple-backtick fences remain content. + s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{ + const langInfo=(info||'').trim(); + const langMatch=langInfo.match(/^(\w[\w+-]*)$/); + let lang=langMatch?(langMatch[1]||'').trim().toLowerCase():''; code=code||''; // Remove one trailing newline if present (the fence consumes its own) if(code.endsWith('\n')) code=code.slice(0,-1); @@ -872,19 +884,28 @@ function renderModelDropdown(){ } // Add remaining models matching filter let _lastGroup=null; + // Count models per group for heading labels (#1425) + const _groupCounts={}; + for(const m of _modelData){ + if(configuredIds.has(m.value)) continue; + if(m.group) _groupCounts[m.group]=(_groupCounts[m.group]||0)+1; + } for(const m of _modelData){ if(configuredIds.has(m.value)||!matches(m)) continue; if(m.group&&m.group!==_lastGroup){ const heading=document.createElement('div'); heading.className='model-group'; - heading.textContent=m.group; + const count=_groupCounts[m.group]||0; + heading.textContent=count>1?`${m.group} (${count})`:m.group; dd.appendChild(heading); _lastGroup=m.group; } const row=document.createElement('div'); row.className='model-opt'+(m.value===sel.value?' active':''); const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; - row.innerHTML=`
${m.name}${badgeHtml}
${m.id}`; + // Inline provider chip on every row that has a group (#1425) + const providerChip=m.group?`${esc(m.group)}`:''; + row.innerHTML=`
${m.name}${badgeHtml}${providerChip}
${m.id}`; row.onclick=()=>selectModelFromDropdown(m.value); dd.appendChild(row); } @@ -1736,7 +1757,8 @@ function renderMd(raw){ s=(function _applyBlockquotes(input){ const lines=input.split('\n'); const out=[]; - let inFence=false; // inside a non-blockquote ```...``` fence + let inFence=false; // inside a non-blockquote backtick fence + let fenceLen=0; let bqStart=-1; const flush=(end)=>{ if(bqStart<0) return; @@ -1759,13 +1781,15 @@ function renderMd(raw){ const line=lines[i]; if(inFence){ out.push(line); - if(/^```/.test(line)) inFence=false; + if(_isBacktickFenceClose(line,fenceLen)){inFence=false;fenceLen=0;} continue; } - if(/^```/.test(line)){ + const fenceOpen=_matchBacktickFenceLine(line); + if(fenceOpen){ flush(i); out.push(line); inFence=true; + fenceLen=fenceOpen.len; continue; } if(/^>/.test(line)){ @@ -1809,14 +1833,16 @@ function renderMd(raw){ const _preBlock_stash=[]; const fence_stash=[]; // CommonMark §4.5: opening fence must start a line (with up to 3 spaces of indent) - // and closing fence must also start a line. Without line anchoring, a literal ``` inside - // a code block (e.g. a regex pattern with ``` in a lookbehind, a script that documents - // fences) terminates the outer block at the wrong place, leaking content into the - // markdown stream where bold/italic/inline-code passes corrupt it. Fixes #1438. - s=s.replace(/(^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)/g,(_,lead,raw)=>{ - const m=raw.match(/^(\w[\w+-]*)\n?([\s\S]*)$/); - const lang=m?(m[1]||'').trim().toLowerCase():''; - const code=m?m[2]:raw.replace(/^\n?/,''); + // and closing fence must start a line with the same backtick char and at least + // as many backticks as the opener. Without line/fence-length anchoring, a literal + // ``` inside a code block (e.g. a nested markdown example) terminates the outer + // block at the wrong place, leaking content into the markdown stream where + // bold/italic/inline-code passes corrupt it. Fixes #1438 and #1696. + s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{ + const langInfo=(info||'').trim(); + const langMatch=langInfo.match(/^(\w[\w+-]*)$/); + const lang=langMatch?(langMatch[1]||'').trim().toLowerCase():''; + code=code||''; const codeLines=code.split('\n'); const firstCodeLine=codeLines.find(line=>line.trim())||''; const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||''; @@ -3202,8 +3228,30 @@ function dismissUpdate(){ const b=$('updateBanner');if(b)b.classList.remove('visible'); sessionStorage.setItem('hermes-update-dismissed','1'); } +function _isUpdateApplyNetworkError(error){ + if(error && error.status) return false; + const message=(error&&error.message)||String(error||''); + return /Failed to fetch|NetworkError|Load failed/i.test(message); +} +function _formatUpdateApplyExceptionMessage(error){ + if(_isUpdateApplyNetworkError(error)){ + return 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.'; + } + const message=(error&&error.message)||String(error||'unknown error'); + return 'Update failed: '+message; +} async function applyUpdates(){ + if(window._updateApplyInFlight) return; + window._updateApplyInFlight=true; const btn=$('btnApplyUpdate'); + const resetApplyButton=(delayMs)=>{ + const reset=()=>{ + window._updateApplyInFlight=false; + if(btn){btn.disabled=false;btn.textContent='Update Now';} + }; + if(delayMs>0) setTimeout(reset,delayMs); + else reset(); + }; if(btn){btn.disabled=true;btn.textContent='Updating\u2026';} const errEl=$('updateError'); if(errEl){errEl.style.display='none';errEl.textContent='';} @@ -3219,7 +3267,7 @@ async function applyUpdates(){ const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})}); if(!res.ok){ _showUpdateError(target,res); - if(btn){btn.disabled=false;btn.textContent='Update Now';} + resetApplyButton(0); return; } } @@ -3228,9 +3276,10 @@ async function applyUpdates(){ sessionStorage.removeItem('hermes-update-dismissed'); _waitForServerThenReload(); }catch(e){ - if(errEl){errEl.textContent='Update failed: '+e.message;errEl.style.display='block';} - else showToast('Update failed: '+e.message); - if(btn){btn.disabled=false;btn.textContent='Update Now';} + const msg=_formatUpdateApplyExceptionMessage(e); + if(errEl){errEl.textContent=msg;errEl.style.display='block';} + else showToast(msg); + resetApplyButton(_isUpdateApplyNetworkError(e)?5000:0); } } function _showUpdateError(target,res){ @@ -5653,6 +5702,7 @@ function _renderTreeItems(container, entries, depth){ // Name const nameEl=document.createElement('span'); nameEl.className='file-name';nameEl.textContent=item.name;nameEl.title=t('double_click_rename'); + nameEl.onclick=(e)=>e.stopPropagation(); nameEl.ondblclick=(e)=>{ e.stopPropagation(); // For directories, double-click navigates (breadcrumb view) diff --git a/tests/test_1058_adaptive_title_refresh.py b/tests/test_1058_adaptive_title_refresh.py index bfb8afce..8ccd0d03 100644 --- a/tests/test_1058_adaptive_title_refresh.py +++ b/tests/test_1058_adaptive_title_refresh.py @@ -287,6 +287,40 @@ class TestRunBackgroundTitleRefresh: assert len(title_events) == 1 assert title_events[0][1]['title'] == 'New Refreshed Title' + def test_saves_refreshed_title_outside_global_lock(self): + """Refreshing an existing title must not call Session.save() while holding LOCK.""" + class TrackingLock: + def __init__(self): + self.held = False + + def __enter__(self): + assert not self.held + self.held = True + return self + + def __exit__(self, exc_type, exc, tb): + self.held = False + + put, events = self._make_put_event() + lock = TrackingLock() + s = self._make_session_obj(title='Old Title') + + def save(*args, **kwargs): + assert not lock.held, "Session.save() must run outside api.models.LOCK" + + s.save = save + fake_sessions = {'sid': s} + with patch('api.streaming.get_session', return_value=s), \ + patch('api.streaming._aux_title_configured', return_value=True), \ + patch('api.streaming._generate_llm_session_title_via_aux', + return_value=('New Refreshed Title', 'llm_ok', 'raw')), \ + patch('api.streaming.SESSIONS', fake_sessions), \ + patch('api.streaming.LOCK', lock): + _run_background_title_refresh('sid', 'u', 'a', 'Old Title', put) + title_events = [(n, d) for n, d in events if n == 'title'] + assert len(title_events) == 1 + assert title_events[0][1]['title'] == 'New Refreshed Title' + def test_exceptions_are_silently_swallowed(self): """Any unexpected error inside must not propagate — it's a background daemon.""" put, events = self._make_put_event() diff --git a/tests/test_1325_user_fenced_code.py b/tests/test_1325_user_fenced_code.py index 80c1be39..7b4ec77a 100644 --- a/tests/test_1325_user_fenced_code.py +++ b/tests/test_1325_user_fenced_code.py @@ -7,23 +7,30 @@ UI_JS = os.path.join(os.path.dirname(__file__), '..', 'static', 'ui.js') def _extract_js_functions(): - """Extract esc and _renderUserFencedBlocks from ui.js by line numbers.""" - lines = open(UI_JS).read().split('\n') - # esc is on line 52 (0-indexed: 51) - esc_def = lines[51] - # _renderUserFencedBlocks starts at line 61 (0-indexed: 60) - # Find the end by matching closing brace at column 0 - fn_lines = [] - i = 60 # 0-indexed - depth = 0 - while i < len(lines): - fn_lines.append(lines[i]) - depth += lines[i].count('{') - lines[i].count('}') - if depth <= 0: - break + """Extract esc, fence helpers, and _renderUserFencedBlocks from ui.js.""" + src = open(UI_JS).read() + + def extract_function(name): + start = src.find(f"function {name}(") + if start < 0: + raise AssertionError(f"{name} not found in ui.js") + i = src.find("{", start) + depth = 1 i += 1 - fn_def = '\n'.join(fn_lines) - return esc_def, fn_def + while i < len(src) and depth: + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + i += 1 + return src[start:i] + + esc_line = next(line for line in src.split("\n") if line.startswith("const esc=")) + helper_defs = "\n".join( + extract_function(name) + for name in ("_matchBacktickFenceLine", "_isBacktickFenceClose", "_renderUserFencedBlocks") + ) + return esc_line, helper_defs def _run_user_render(text_input): @@ -116,6 +123,16 @@ class TestUserFencedBlocks: assert '") == 1 + assert out.count("") == 1 + assert '
md
' in out + assert "```inner" in out + assert "foo" in out + assert "
````" not in out + def test_inline_backticks_not_touched(self): """Inline backticks (single backtick, not fenced block) should remain escaped as text.""" out = _run_user_render("use `var x = 1` here") diff --git a/tests/test_issue1154_fenced_code_leak.py b/tests/test_issue1154_fenced_code_leak.py index b1d91b92..30df0e92 100644 --- a/tests/test_issue1154_fenced_code_leak.py +++ b/tests/test_issue1154_fenced_code_leak.py @@ -43,6 +43,8 @@ function extractFunc(name) { } return src.slice(start, i); } +eval(extractFunc('_matchBacktickFenceLine')); +eval(extractFunc('_isBacktickFenceClose')); eval(extractFunc('renderMd')); let buf = ''; diff --git a/tests/test_issue1438_fence_anchoring.py b/tests/test_issue1438_fence_anchoring.py index 63f28b28..530a9707 100644 --- a/tests/test_issue1438_fence_anchoring.py +++ b/tests/test_issue1438_fence_anchoring.py @@ -199,23 +199,15 @@ def test_inline_code_after_fence(): def test_renderMd_fence_regex_is_line_anchored(): - """The fence regex in renderMd must include `(^|\\n)` opener and `(?=\\n|$)` closer. - - Pattern: (^|\\n)[ ]{0,3}```(?:([\\s\\S]*?)\\n)?[ ]{0,3}```(?=\\n|$) - The `(?:...\\n)?` makes the body optional so empty fences (```\\n```) still match. - """ - assert re.search( - r"s=s\.replace\(/\(\^\|\\n\)\[ \]\{0,3\}```\(\?:\(\[\\s\\S\]\*\?\)\\n\)\?\[ \]\{0,3\}```\(\?=\\n\|\$\)/g", - UI_JS, - ), "renderMd fence regex is not line-anchored — regression of #1438" + """The fence regex in renderMd must keep line anchoring and fence-length matching.""" + pattern = r"s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g" + assert pattern in UI_JS, "renderMd fence regex lost line anchoring or #1696 fence-length matching" def test_renderUserFencedBlocks_fence_regex_is_line_anchored(): """The fence regex in _renderUserFencedBlocks must also be line-anchored.""" - assert re.search( - r"s=s\.replace\(/\(\^\|\\n\)\[ \]\{0,3\}```\(\[a-zA-Z0-9_\+\-\]\*\)\\n\(\?:\(\[\\s\\S\]\*\?\)\\n\)\?\[ \]\{0,3\}```\(\?=\\n\|\$\)/g", - UI_JS, - ), "_renderUserFencedBlocks fence regex is not line-anchored — regression of #1438" + pattern = r"s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g" + assert UI_JS.count(pattern) >= 2, "render/user fence regexes lost line anchoring or #1696 fence-length matching" def test_stripForTTS_fence_regex_is_line_anchored(): @@ -274,8 +266,10 @@ def test_diff_fence_with_inner_backticks_in_content(): # Pattern explanation: ui.js source contains literal backslash-n in regex literals # (ONE backslash + 'n'). In a Python raw string, r"\\n" compiles to a regex pattern # matching ONE literal backslash followed by 'n'. - matches = re.findall(r"```\(\?=\\n\|\$\)", UI_JS) - assert len(matches) >= 3, ( - f"all 3 fence sites (renderMd, _renderUserFencedBlocks, _stripForTTS) " - f"must have line-anchored close fence; found {len(matches)} occurrences" + new_matches = UI_JS.count(r"[ ]{0,3}\2`*[ \t]*(?=\n|$)") + old_tts_matches = re.findall(r"```\(\?=\\n\|\$\)", UI_JS) + assert new_matches >= 2 and len(old_tts_matches) >= 1, ( + f"renderMd/_renderUserFencedBlocks must have fence-length-aware line-anchored " + f"closers and _stripForTTS must keep a line-anchored closer; found " + f"new={new_matches}, tts={len(old_tts_matches)}" ) diff --git a/tests/test_issue1446_glued_heading_lift.py b/tests/test_issue1446_glued_heading_lift.py index d760c34e..a9ef33f6 100644 --- a/tests/test_issue1446_glued_heading_lift.py +++ b/tests/test_issue1446_glued_heading_lift.py @@ -208,6 +208,8 @@ function extractFunc(name) { } return src.slice(start, i); } +eval(extractFunc('_matchBacktickFenceLine')); +eval(extractFunc('_isBacktickFenceClose')); eval(extractFunc('renderMd')); let buf = ''; diff --git a/tests/test_issue1618_yaml_json_diff_newline_preserve.py b/tests/test_issue1618_yaml_json_diff_newline_preserve.py index 73c5db9f..61f983ef 100644 --- a/tests/test_issue1618_yaml_json_diff_newline_preserve.py +++ b/tests/test_issue1618_yaml_json_diff_newline_preserve.py @@ -145,6 +145,8 @@ function extractFunc(name) { } return src.slice(start, i); } +eval(extractFunc('_matchBacktickFenceLine')); +eval(extractFunc('_isBacktickFenceClose')); eval(extractFunc('renderMd')); let buf = ''; diff --git a/tests/test_issue1633_models_cache_version_stamp.py b/tests/test_issue1633_models_cache_version_stamp.py index b06c0294..772ed127 100644 --- a/tests/test_issue1633_models_cache_version_stamp.py +++ b/tests/test_issue1633_models_cache_version_stamp.py @@ -214,6 +214,7 @@ def test_load_skips_version_check_when_runtime_unknown(isolated_cache, monkeypat # Write a cache that's correct except has no _webui_version cache = { "_schema_version": config._MODELS_CACHE_SCHEMA_VERSION, + "_source_fingerprint": config._models_cache_source_fingerprint(), # no _webui_version **_shape_cache(), } @@ -268,6 +269,7 @@ def test_is_loadable_disk_cache_checks_versions(with_runtime_version): good = { "_schema_version": config._MODELS_CACHE_SCHEMA_VERSION, "_webui_version": "v0.50.293", + "_source_fingerprint": config._models_cache_source_fingerprint(), **_shape_cache(), } assert config._is_loadable_disk_cache(good) is True diff --git a/tests/test_issue1680_codex_spark.py b/tests/test_issue1680_codex_spark.py new file mode 100644 index 00000000..cee1c573 --- /dev/null +++ b/tests/test_issue1680_codex_spark.py @@ -0,0 +1,104 @@ +"""Regression tests for #1680 — Codex model picker uses live Codex discovery.""" + +import json +import sys +import types + +from api import config + + +def _flatten_ids(groups): + return [m.get("id") for g in groups for m in g.get("models", [])] + + +def _install_fake_hermes_models(monkeypatch, provider_model_ids): + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.__path__ = [] + models = types.ModuleType("hermes_cli.models") + models._PROVIDER_ALIASES = {} + models.provider_model_ids = provider_model_ids + monkeypatch.setitem(sys.modules, "hermes_cli", hermes_cli) + monkeypatch.setitem(sys.modules, "hermes_cli.models", models) + + +def _configure_codex(monkeypatch, tmp_path, default="gpt-5.3-codex-spark"): + monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml") + monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json") + monkeypatch.setattr(config, "cfg", { + "model": {"provider": "openai-codex", "default": default}, + "providers": {}, + "fallback_providers": [], + }) + monkeypatch.setattr(config, "_cfg_mtime", 0.0) + config.invalidate_models_cache() + + +def test_openai_codex_group_uses_provider_model_ids_for_spark(monkeypatch, tmp_path): + """Codex-only models from the Codex catalog must surface in /api/models. + + The static WebUI fallback chronically drifts. ``gpt-5.3-codex-spark`` is + the regression case from #1680: it is discoverable by the Codex provider + resolver but was missing from the picker because get_available_models() + copied _PROVIDER_MODELS["openai-codex"] without asking hermes_cli. + """ + calls = [] + + def provider_model_ids(provider): + calls.append(provider) + assert provider == "openai-codex" + return ["gpt-5.4", "gpt-5.3-codex-spark", "gpt-5.3-codex"] + + _install_fake_hermes_models(monkeypatch, provider_model_ids) + _configure_codex(monkeypatch, tmp_path) + + result = config.get_available_models() + + codex_groups = [g for g in result["groups"] if g.get("provider_id") == "openai-codex"] + assert calls == ["openai-codex"] + assert codex_groups, "OpenAI Codex group should be present" + assert "gpt-5.3-codex-spark" in _flatten_ids(codex_groups) + assert codex_groups[0]["models"][0]["label"] == "GPT 5.4" + + +def test_openai_codex_group_merges_visible_codex_cache_models(monkeypatch, tmp_path): + """Visible Codex CLI cache models should appear even if API-filtered. + + Michael's local Codex cache lists ``gpt-5.3-codex-spark`` with + ``supported_in_api: false``. The agent helper currently filters those IDs + out, but the WebUI picker is a Codex-model selection surface and should + mirror the visible Codex catalog instead of hiding Spark. + """ + def provider_model_ids(provider): + assert provider == "openai-codex" + return ["gpt-5.4", "gpt-5.3-codex"] + + _install_fake_hermes_models(monkeypatch, provider_model_ids) + _configure_codex(monkeypatch, tmp_path, default="gpt-5.4") + + codex_home = tmp_path / "codex-home" + codex_home.mkdir() + (codex_home / "models_cache.json").write_text( + json.dumps( + { + "models": [ + {"slug": "gpt-5.4", "visibility": "list", "priority": 0}, + { + "slug": "gpt-5.3-codex-spark", + "visibility": "list", + "supported_in_api": False, + "priority": 7, + }, + {"slug": "hidden-test-model", "visibility": "hide", "priority": 8}, + ] + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + result = config.get_available_models() + + codex_groups = [g for g in result["groups"] if g.get("provider_id") == "openai-codex"] + ids = _flatten_ids(codex_groups) + assert "gpt-5.3-codex-spark" in ids + assert "hidden-test-model" not in ids diff --git a/tests/test_issue1697_multi_image_paste.py b/tests/test_issue1697_multi_image_paste.py new file mode 100644 index 00000000..250cb155 --- /dev/null +++ b/tests/test_issue1697_multi_image_paste.py @@ -0,0 +1,142 @@ +"""Regression coverage for #1697: multi-image clipboard paste attachments.""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).parent.parent.resolve() +BOOT_JS_PATH = REPO_ROOT / "static" / "boot.js" +PANELS_JS_PATH = REPO_ROOT / "static" / "panels.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _read_js(path: Path) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def _extract_msg_paste_registration() -> str: + boot = _read_js(BOOT_JS_PATH) + marker = "$('msg').addEventListener('paste',e=>{" + start = boot.find(marker) + assert start >= 0, "boot.js must register the composer paste handler" + end_marker = "\n});" + end = boot.find(end_marker, start) + assert end >= 0, "composer paste handler should end with a listener close" + return boot[start : end + len(end_marker)] + + +def _run_node(source: str) -> str: + result = subprocess.run( + [NODE], + input=source, + text=True, + capture_output=True, + cwd=REPO_ROOT, + timeout=20, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(f"node driver failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}") + return result.stdout.strip() + + +def _paste_harness(items_js: str) -> dict: + paste_registration = json.dumps(_extract_msg_paste_registration()) + source = f""" +const vm = require('vm'); +const pasteRegistration = {paste_registration}; +const listeners = {{}}; +const S = {{pendingFiles: []}}; +let renderCount = 0; +let lastStatus = ''; +let preventDefaultCount = 0; +class File extends Blob {{ + constructor(parts, name, options={{}}) {{ + super(parts, options); + this.name = name; + this.lastModified = options.lastModified || 0; + }} +}} +const context = {{ + S, + File, + Blob, + Date: {{now: () => 1700000000000}}, + Array, + console, + $: (id) => {{ + if (id !== 'msg') throw new Error('unexpected element id '+id); + return {{addEventListener: (type, cb) => {{listeners[type] = cb;}}}}; + }}, + addFiles: (files) => {{ + for (const f of files) {{ + if (!S.pendingFiles.find(p => p.name === f.name)) S.pendingFiles.push(f); + }} + renderCount += 1; + }}, + setStatus: (text) => {{ lastStatus = text; }}, + t: (key) => key === 'image_pasted' ? 'Image pasted: ' : key, +}}; +vm.createContext(context); +vm.runInContext(pasteRegistration, context); +listeners.paste({{ + clipboardData: {{items: {items_js}}}, + preventDefault: () => {{ preventDefaultCount += 1; }}, +}}); +console.log(JSON.stringify({{ + pendingNames: S.pendingFiles.map(f => f.name), + pendingCount: S.pendingFiles.length, + renderCount, + lastStatus, + preventDefaultCount, +}})); +""" + return json.loads(_run_node(source)) + + +def test_one_clipboard_paste_with_two_image_items_adds_two_attachment_chips(): + """Two image clipboard items from one paste must survive addFiles() filename de-dupe.""" + result = _paste_harness( + "[" + "{kind:'file', type:'image/png', getAsFile:()=>new Blob(['one'], {type:'image/png'})}," + "{kind:'file', type:'image/png', getAsFile:()=>new Blob(['two'], {type:'image/png'})}" + "]" + ) + + assert result["preventDefaultCount"] == 1 + assert result["renderCount"] == 1 + assert result["pendingCount"] == 2 + assert result["pendingNames"] == [ + "screenshot-1700000000000-1.png", + "screenshot-1700000000000-2.png", + ] + assert result["lastStatus"] == ( + "Image pasted: screenshot-1700000000000-1.png, " + "screenshot-1700000000000-2.png" + ) + + +def test_single_image_paste_keeps_existing_screenshot_filename_shape(): + """The one-image path should keep screenshot-. for compatibility.""" + result = _paste_harness( + "[{kind:'file', type:'image/png', getAsFile:()=>new Blob(['one'], {type:'image/png'})}]" + ) + + assert result["pendingNames"] == ["screenshot-1700000000000.png"] + + +def test_file_picker_and_drop_paths_still_pass_real_file_names_to_addfiles(): + """Non-clipboard multi-file paths should preserve browser-provided filenames.""" + boot = _read_js(BOOT_JS_PATH) + panels = _read_js(PANELS_JS_PATH) + + assert "$('fileInput').onchange=e=>{addFiles(Array.from(e.target.files));e.target.value='';};" in boot + assert "const files=Array.from(e.dataTransfer.files);" in panels + assert "if(files.length){addFiles(files);$('msg').focus();}" in panels + assert "screenshot-" not in panels[panels.find("document.addEventListener('drop'") : panels.find("document.addEventListener('drop'") + 900] diff --git a/tests/test_issue1699_model_cache_source_fingerprint.py b/tests/test_issue1699_model_cache_source_fingerprint.py new file mode 100644 index 00000000..30500eb5 --- /dev/null +++ b/tests/test_issue1699_model_cache_source_fingerprint.py @@ -0,0 +1,144 @@ +"""Regression tests for #1699: /api/models cache must track external auth/config changes. + +The bug: WebUI caches /api/models for 24h in memory and on disk. When a user +runs `hermes setup` in a terminal and the Hermes auth store switches the active +provider outside WebUI, the browser can keep seeing the previous provider's +PRIMARY badge until the cache is manually cleared or expires. +""" + +import json +import sys +import time +import types + +import api.config as config + + +def _reset_memory_cache() -> None: + with config._available_models_cache_lock: + config._available_models_cache = None + config._available_models_cache_ts = 0.0 + if hasattr(config, "_available_models_cache_source_fingerprint"): + config._available_models_cache_source_fingerprint = None + config._cache_build_in_progress = False + config._cache_build_cv.notify_all() + + +def _valid_models_cache(provider_id: str, model_id: str) -> dict: + return { + "active_provider": provider_id, + "default_model": model_id, + "configured_model_badges": { + model_id: {"role": "primary", "label": "Primary", "provider": provider_id} + }, + "groups": [ + { + "provider": config._PROVIDER_DISPLAY.get(provider_id, provider_id.title()), + "provider_id": provider_id, + "models": [{"id": model_id, "label": model_id}], + } + ], + } + + +def _write_auth_store(hermes_home, provider_id: str) -> None: + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "auth.json").write_text( + json.dumps({"active_provider": provider_id, "credential_pool": {}}), + encoding="utf-8", + ) + + +def _configure_isolated_sources(tmp_path, monkeypatch, provider_id: str) -> None: + hermes_home = tmp_path / "hermes-home" + state_dir = tmp_path / "state" + cache_path = state_dir / "models_cache.json" + state_dir.mkdir(parents=True, exist_ok=True) + + hermes_home.mkdir(parents=True, exist_ok=True) + config_path = hermes_home / "config.yaml" + # Leave model.provider unset so get_available_models() must honor the auth + # store's active_provider fallback, matching CLI setup/auth-store drift. + config_path.write_text("model:\n default: glm-5.1\n", encoding="utf-8") + monkeypatch.setenv("HERMES_CONFIG_PATH", str(config_path)) + + import api.profiles as profiles + + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: hermes_home) + monkeypatch.setattr(config, "_models_cache_path", cache_path) + + # Keep the test hermetic without requiring hermes-agent to be installed in + # CI: inject the tiny hermes_cli surface get_available_models() imports. + fake_pkg = types.ModuleType("hermes_cli") + fake_pkg.__path__ = [] + fake_models = types.ModuleType("hermes_cli.models") + fake_models._PROVIDER_ALIASES = {} + fake_models.list_available_providers = lambda: [] + fake_auth = types.ModuleType("hermes_cli.auth") + fake_auth.get_auth_status = lambda provider_id: { + "logged_in": False, + "key_source": "", + } + monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg) + monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models) + monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth) + + _write_auth_store(hermes_home, provider_id) + config.reload_config() + _reset_memory_cache() + + +def test_memory_models_cache_invalidates_when_auth_store_active_provider_changes( + tmp_path, monkeypatch +): + _configure_isolated_sources(tmp_path, monkeypatch, "opencode-go") + + stale_openrouter = _valid_models_cache("openrouter", "minimax-m2.7") + with config._available_models_cache_lock: + config._available_models_cache = stale_openrouter + config._available_models_cache_ts = time.monotonic() + if hasattr(config, "_available_models_cache_source_fingerprint"): + # Simulate a cache populated before the external CLI auth-store write. + config._available_models_cache_source_fingerprint = { + "auth_json": {"path": "old-auth.json", "mtime_ns": 1, "size": 10}, + "config_yaml": {"path": "old-config.yaml", "mtime_ns": 1, "size": 10}, + } + + result = config.get_available_models() + + assert result["active_provider"] == "opencode-go" + assert not any(group.get("provider_id") == "openrouter" for group in result["groups"]) + assert any(group.get("provider_id") == "opencode-go" for group in result["groups"]) + + +def test_disk_models_cache_invalidates_when_auth_store_active_provider_changes( + tmp_path, monkeypatch +): + _configure_isolated_sources(tmp_path, monkeypatch, "openrouter") + stale_openrouter = _valid_models_cache("openrouter", "minimax-m2.7") + config._save_models_cache_to_disk(stale_openrouter) + assert config._models_cache_path.exists() + + # External terminal `hermes setup` changes auth.json, not WebUI's in-process cache. + hermes_home = config._models_cache_path.parent.parent / "hermes-home" + _write_auth_store(hermes_home, "opencode-go") + _reset_memory_cache() + + result = config.get_available_models() + + assert result["active_provider"] == "opencode-go" + assert not any(group.get("provider_id") == "openrouter" for group in result["groups"]) + assert any(group.get("provider_id") == "opencode-go" for group in result["groups"]) + + +def test_disk_models_cache_still_loads_when_auth_and_config_sources_are_unchanged( + tmp_path, monkeypatch +): + _configure_isolated_sources(tmp_path, monkeypatch, "opencode-go") + fresh_opencode = _valid_models_cache("opencode-go", "glm-5.1") + config._save_models_cache_to_disk(fresh_opencode) + _reset_memory_cache() + + result = config.get_available_models() + + assert result == fresh_opencode diff --git a/tests/test_issue798.py b/tests/test_issue798.py index 4207400b..37889688 100644 --- a/tests/test_issue798.py +++ b/tests/test_issue798.py @@ -9,7 +9,9 @@ get_hermes_home_for_profile() resolves a HERMES_HOME path from a name without touching os.environ or module-level state. """ +import json import os +import subprocess import sys import threading from pathlib import Path @@ -71,6 +73,96 @@ def test_get_hermes_home_for_profile_does_not_mutate_globals(): ) +def _run_profile_resolution_probe(env): + script = r''' +import json +from pathlib import Path +import api.profiles as p +import api.models as m + +p.set_request_profile('foo') +foo_home = p.get_active_hermes_home() +explicit_foo_home = p.get_hermes_home_for_profile('foo') +foo_runtime = p.get_profile_runtime_env(explicit_foo_home) +model_home = m._get_profile_home('foo') +explicit_bar_home = p.get_hermes_home_for_profile('bar') +p.set_request_profile('bar') +active_bar_home = p.get_active_hermes_home() +print(json.dumps({ + 'default_home': str(p._DEFAULT_HERMES_HOME), + 'foo_home': str(foo_home), + 'explicit_foo_home': str(explicit_foo_home), + 'foo_terminal_cwd': foo_runtime.get('TERMINAL_CWD'), + 'model_home': str(model_home), + 'explicit_bar_home': str(explicit_bar_home), + 'active_bar_home': str(active_bar_home), +})) +''' + result = subprocess.run( + [sys.executable, '-c', script], + cwd=Path(__file__).parent.parent, + env=env, + text=True, + capture_output=True, + check=True, + ) + return json.loads(result.stdout) + + +def test_hermes_base_home_named_profile_matches_cookie_without_doubling(tmp_path): + """R19k / #749: HERMES_BASE_HOME may point directly at a named profile home. + + A single-profile WebUI deployment can start with both HERMES_BASE_HOME and + HERMES_HOME set to /base/profiles/foo while the browser still sends the + logical cookie hermes_profile=foo. Both active-profile and explicit + per-request helpers must use /base/profiles/foo, not the doubled + /base/profiles/foo/profiles/foo path — even if that nested path already + exists from a prior bad write. + """ + profile_home = tmp_path / 'profiles' / 'foo' + doubled_home = profile_home / 'profiles' / 'foo' + doubled_home.mkdir(parents=True) + profile_home.joinpath('config.yaml').write_text( + 'terminal:\n cwd: /expected/profile-home\n', encoding='utf-8' + ) + doubled_home.joinpath('config.yaml').write_text( + 'terminal:\n cwd: /wrong/doubled-home\n', encoding='utf-8' + ) + + env = os.environ.copy() + env.update({ + 'HERMES_BASE_HOME': str(profile_home), + 'HERMES_HOME': str(profile_home), + }) + data = _run_profile_resolution_probe(env) + + assert data['default_home'] == str(tmp_path) + assert data['foo_home'] == str(profile_home) + assert data['explicit_foo_home'] == str(profile_home) + assert data['foo_terminal_cwd'] == '/expected/profile-home' + assert data['model_home'] == str(profile_home) + + +def test_hermes_base_home_named_profile_nonmatching_cookie_uses_sibling_profile_path(tmp_path): + """R19l / #749: non-matching cookies must not silently route to the pinned home. + + When HERMES_BASE_HOME is supplied as /base/profiles/foo but the request asks + for logical profile bar, preserving base semantics means bar resolves to the + sibling /base/profiles/bar. It must not fall back to foo, and it must not + append bar under foo/profiles/bar. + """ + profile_home = tmp_path / 'profiles' / 'foo' + profile_home.mkdir(parents=True) + + env = os.environ.copy() + env.update({'HERMES_BASE_HOME': str(profile_home)}) + data = _run_profile_resolution_probe(env) + + expected_bar_home = tmp_path / 'profiles' / 'bar' + assert data['explicit_bar_home'] == str(expected_bar_home) + assert data['active_bar_home'] == str(expected_bar_home) + + # ── R19e-h: new_session() profile isolation ─────────────────────────────────── # These tests call new_session() directly in-process. Session.save() would write # to SESSION_DIR which is set from HERMES_WEBUI_STATE_DIR at import time and may diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 102c69df..22a831b7 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -54,6 +54,8 @@ function extractFunc(name) { } return src.slice(start, i); } +eval(extractFunc('_matchBacktickFenceLine')); +eval(extractFunc('_isBacktickFenceClose')); eval(extractFunc('renderMd')); let buf = ''; @@ -285,6 +287,49 @@ class TestBugFencedCodeInBlockquote: assert "x = 1" in out +class TestFencedCodeFenceLength: + """CommonMark §4.5 requires the closer to be at least as long as the opener.""" + + def test_five_backtick_outer_fence_preserves_inner_triple_fence(self, driver_path): + src = ( + "- optionally also support fenced code blocks\n\n" + "`````md\n" + "`md\n" + "```novelcrafter\n" + "{#if novel.hasSeries}\n" + "...\n" + "{#endif}\n" + "```\n" + "`````\n\n" + "That is much more correct than pretending" + ) + out = _render(driver_path, src) + assert out.count("
") == 1
+        assert out.count("
") == 1 + assert '
md
' in out + assert "```novelcrafter" in out + assert "{#if novel.hasSeries}" in out + assert "That is much more correct than pretending" in out + assert "

`````" not in out + assert "
`````" not in out + + def test_four_backtick_outer_fence_preserves_inner_triple_fence(self, driver_path): + out = _render(driver_path, "````md\n```inner\nfoo\n```\n````\n") + assert out.count("

") == 1
+        assert out.count("
") == 1 + assert '
md
' in out + assert "```inner" in out + assert "foo" in out + assert "

````" not in out + + def test_three_backtick_fence_still_renders_language_class(self, driver_path): + out = _render(driver_path, "```js\nconsole.log('ok')\n```") + assert out.count("

") == 1
+        assert '
js
' in out + assert 'class="language-js"' in out + assert "console.log('ok')" in out + + class TestBugBlankContinuationInBlockquote: """Bug 2: blank > lines between paragraphs fragmented the blockquote into separate elements with literal > characters between them.""" diff --git a/tests/test_sprint16.py b/tests/test_sprint16.py index 0b7b309a..fd9882bc 100644 --- a/tests/test_sprint16.py +++ b/tests/test_sprint16.py @@ -61,8 +61,8 @@ def render_md(raw): fence_stash.append(m.group()) return "\x00F" + str(len(fence_stash) - 1) + "\x00" - # Fence regex line-anchored to match JS fix for #1438 (allows empty fence) - s = re.sub(r"(?:^|\n)[ ]{0,3}```(?:[\s\S]*?\n)?[ ]{0,3}```(?=\n|$)|`[^`\n]+`", stash, s) + # Fence regex line-anchored to match JS fix for #1438 and fence-length fix for #1696 + s = re.sub(r"(?:^|\n)[ ]{0,3}(`{3,})[^\n`]*\n(?:[\s\S]*?\n)?[ ]{0,3}\1`*(?=\n|$)|`[^`\n]+`", stash, s) s = re.sub(r"([\s\S]*?)", lambda m: "**" + m.group(1) + "**", s, flags=re.I) s = re.sub(r"([\s\S]*?)", lambda m: "**" + m.group(1) + "**", s, flags=re.I) s = re.sub(r"([\s\S]*?)", lambda m: "*" + m.group(1) + "*", s, flags=re.I) @@ -77,11 +77,12 @@ def render_md(raw): # Fenced code blocks def fenced(m): - lang, code = m.group(1), (m.group(2) or "").rstrip("\n") + info, code = (m.group(2) or "").strip(), (m.group(3) or "").rstrip("\n") + lang = info.lower() if re.match(r"^\w[\w+-]*$", info) else "" h = f'
{esc(lang)}
' if lang else "" return h + "
" + esc(code) + "
" - # Fenced code blocks (line-anchored, fixes #1438; allows empty fence) - s = re.sub(r"(?:^|\n)[ ]{0,3}```([\w+-]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)", fenced, s) + # Fenced code blocks (line-anchored, fixes #1438; fence-length matching fixes #1696) + s = re.sub(r"(?:^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\1`*(?=\n|$)", fenced, s) s = re.sub(r"`([^`\n]+)`", lambda m: "" + esc(m.group(1)) + "", s) # Inline formatting (top-level, outside list items) @@ -358,6 +359,52 @@ def test_render_md_fenced_code_protects_html(cleanup_test_sessions): "Fenced code content was lost after stash/restore" +def test_render_md_fenced_code_with_five_backtick_outer_preserves_inner_triples(cleanup_test_sessions): + """CommonMark §4.5: a 5-backtick fence must not close at an inner triple fence.""" + src = ( + "- optionally also support fenced code blocks\n\n" + "`````md\n" + "`md\n" + "```novelcrafter\n" + "{#if novel.hasSeries}\n" + "...\n" + "{#endif}\n" + "```\n" + "`````\n\n" + "That is much more correct than pretending" + ) + out = render_md(src) + assert out.count("
") == 1
+    assert out.count("
") == 1 + assert '
md
' in out + assert "```novelcrafter" in out + assert "{#if novel.hasSeries}" in out + assert "That is much more correct than pretending" in out + assert "

`````" not in out + assert "
`````" not in out + + +def test_render_md_fenced_code_with_four_backtick_outer_preserves_inner_triples(cleanup_test_sessions): + """A 4-backtick outer fence should also require a 4+ backtick closer.""" + src = "````md\n```inner\nfoo\n```\n````\n" + out = render_md(src) + assert out.count("

") == 1
+    assert out.count("
") == 1 + assert '
md
' in out + assert "```inner" in out + assert "foo" in out + assert "

````" not in out + + +def test_render_md_fenced_code_three_backtick_path_still_renders_language(cleanup_test_sessions): + """The common 3-backtick path must keep rendering a single language-tagged block.""" + src = "```js\nconsole.log('ok')\n```" + out = render_md(src) + assert out.count("

") == 1
+    assert '
js
' in out + assert "console.log('ok')" in out or "console.log('ok')" in out + + # ── Security: XSS must be blocked ───────────────────────────────────────────── def test_render_md_xss_img_tag_escaped(cleanup_test_sessions): diff --git a/tests/test_sprint41.py b/tests/test_sprint41.py index f4f6d542..1e5b3983 100644 --- a/tests/test_sprint41.py +++ b/tests/test_sprint41.py @@ -327,6 +327,19 @@ class TestIssue495TitleStreaming(unittest.TestCase): "Substantive answer text on a tool_call row must be preserved", ) + def test_fallback_title_preserves_unicode_letters(self): + """Local fallback title generation must not strip German umlauts.""" + from api.streaming import _fallback_title_from_exchange + + title = _fallback_title_from_exchange( + "Bitte führe ein Selbst-Audit durch. Wo ist überall noch Gemini-2.5-flash als Modell im Einsatz? Sei gründlich", + "Ich prüfe live statt aus Bauchgefühl.", + ) + + self.assertIsNotNone(title) + self.assertIn("führe", title) + self.assertNotIn("hre", title.split()) + def test_title_snippet_skips_tool_call_preamble_only_rows(self): """Tool-call rows whose content is empty or meta-reasoning preamble ('Let me check my memory first.') must still be skipped — those are diff --git a/tests/test_update_apply_ui.py b/tests/test_update_apply_ui.py new file mode 100644 index 00000000..c6cc50aa --- /dev/null +++ b/tests/test_update_apply_ui.py @@ -0,0 +1,55 @@ +"""Frontend regression coverage for Update Now apply failures (#1321).""" +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = ROOT / "static" / "ui.js" + + +def _ui_js() -> str: + return UI_JS.read_text(encoding="utf-8") + + +def test_update_apply_network_error_has_recovery_message_not_raw_failed_to_fetch(): + """Network/interrupted update apply failures should not surface raw fetch text alone.""" + src = _ui_js() + assert "function _formatUpdateApplyExceptionMessage" in src + assert "could not reach the WebUI server" in src + assert "restarted or the connection was interrupted" in src + assert "wait a few seconds, reload the page, then check the server" in src + assert "Update failed: '+e.message" not in src + assert 'Update failed: "+e.message' not in src + + +def test_update_apply_structured_server_errors_still_use_json_message_path(): + """Server-reachable JSON errors must keep the existing targeted message path.""" + src = _ui_js() + apply_start = src.index("async function applyUpdates()") + show_error_call = src.index("_showUpdateError(target,res);", apply_start) + reset_button = src.index("resetApplyButton(0);", show_error_call) + assert show_error_call < reset_button + assert "const msg='Update failed ('+target+'): '+(res.message||'unknown error');" in src + + +def test_update_apply_network_error_classifier_ignores_http_status_errors(): + """HTTP response errors should not be classified as interrupted transport failures.""" + src = _ui_js() + fn_start = src.index("function _isUpdateApplyNetworkError(error)") + fn_end = src.index("function _formatUpdateApplyExceptionMessage", fn_start) + body = src[fn_start:fn_end] + compact = re.sub(r"\s+", "", body) + assert "if(error&&error.status)returnfalse;" in compact + assert body.index("error.status") < body.index("/Failed to fetch|NetworkError|Load failed/i") + assert "Failed to fetch|NetworkError|Load failed" in body + + +def test_update_apply_prevents_duplicate_apply_requests_while_in_flight(): + """Double-clicks should not send a second update apply request during restart race windows.""" + src = _ui_js() + apply_start = src.index("async function applyUpdates()") + next_fn = src.index("function _showUpdateError", apply_start) + body = src[apply_start:next_fn] + assert "window._updateApplyInFlight" in body + assert "if(window._updateApplyInFlight) return;" in body + assert "window._updateApplyInFlight=true;" in body + assert "window._updateApplyInFlight=false;" in body diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 357dace1..5eaee61e 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -79,6 +79,31 @@ class TestUpdateChecker: assert result['repo_url'] == 'https://github.com/NousResearch/hermes-agent' + def test_repo_url_strips_dot_git_before_trailing_slashes(self, tmp_path, monkeypatch): + import api.updates as upd + + (tmp_path / '.git').mkdir() + + def fake_run(args, cwd, timeout=10): + if args[0] == 'fetch': + return '', True + if args[:2] == ['rev-parse', '--abbrev-ref']: + return 'origin/master', True + if args[:2] == ['rev-list', '--count']: + return '2', True + if args[0] == 'merge-base': + return 'abcdef1234567890', True + if args[:2] == ['rev-parse', '--short']: + return 'abcdef1', True + if args[:2] == ['remote', 'get-url']: + return 'https://github.com/nesquena/hermes-webui.git/', True + return '', True + + monkeypatch.setattr(upd, '_run_git', fake_run) + result = upd._check_repo(tmp_path, 'webui') + + assert result['repo_url'] == 'https://github.com/nesquena/hermes-webui' + class TestConflictError: """#813 — conflict error must include flag + recovery command.""" diff --git a/tests/test_workspace_tree_rename.py b/tests/test_workspace_tree_rename.py new file mode 100644 index 00000000..fd6c401a --- /dev/null +++ b/tests/test_workspace_tree_rename.py @@ -0,0 +1,22 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") + + +def test_workspace_file_name_click_stops_before_dblclick_rename(): + """Clicking a file name must not bubble to the row open handler before dblclick rename.""" + name_start = UI_JS.index("const nameEl=document.createElement('span');") + dblclick_idx = UI_JS.index("nameEl.ondblclick=(e)=>", name_start) + click_idx = UI_JS.find("nameEl.onclick=(e)=>e.stopPropagation();", name_start, dblclick_idx) + + assert click_idx != -1, ( + "workspace file-tree name span must stop click propagation before its dblclick " + "rename handler so the row openFile() click does not win the first click" + ) + + +def test_workspace_file_row_click_still_opens_file_preview(): + """Only the name span should swallow clicks; the rest of the file row still opens preview.""" + assert "el.onclick=async()=>openFile(item.path);" in UI_JS