mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
Merge pull request #1707 from nesquena/stage-301
v0.51.4 — 10-PR full-sweep batch
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
|
||||
+2
-2
@@ -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: <repo>/*
|
||||
|
||||
+148
-11
@@ -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)
|
||||
|
||||
+32
-18
@@ -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/<name> 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/<name> 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 = {
|
||||
|
||||
+11
-2
@@ -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)
|
||||
|
||||
+20
-5
@@ -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,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
+4
-2
@@ -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(', '));
|
||||
|
||||
+2
-1
@@ -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;}
|
||||
|
||||
+70
-20
@@ -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?`<span class="model-opt-badge model-opt-badge--${esc(m.badge.role||'configured')}">${esc(m.badge.label||'Configured')}</span>`:'';
|
||||
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${m.name}</span>${badgeHtml}</div><span class="model-opt-id">${m.id}</span>`;
|
||||
// Inline provider chip on every row that has a group (#1425)
|
||||
const providerChip=m.group?`<span class="model-opt-provider">${esc(m.group)}</span>`:'';
|
||||
row.innerHTML=`<div class="model-opt-top"><span class="model-opt-name">${m.name}</span>${badgeHtml}${providerChip}</div><span class="model-opt-id">${m.id}</span>`;
|
||||
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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 '<a ' not in out
|
||||
assert 'https://example.com' in out
|
||||
|
||||
def test_four_backtick_outer_fence_preserves_inner_triple_fence(self):
|
||||
"""User-message code fences should follow CommonMark fence-length matching too."""
|
||||
out = _run_user_render("````md\n```inner\nfoo\n```\n````")
|
||||
assert out.count("<pre>") == 1
|
||||
assert out.count("</pre>") == 1
|
||||
assert '<div class="pre-header">md</div>' in out
|
||||
assert "```inner" in out
|
||||
assert "foo" in out
|
||||
assert "<br>````" 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")
|
||||
|
||||
@@ -43,6 +43,8 @@ function extractFunc(name) {
|
||||
}
|
||||
return src.slice(start, i);
|
||||
}
|
||||
eval(extractFunc('_matchBacktickFenceLine'));
|
||||
eval(extractFunc('_isBacktickFenceClose'));
|
||||
eval(extractFunc('renderMd'));
|
||||
|
||||
let buf = '';
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
|
||||
@@ -208,6 +208,8 @@ function extractFunc(name) {
|
||||
}
|
||||
return src.slice(start, i);
|
||||
}
|
||||
eval(extractFunc('_matchBacktickFenceLine'));
|
||||
eval(extractFunc('_isBacktickFenceClose'));
|
||||
eval(extractFunc('renderMd'));
|
||||
|
||||
let buf = '';
|
||||
|
||||
@@ -145,6 +145,8 @@ function extractFunc(name) {
|
||||
}
|
||||
return src.slice(start, i);
|
||||
}
|
||||
eval(extractFunc('_matchBacktickFenceLine'));
|
||||
eval(extractFunc('_isBacktickFenceClose'));
|
||||
eval(extractFunc('renderMd'));
|
||||
|
||||
let buf = '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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-<timestamp>.<ext> 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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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("<pre>") == 1
|
||||
assert out.count("</pre>") == 1
|
||||
assert '<div class="pre-header">md</div>' in out
|
||||
assert "```novelcrafter" in out
|
||||
assert "{#if novel.hasSeries}" in out
|
||||
assert "That is much more correct than pretending" in out
|
||||
assert "<p>`````" not in out
|
||||
assert "<br>`````" 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("<pre>") == 1
|
||||
assert out.count("</pre>") == 1
|
||||
assert '<div class="pre-header">md</div>' in out
|
||||
assert "```inner" in out
|
||||
assert "foo" in out
|
||||
assert "<p>````" 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("<pre>") == 1
|
||||
assert '<div class="pre-header">js</div>' 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."""
|
||||
|
||||
+52
-5
@@ -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"<strong>([\s\S]*?)</strong>", lambda m: "**" + m.group(1) + "**", s, flags=re.I)
|
||||
s = re.sub(r"<b>([\s\S]*?)</b>", lambda m: "**" + m.group(1) + "**", s, flags=re.I)
|
||||
s = re.sub(r"<em>([\s\S]*?)</em>", 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'<div class="pre-header">{esc(lang)}</div>' if lang else ""
|
||||
return h + "<pre><code>" + esc(code) + "</code></pre>"
|
||||
# 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: "<code>" + esc(m.group(1)) + "</code>", 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("<pre>") == 1
|
||||
assert out.count("</pre>") == 1
|
||||
assert '<div class="pre-header">md</div>' in out
|
||||
assert "```novelcrafter" in out
|
||||
assert "{#if novel.hasSeries}" in out
|
||||
assert "That is much more correct than pretending" in out
|
||||
assert "<p>`````" not in out
|
||||
assert "<br>`````" 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("<pre>") == 1
|
||||
assert out.count("</pre>") == 1
|
||||
assert '<div class="pre-header">md</div>' in out
|
||||
assert "```inner" in out
|
||||
assert "foo" in out
|
||||
assert "<p>````" 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("<pre>") == 1
|
||||
assert '<div class="pre-header">js</div>' 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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user