Merge pull request #1707 from nesquena/stage-301

v0.51.4 — 10-PR full-sweep batch
This commit is contained in:
nesquena-hermes
2026-05-05 08:56:38 -07:00
committed by GitHub
30 changed files with 1105 additions and 100 deletions
+35
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -50,6 +50,15 @@ function _setCompressionSessionLock(sid){
window._compressionLockSid=sid||null;
}
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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)
+34
View File
@@ -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()
+33 -16
View File
@@ -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")
+2
View File
@@ -43,6 +43,8 @@ function extractFunc(name) {
}
return src.slice(start, i);
}
eval(extractFunc('_matchBacktickFenceLine'));
eval(extractFunc('_isBacktickFenceClose'));
eval(extractFunc('renderMd'));
let buf = '';
+11 -17
View File
@@ -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
+104
View File
@@ -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
+142
View File
@@ -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
+92
View File
@@ -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
+45
View File
@@ -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(&#39;ok&#39;)" in out
class TestBugBlankContinuationInBlockquote:
"""Bug 2: blank > lines between paragraphs fragmented the blockquote into
separate elements with literal > characters between them."""
+52 -5
View File
@@ -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(&#39;ok&#39;)" in out or "console.log(&#x27;ok&#x27;)" in out
# ── Security: XSS must be blocked ─────────────────────────────────────────────
def test_render_md_xss_img_tag_escaped(cleanup_test_sessions):
+13
View File
@@ -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
+55
View File
@@ -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
+25
View File
@@ -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."""
+22
View File
@@ -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