Two bugs fixed: (1) _PROVIDER_MODELS["nous"] updated to slash-prefixed IDs that Nous API expects. (2) resolve_model_provider() now routes portal provider models through the portal (not OpenRouter) and preserves the full slash-prefixed model ID. 10 regression tests.
Breaking: auto_install_agent_deps() is now disabled by default. Set HERMES_WEBUI_AUTO_INSTALL=1 to re-enable. New _trusted_agent_dir() checks ownership and permission bits. Addresses #842 by @tomaioo.
Pass gateway_session_key=session_id to AIAgent from streaming.py so Honcho per-session strategy pins to stable WebUI session ID rather than creating a new Honcho session each turn.
Surfaces providers added via credential_pool in the model dropdown. Ambient gh-cli tokens suppressed. _apply_provider_prefix helper extracted. Ollama Cloud display name + dynamic model list. looksLikeBareOllamaId heuristic tightened. Test isolation fixed.
PR #820 by @starship-s.
Three related profile-switching fixes:
- Always persist hermes_profile=default cookie when switching back to default (was being cleared with max-age=0, causing fallback to process-global profile)
- Replace undefined updateWorkspaceChip() with syncTopbar() in the sessionInProgress branch of switchToProfile()
- Make sidebar/dropdown active-profile rendering prefer S.activeProfile client state when available, with safe fallback
Tests: 1854 passing.
Replace _normalize_session_model_in_place() on the GET /api/session read path with a read-only _resolve_effective_session_model_for_display() that returns the effective display model without writing it back to disk or the session index.
Closes#845.
Tests: 1856 passing.
Prune ghost _index.json rows whose backing session file no longer exists, on both incremental index writes and all_sessions() reads. Fixes duplicate session entries after session-id rotation (e.g. context compression). Also pre-snapshots in_memory_ids under a single LOCK acquisition in all_sessions() rather than one per row.
Closes#846.
Review additions: optimised lock pattern in all_sessions() (one LOCK acquisition instead of N). Tests: 1856 passing.
* fix(models): stale cross-provider model no longer shows as unavailable in picker
Two bugs allowed an openai/gpt-5.4-mini stale session model to appear as
'(unavailable)' under a custom provider group for users who never configured
OpenAI (#829).
Backend (api/routes.py): _resolve_compatible_session_model() had a blanket
early-return for active_provider in {custom, openrouter} that skipped all
normalization regardless of whether any catalog group could route the model's
prefix. A custom_providers-only user with a stale openai/... session model
was never corrected. Fixed: only skip normalization when the model prefix is
actually routable (matches a catalog group provider_id, or an openrouter
group is present that can route any provider/model).
Frontend (static/ui.js): renderSession() injected a bare <option> (not in
any <optgroup>) for models not found in the dropdown. renderModelDropdown()
rendered bare options without emitting a group heading, so they visually
inherited the last rendered provider heading — making the stale model appear
to belong to the custom provider group. Fixed: silently reset to the first
available model and fire a PATCH to persist the correction instead of
injecting a misleading (unavailable) option.
5 new tests in test_provider_mismatch.py cover:
- stale openai model cleared when custom_providers-only + no default_model
- stale openai model cleared when custom_providers-only + default_model set
- openrouter model preserved when openrouter group present
- custom/ namespace always preserved
- ui.js no longer injects model_unavailable option
* fix(ui): declare modelSel locally in syncTopbar reset path; fix test assertion
- Use const modelSel=$('modelSelect') instead of undeclared sel in the
stale-model reset branch of syncTopbar() (caught in Opus review)
- Fix test assertion: or → and for model_unavailable key absence check
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
* fix(sessions): surface gateway SSE failures and add polling fallback
- add a JSON probe mode for the gateway SSE endpoint
- detect watcher-unavailable 503s from the browser
- fall back to periodic session refresh with a toast
- add probe payload tests and endpoint coverage
Fixes#635
* fix(sessions): surface gateway SSE failures and add polling fallback (#826)
Absorbed from PR #826 by @cloudyun888 (fixes#635).
When the gateway watcher thread is not running, the browser now shows a
toast notification and falls back to 30-second periodic polling for session
sync. Previously the SSE failure was completely silent with no user feedback.
Changes from original PR:
- Deleted misplaced test_gateway_sse_probe_unit.py (was at repo root, not
discovered by `pytest tests/`); unit tests moved into tests/test_gateway_sync.py
- _gateway_sse_probe_payload now checks watcher._thread.is_alive() rather
than just watcher is not None — a watcher instance with a dead poll thread
now correctly reports unavailable and activates the polling fallback
- probeGatewaySSEStatus catch(e) now starts the polling fallback on network
error rather than silently swallowing the failure
- Added 5 unit tests covering all watcher-alive/dead/missing/disabled branches
Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
* cleanup(gateway): public is_alive() + dedup probe/live watcher-alive check + changelog
Three small cleanups on top of @cloudyun888's PR #826 absorption:
1. Add GatewayWatcher.is_alive() public accessor so routes.py doesn't
reach into the private _thread attribute. The existing private-
attribute check stays as a defensive fallback for any older in-
memory instance or test double that doesn't implement the full API.
2. Dedupe the watcher_alive computation in _handle_gateway_sse_stream:
the live-SSE path now calls _gateway_sse_probe_payload(...) and reads
its watcher_running field instead of re-deriving the same logic
inline. Keeps probe and SSE in sync automatically.
3. CHANGELOG trailer was (#826, fixes#635, @cloudyun888) — this PR is
#828, so updated to (#828, absorbs PR #826 by @cloudyun888, fixes
#635) matching the repo convention for absorbed PRs (see #805).
Added two regression tests:
- test_gateway_watcher_is_alive_public_method — covers the three
lifecycle states (before start, while running, after stop).
- test_probe_payload_prefers_public_is_alive — asserts the probe
uses watcher.is_alive() rather than poking _thread when the
public method exists.
Full suite: 1735 passed, 0 new failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: cloudyun888 <269269188+86cloudyun-afk@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#815.
Three root causes fixed:
1. Provider aliases (z.ai/x.ai/google/grok/claude/aws-bedrock/dashscope/~25 more) not
normalized before _PROVIDER_MODELS lookup — provider fell to empty else-branch while
TUI worked (it normalizes at startup). Fixed via _resolve_provider_alias() + inlined
_PROVIDER_ALIASES table in api/config.py.
2. Silent ImportError in original normalization: 'from hermes_cli.models import
_PROVIDER_ALIASES' inside try/except silently failed without hermes-agent on sys.path
(CI, minimal installs). The inlined table fixes this — normalization now works
regardless of whether hermes-agent is installed.
3. /api/models/live?provider=custom now falls back to custom_providers entries from
config.yaml when provider_model_ids() returns empty.
Also: provider_id on every group in /api/models response for deterministic JS optgroup
matching (no substring false positives). 17 targeted tests, 1725/1725 full suite.
* fix: update banner conflict recovery + server self-restart after update (#813#814)
* fix(update): restart must wait for in-flight update + reset force button on retry
Two defects in the update banner flow found during review of PR #816:
1. Two-target race (webui + agent sequential)
The client posts targets sequentially: webui succeeds and schedules
a restart timer (2 s delay); client then posts agent; server begins
agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull,
killing the agent update and closing the client connection. User
sees "Update failed (agent): Failed to fetch" even though webui did
update, and the agent repo is in an unknown partial state.
Fix: _schedule_restart() now blocks on _apply_lock before calling
os.execv. If a second update is in flight when the timer fires, the
restart thread waits until it completes. If nothing is in flight the
lock acquire is instant, so no-op updates still restart immediately.
2. Stale force-update button across retries
_showUpdateError sets btnForceUpdate to display:inline-block when
res.conflict / res.diverged. Nothing resets it on the next retry,
so a subsequent non-conflict error (e.g. network) leaves the stale
force button visible pointing at the previous target.
Fix: applyUpdates() now hides the force button and clears its
data-target at the start of each attempt.
Tests:
- test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a
helper thread, verifies execv is delayed until the lock is released.
- test_schedule_restart_still_fires_when_no_update_in_flight: sanity
check that the common path still works with no contention.
- test_apply_updates_resets_force_button_at_start: regression guard
that the reset appears before the update loop begins.
Full suite: 1683 passed, 0 failures.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(update): hold _apply_lock through execv + fix banner error layout
Two fixes from Opus review:
1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern
acquired _apply_lock, released it, then called os.execv — leaving a brief
window where a new update could start between release and execv. Fixed by
moving os.execv inside the 'with _apply_lock:' block so the process is
replaced while still holding the lock; no new update can acquire it.
2. Banner CSS layout (static/index.html): #updateError was a direct flex child
of .update-banner (display:flex row), so long error messages sat inline
between #updateMsg and the buttons instead of below the message.
Wrapped #updateMsg + #updateError in a flex-column container so errors
stack vertically under the status line.
* docs: add v0.50.134 CHANGELOG entry
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#461
Adds full /reasoning CLI parity to the WebUI slash command system:
- /reasoning show|on → window._showThinking = true; writes display.show_reasoning to config.yaml (same key as CLI); mirrors to settings.json for boot.js
- /reasoning hide|off → same in reverse; re-renders immediately
- /reasoning none|minimal|low|medium|high|xhigh → POST /api/reasoning → writes agent.reasoning_effort to config.yaml; takes effect next turn (matching CLI semantics)
- /reasoning (no args) → GET /api/reasoning → live status toast from config.yaml
- Autocomplete shows all 8 options: show|hide|none|minimal|low|medium|high|xhigh
- Profile-isolated: _get_config_path() is thread-local so per-profile settings never bleed across
- Boot hydration: window._showThinking initialised from settings.json show_thinking on page load
- Inspect.signature guard in streaming.py so older hermes-agent builds don't TypeError
28 new tests, 1708/1708 total passing. Full browser QA on port 8789 with isolated state. CLI/config.yaml sync verified with hermes_constants.parse_reasoning_effort().
Fixes the multi-client profile isolation bug (#798).
- get_hermes_home_for_profile(): pure path resolver, validates name against
_PROFILE_ID_RE (rejects path traversal), never mutates os.environ or globals
- new_session() accepts explicit profile= param from POST body (S.activeProfile),
short-circuits the process-level _active_profile global
- streaming handler resolves HERMES_HOME from s.profile instead of the global
- sessions.js sends profile: S.activeProfile in every new-session POST
10 tests in tests/test_issue798.py including concurrency and traversal coverage.
Co-authored-by: nesquena <nesquena@users.noreply.github.com>
fix(onboarding): recognize credential_pool OAuth auth for openai-codex (#797)
The onboarding readiness check in `api/onboarding.py` only looked at the legacy
`providers[provider]` key in `auth.json`. Hermes runtime resolves OAuth tokens from
`credential_pool[provider]` (device-code / OAuth flows), so WebUI could report "not ready"
while the runtime chatted successfully. The check now covers both storage locations with
a fail-closed helper. Adds three regression tests.
Reported in #796, fixed by @davidsben.
Co-authored-by: davidsben <davidsben@users.noreply.github.com>
* fix: dynamic version badge — read from git tag, never hardcoded
The settings panel showed v0.50.87 and the HTTP Server: header said
HermesWebUI/0.50.38 — both hardcoded strings that drift further behind
with every release because there was no mechanism to keep them in sync.
Changes:
- api/updates.py: add _run_git() (moved before _detect_webui_version),
_detect_webui_version(), and WEBUI_VERSION module constant resolved
once at import time via 'git describe --tags --always --dirty'.
Fallback chain: git → api/_version.py → 'unknown'.
- api/routes.py: inject webui_version into GET /api/settings response
so the frontend can read it without a separate API call.
- static/panels.js: loadSettingsPanel() populates .settings-version-badge
from settings.webui_version — one line after the existing api() call.
- static/index.html: replace stale hardcoded 'v0.50.87' with '—'
placeholder; JS overwrites it as soon as the settings panel opens.
- server.py: replace hardcoded 'HermesWebUI/0.50.38' server_version with
'HermesWebUI/' + WEBUI_VERSION.lstrip('v') — stays in sync automatically.
- Dockerfile: add ARG HERMES_VERSION=unknown and write api/_version.py
so Docker images (where .git is excluded) still show the correct tag.
- .github/workflows/release.yml: pass build-args: HERMES_VERSION=${{ github.ref_name }}
to the Docker build step on tag pushes.
- .gitignore: exclude api/_version.py (generated by Docker/CI, never committed).
No manual 'update the version badge' step is required going forward.
Tagging is sufficient — the badge and HTTP header update automatically.
Tests: 18 new tests in tests/test_version_badge.py covering the full
resolution chain, /api/settings injection, HTML placeholder, JS wiring,
and server.py import. 1596 tests pass total.
* fix: address review feedback on PR #790
- api/updates.py: replace exec() with regex parse for api/_version.py
(no supply-chain risk from build artifact; exec unnecessary for one assignment)
- api/updates.py: cap git describe timeout at 3s (was 10s — import-time
stall on NFS/.git would block server startup unnecessarily)
- server.py: lstrip('v') → removeprefix('v') (lstrip strips chars not prefix)
- server.py: emit bare 'HermesWebUI' when version is 'unknown' rather than
'HermesWebUI/unknown' (log aggregators expect semver-ish suffix or none)
- CHANGELOG.md: add v0.50.124 entry for this user-visible change
- tests: rename exec-error test to reflect regex behaviour; add tests for
removeprefix usage and unknown-version header guard (1598 tests total)
---------
Co-authored-by: nesquena-hermes <hermes@nesquena.com>
set_hermes_default_model() calls reload_config() which resyncs _cfg_mtime,
so the mtime check inside get_available_models() never fires and the POST
response returns the stale cached default. Explicitly drop the TTL cache
after reload so the next read recomputes. Fixes the CI failure in
test_default_model_updates_hermes_config which the prior teardown-only
fix in this PR did not actually address.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cancel_stream() now pops STREAMS/CANCEL_FLAGS/AGENT_INSTANCES and clears session.active_stream_id immediately after signalling cancel. Fixes sessions permanently stuck at 409 when the agent thread is blocked in a bad tool call. Session cleanup runs outside STREAMS_LOCK to preserve lock ordering.
Fixes#653
Co-authored-by: bergeouss <bergeouss@users.noreply.github.com>
- quota_exhausted error type: distinguishes credit exhaustion from rate limits
- Streaming errors persisted to session file so they survive page reload
- _error flag excludes persisted errors from subsequent LLM API calls
- stream_end and title SSE events use original session_id (not s.session_id which rotates during context compaction)
Fixes#739, #652, #653
Removes the bubble_layout toggle from Settings, all persistence, CSS, i18n strings, and the UI docs demo. The CSS was already effectively dead. Users with a saved bubble_layout value in settings.json get a clean migration via _SETTINGS_LEGACY_DROP_KEYS.
Credit: @aronprins (PR #760 / #777)
Co-authored-by: aronprins <aronprins@users.noreply.github.com>
Removes split-brain where WebUI Settings persisted default_model separately from Hermes runtime config.yaml. New POST /api/default-model endpoint writes to config.yaml. Existing saved values migrated on first load.
Fixes#761
Co-authored-by: aronprins <aronprins@users.noreply.github.com>
Adds compact/detailed toggle for the session list sidebar. Compact is the default (no behavior change for existing users). Detailed mode shows message count and model; profile names only appear when mixing sessions across profiles.
Fixes#673
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
Squash merge of PR #717 — rebased on behalf of @franksong2702.
## What it does
Fixes#680. Footer chrome (timestamps, copy, edit, regenerate) is now hover-only for both user and assistant message rows, consistent throughout the conversation. The last assistant turn keeps cumulative usage visible at rest; timestamp and actions are revealed inline on hover in the same row.
Key changes:
- `static/ui.js`: new `_formatMessageFooterTimestamp()` (local timezone, cross-day fuller format); `timeHtml` no longer gated to user-only; last assistant usage moved from separate `.msg-usage` div to inline `.msg-usage-inline` span in the footer
- `static/style.css`: `.msg-foot-with-usage` class + rules; assistant footer opacity changed from 0.45 to 0 (hover-only); `:focus-within` alongside `:hover` for keyboard users
- `api/streaming.py`: `_restore_reasoning_metadata()` now preserves `_ts`/`timestamp` for unchanged historical messages
- `tests/test_sprint49.py`: 8 new tests covering rendering contract, hover CSS, timestamp preservation
Tests: 1518 passed. QA: 20/20. Browser verified. Reviewed and approved by @nesquena and @aronprins.
## Summary
Follow-up to #751/#752. Code review identified a case where `_normalize_session_model_in_place` could call `session.save()` (which triggers a full session index rebuild) for sessions with `model: null` or missing model field.
Root cause: `_resolve_compatible_session_model(None)` returns `(default_model, True)` when a default exists — which was interpreted as "changed, needs save." But there's nothing to correct for a session with no model; the default is just a fallback for display purposes, not a cross-provider correction worth persisting.
Fix: capture `original_model` before calling `_resolve_compatible_session_model`. Only call `session.save()` if `original_model` was non-empty and actually changed.
Adds a test asserting `save_calls == []` when `session.model is None`.
No behavior change for sessions with a real model (the primary use case of #751 is unaffected).
## Summary
Regression fix for #751.
Models with custom or unrecognized prefixes (e.g. `custom-provider/my-model`, `test/import-model`) were being incorrectly replaced with the active provider default. Root cause: `_normalize_provider_id("custom-provider")` matched the `"custom"` prefix and returned `"custom"`, which ≠ `active_provider` → normalization fired.
Two-part fix:
1. Add `"custom"` and `"openrouter"` to the `model_provider` exclusion set in `_resolve_compatible_session_model` (parallel to the existing `active_provider` guard)
2. Return `""` for unknown prefixes in `_normalize_provider_id` so the `if model_provider` truthiness check safely short-circuits
Adds a regression test covering `custom-provider/`, `test/`, `my-local-llm/`, and `lmstudio-community/` prefixes.
## Tests
1499 passed, 0 failures (was 2 failures before this fix)
## Summary
Rebased-on-behalf of @likawa3b (originally PR #748 — stale base).
Sessions can outlive provider changes. When an old session still points to a model from a previous provider (e.g. `gemini-3.1-pro-preview` after switching the agent to OpenAI Codex), starting a chat hits the wrong backend and fails silently.
This PR adds a lightweight normalization pass:
- `_normalize_provider_id()` maps common prefixes to canonical provider IDs
- `_resolve_compatible_session_model()` checks the session model's provider against `active_provider` and returns the default model if they differ
- `_normalize_session_model_in_place()` is called at GET `/api/session` — corrects and persists stale models once
- Chat start also normalizes via `_resolve_compatible_session_model()` and returns `effective_model` in the response
- `messages.js` applies `effective_model` back to the UI/localStorage/dropdown if set
Closes#748
## Tests
1498 passed (2 pre-existing ordering failures unrelated to this PR; 5 new tests added in `test_provider_mismatch.py`).
**Original author:** @likawa3b
Strips <function_calls> XML from assistant messages before rendering, adds workspace file panel empty-state messages, and changes notification description from 'tab' to 'app'. 16 new tests. Fixes#702, #703, #704.
Fixes config loading failures on Windows with non-UTF-8 default locales (GBK, Shift_JIS etc). All Path.read_text() calls in api/config.py and api/profiles.py now specify encoding='utf-8'.
The hermes_cli fast path ignored hermes_home, returning True from real system auth for OAuth providers. Removed — auth now scoped to hermes_home/auth.json only. 1423 passed, 0 failed.
MiniMax M2.7/highspeed added to _FALLBACK_MODELS. MINIMAX_API_KEY and MINIMAX_CN_API_KEY added to env scan tuple so os.environ is checked. 11 tests. Independent review by @nesquena confirmed correct, needed rebase only.
Providers in config.yaml with explicit models: list were silently ignored. Fix extends the model-list builder to check cfg.providers[pid].models, covering both dict and list formats. Also includes providers only in config.yaml (not _PROVIDER_MODELS). 5 regression tests added. Independent review by @nesquena.
DEFAULT_MODEL now defaults to "" instead of "openai/gpt-5.4-mini". Guards added in model-list builder so empty default does not create blank model entries. Adds 3 tests in test_issue646.py. Independent review by @nesquena.
Fixes <|turn|>thinking delimiter (was wrong as <|turn>thinking) in api/streaming.py, static/messages.js, and static/ui.js. Adds 13 regression tests. Independent review by @nesquena.
Independent review by @nesquena confirmed all blockers resolved. Theme×skin two-axis system replaces old monolithic color schemes. Closes#627. Co-Authored-By: aronprins <aronprins@users.noreply.github.com>
Squash-merges PR #614. Fixes Docker 500-on-every-request crash from PermissionError in load_settings() (issue #570 follow-up).
Both SETTINGS_FILE.exists() call sites now catch OSError and fall back to defaults. Reviewer nits addressed: removed unused imports/var in tests, improved log message to say "inaccessible?" instead of "permission denied?". Rebased clean onto v0.50.73. 1373 tests passing, QA harness green.