Merge remote-tracking branch 'origin/master' into feat/webui-notes-sources

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
AJV20 committed 2026-05-22 14:14:15 -04:00
commit e794b54e18
104 files changed
+11209 -582

No files matched your search

+211 -44
View File
@@ -1,3 +1,4 @@
---
# Hermes Web UI -- Changelog
## [Unreleased]
@@ -6,6 +7,212 @@
- Add a read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki, while explicitly leaving automatic session recall unchanged.
## [v0.51.114] — 2026-05-22 — Release CL (stage-407 — 1-PR — update-check recovery from remote re-tags)
### Fixed
- **PR #2758** by @nesquena-hermes — fix(updates): pass `--force` to `git fetch --tags` in `api/updates.py` so the WebUI's release-tracking update check can recover from a remote re-tag (e.g. a release tag that was force-pushed to a new commit after a squash-merge). Without `--force`, plain `git fetch origin --tags` returns `! [rejected] vX.Y.Z (would clobber existing tag)` and the entire update path (check, force-apply, normal-apply) jams indefinitely — neither the periodic check nor manual "Check now" nor the Update button can recover. Three fetch call sites were patched (`_check_repo`, `apply_force_update`, `apply_update`) to use `--tags --force`; the WebUI never pushes tags, so deferring to the remote's view is the right contract. Closes #2756.
## [v0.51.113] — 2026-05-22 — Release CK (stage-406 — 1-PR — composer model picker lag fix + hard-refresh recovery)
### Fixed
- **PR #2743** by @franksong2702 — Composer model picker now opens immediately from the existing static option list while the dynamic `/api/models` catalog hydrates in the background, instead of blocking the click on the catalog request. A just-selected session model also survives a hard refresh that interrupts the async `/api/session/update` POST: the selection is staged into `sessionStorage` (keyed by session_id, 10-minute TTL) before the async update flies, and `loadSession()` re-applies the pending pick on next session restore and retries the persistence call. Tests pin the new ordering: visible picker render before `await`, pending-state save before `await api('/api/session/update')`, and pending-state replay before the first `syncTopbar()` projects server metadata.
## [v0.51.112] — 2026-05-22 — Release CJ (stage-405 — 1-PR — session model authoritative across restore)
### Fixed
- **PR #2737** by @ai-ag2026 — Keep the session model authoritative when a restored session is reactivated. Previously, stale browser-cached picker state could override an active conversation's model in four scenarios: (1) on initial boot when `localStorage` had a different model preference than the active session, (2) on hard refresh when `S._bootReady` revealed the composer chip before the live catalog hydrated, (3) when the session's model wasn't in the current provider catalog (the static/default fallback silently rewrote `S.session.model`), (4) when starting a new session whose model wasn't in the static HTML dropdown. The fix: `loadSession()` now requests `resolve_model=1` so backend normalization happens synchronously with metadata; boot model hydration prefers the active session over `localStorage`; hard refresh re-runs the model dropdown hydration before `_bootReady`; a new `_ensureModelOptionInDropdown()` helper injects a `data-custom='1'` option for models not in the catalog instead of silently rewriting `S.session.model` to the default. 100 LOC of new pytest regression coverage pinning each behavior.
## [v0.51.111] — 2026-05-22 — Release CI (stage-404 — 1-PR — keep state.db replays out of sidecar tail)
### Fixed
- **PR #2746** by @ai-ag2026 — Prevent replayed state.db rows from being appended after an already-correct sidecar transcript tail. `merge_session_messages_append_only()` previously tried to skip state.db rows replaying the sidecar, but two edge cases leaked through: (1) the final row of a replayed sidecar prefix was not skipped because the replay index had reached the sidecar sequence length, and (2) a replayed middle segment was not considered prefix replay, so old state.db rows could be appended after the saved assistant tail. That made `/api/session` appear to end on an old user prompt even when the saved sidecar already ended on the real assistant answer. The fix tracks per-(role, content) visible-occurrence counts in the sidecar and uses that as a replay budget when comparing state.db rows; legitimate repeated messages from state.db are still preserved. `_has_visible_duplicate()` is kept as a thin wrapper around the new `_matching_visible_duplicate()` for backwards compatibility. Regression test covers both full-replay and middle-segment replay shapes.
## [v0.51.110] — 2026-05-22 — Release CH (stage-403 — 2-PR batch — default personality from config + sort configured providers to top)
### Added
- **PR #2747** by @s010mn — `new_session()` now reads `display.personality` from `config.yaml` as the default for new conversations. Previously every new session started with `personality=None` and required an explicit `/personality <name>` slash command. Values `'none'`, `'default'`, `'neutral'`, and empty string are treated as no-personality. Case-insensitive — `personality: Taleb` normalizes to `taleb`. Config-read is wrapped in try/except so malformed config falls back to the prior behavior rather than crashing session creation. The `/personality` slash command still works for per-session overrides.
- **PR #2683** by @jasonjcwu — Sort providers so configured/custom entries appear first in both the model picker dropdown (`api/config.py::get_available_models`) and the Settings providers panel (`api/providers.py::get_providers`). Priority order: (1) the active provider, (2) `custom:*` providers from `custom_providers` config, (3) providers with configured API keys (credential pool or `config.yaml`), (4) all others alphabetical. Eliminates scrolling past 25+ unconfigured providers to find the one in active use.
## [v0.51.109] — 2026-05-22 — Release CG (stage-402 — 2-PR batch — sidebar action menu click stability + chat panel sidebar resync after navigation)
### Fixed
- **PR #2741** by @ai-ag2026 — Keep the sidebar conversation actions menu open while session-list refreshes, stream updates, or panel-resync repairs arrive. Previously the three-dot menu beside chat titles could be torn down before the user finished clicking it because `renderSessionListFromCache()` rebuilt the row DOM (and the fixed-position menu's anchor) without checking whether the menu was open. The new early-return at the top of the refresh keeps the menu stable; destructive menu actions explicitly close the menu before they fire, so dismissal still works as expected.
- **PR #2736** by @ai-ag2026 — Resync the chat sidebar after returning from Settings/Logs/other panels. The session list is virtualized, and the browser can clamp the preserved scrollTop during a panel transition; without a render after the chat view is visible again, stale virtual spacer/header DOM remained until the next manual scroll. The new `_resyncChatSidebarAfterPanelSwitch()` helper runs one guarded `requestAnimationFrame` after the panel becomes visible, bails if a rename input or action menu is open, and uses no polling.
## [v0.51.108] — 2026-05-22 — Release CF (stage-401 — 4-PR batch — session-index dedup + update-check diagnostic redaction + handoff-summary sqlite connection leak fix + Slice 4d runner route gate docs)
### Added
- **PR #2744** by @Michaelyklam — #1925 RuntimeAdapter RFC follow-up: marks the `runner-local` selection seam shipped in v0.51.105 and defines the next Slice 4d supervised runner route gate before live chat can move onto a runner backend. The gate keeps runner routing default-off, preserves legacy fallback and public response shapes, and carries the Runtime API gap matrix forward so active-run discovery, session→run lookup, command metadata, artifacts, and provider/tool routing do not become private WebUI runtime replicas.
### Fixed
- **PR #2738** by @weidzhou — `_write_session_index()` full-rebuild path now deduplicates entries by `session_id`. When old-format `session_*.json` files coexist with WebUI-format `xxx.json` files sharing the same `session_id`, the index produced duplicate Vue `:key` entries and crashed the frontend with a blank page. The lazy rebuild now uses `dict[session_id → compact_entry]` keyed on session_id, with the higher `message_count` entry winning on conflict.
- **PR #2730** by @ashbuildslife — Sanitize git fetch diagnostics before returning update-check errors to the browser. New `_sanitize_git_diagnostic()` in `api/updates.py` strips credentialed URL userinfo (`user:token@host`), GitHub token shapes (`ghp_*`, `gho_*`, `github_pat_*`), and secret-looking query parameters (`?access_token=`, `?token=`, `?password=`, `?auth=`, `?key=`), then caps the message at 300 characters. Empirically verified that plain `https://github.com/owner/repo.git` URLs and SSH-style `git@host:owner/repo` remotes pass through untouched — only credentialed shapes are redacted. Update-check failure context (e.g. `Authentication failed`, network errors) is preserved.
- **PR #2742** by @Isla-Liu — Per-turn SQLite connection leak in handoff-summary path (#2233). Two functions on the `/api/session/handoff-summary` hot path were opening `sqlite3.connect(...)` inside a bare `with` statement, which commits the transaction at scope exit but does NOT close the connection. Per-turn invocations accumulated `state.db`/`state.db-wal` file descriptors and CPython heap pages on long-lived worker threads, surfacing as multi-GB VmRSS / 6× duplicated state.db fds on long-running installs. Wrapped both call sites with `contextlib.closing(...)` (already imported and used at 7 other sites in the same files) so the connection is closed deterministically: `api/models.py::count_conversation_rounds` and `api/routes.py::_persist_handoff_summary_to_state_db`. Regression test loops both functions 20× against a tmp `state.db` and asserts `/proc/<pid>/fd` count does not grow more than 2. Live soak: fd growth = 0, VmRSS growth = 0 KB across 20 POSTs.
## [v0.51.107] — 2026-05-21 — Release CE (stage-400 — 8-PR batch — pinned-sessions-limit getter rename + uploaded-file user-turn dedupe + active-run repair guard + incremental KaTeX streaming + profile default model on fresh boot + French locale completion + update-check error surfacing + release-update apply path)
### Fixed
- **PR #2718** by @eslicarrillo — Follow-up to v0.51.105's #2700: rename `_pinnedSessionsLimit()` to `_getPinnedSessionsLimit()` so the helper matches the rest of `sessions.js`'s `_get*()` naming convention for accessors. No behavior change.
- **PR #2723** by @ai-ag2026 — Deduplicate uploaded-file user turns when the optimistic browser bubble uses plain text but the server-persisted pending turn includes the `[Attached files: ...]` suffix. Previously a turn with attachments could render twice (optimistic + persisted) in the visible transcript before reconciling.
- **PR #2721** by @ai-ag2026 — During session repair on restart, treat sessions with a live in-flight run as active. The prior code path could prune restart-stale state for sessions that were actually mid-stream when the server bounced, dropping the resumable run. Now the active-stream check gates the prune so live runs survive a restart cleanly.
- **PR #2710** by @Michaelyklam — Render streamed math (KaTeX) incrementally during a stream so completed expressions render in place as their closing delimiter arrives, instead of all-at-once at the end of the turn. Eliminates the visual flash where prose-with-math sat as raw `\\[...\\]` markup until the final render pass.
- **PR #2709** by @starship-s — On a fresh boot with no persisted model in localStorage, prefer the active profile's configured default model over the static HTML option. Previously a clean install / first-load could surface the placeholder model until the user manually picked a different one, even when the profile had a default configured. Behavior note: the boot path now calls `_clearPersistedModelState()` on each load when no profile default is found, so a previously-persisted user pick is wiped on refresh — this is intentional (matches the PR's "profile default wins on fresh boot" intent and is ratified by `tests/test_model_default_boot_precedence.py`).
- **PR #2722** by @victorwhale — Complete French (`fr`) locale coverage: +93 missing translation keys covering Settings, profile-ops, gateway tile, skills modal, session controls, and i18n test surfaces. Coverage 88.8% → 96.7%.
- **PR #2717** by @ai-ag2026 — Surface update-check fetch errors in the UI instead of failing silently. The background `api/updates/check` request previously swallowed network failures, so an offline / blocked-CDN scenario showed no indication that the version banner couldn't render. Now the failure is logged and exposed to the System panel's update-status card.
- **PR #2719** by @ai-ag2026 — Apply release-update target correctly when the user clicks "Check for updates" after a prior dismissal: clears the `sessionStorage` check-once stamp and forces banner re-evaluation. The prior path silently no-op'd because the once-per-tab guard fired before the explicit user click could re-trigger the fetch.
## [v0.51.106] — 2026-05-21 — Release CD (stage-399 — 3-PR batch — restamped state.db replay dedupe + context_messages dedupe so agent doesn't see duplicates + empty _partial bloat fix)
### Fixed
- **PR #2686** by @ai-ag2026 — Prevent `/api/session` display merges from appending restamped `state.db` replay rows after the sidecar tail when those rows are already visible in the sidecar. Compressed sessions previously could appear to end on an old user prompt even though the assistant answer was persisted earlier in the transcript. The fix deduplicates by visible role+content even when timestamps drift (coarse sidecar seconds vs newer state.db floats), preserves the sidecar assistant tail across compaction-card variants and tool-metadata drift, and handles workspace-prefix user prompt variants. Regression test covers the full surface.
- **PR #2705** by @AlexeyDsov — Deduplicate replayed context messages before they reach the agent so the model no longer sees the same conversation row twice. The UI-side fix shipped in v0.51.96's #2620 corrected the display transcript but the agent still received duplicates in its context (no on-disk duplication — only at runtime in the model-facing context). Starting from the 2nd turn in any session, duplicates would cause the agent to repeat itself or list items twice. The new dedup pass runs at the WebUI/agent boundary so the runtime context is canonical regardless of upstream replay shape.
- **PR #2704** by @wirtsi — Prevent unbounded `_partial` message accumulation in session files. Two interacting bugs in `cancel_stream()` and `_message_identity()` produced multi-GB session JSON growth: (1) the `_partial_already_present` dedup check was gated on `if _stripped:`, but reasoning-only cancellations have empty stripped text so every cancel inserted a new identical empty `_partial` entry; (2) `_message_identity()` returned `None` for empty `_partial` messages so the merge layer had no way to spot the duplicate. The fix tightens both paths and adds a regression test that replays the cancel-cycle to assert bounded growth. Closes the OOM crash class reported against long-running reasoning-heavy sessions.
## [v0.51.105] — 2026-05-21 — Release CC (stage-398 — 4-PR batch — hide suggestions preference + Docker agent version from copied source + runner-local adapter selection + configurable pinned session limit)
### Added
- **PR #2687** by @Michaelyklam (closes #2679) — Settings → Preferences gains a `Hide chat suggestions` toggle (config key `hide_empty_state_suggestions`). The empty new-chat screen normally shows three suggestion buttons as first-class tap targets, which causes accidental taps on mobile. Users who don't want the suggestions can hide them via the preference; the toggle persists across sessions and reloads. Default is OFF (suggestions remain visible) so existing users see no change.
- **PR #2700** by @ai-ag2026 — Settings → Preferences gains a `Pinned conversations limit` numeric input. Builds on v0.51.96's #2614 3-cap by making the cap configurable (range 1–99, default 3, validated server-side via `_SETTINGS_INT_RANGES`). Backend validates the new cap on read, surfaces an error if a pin attempt would exceed it, and the right-click menu disables the pin item with an explanatory tooltip when the cap is reached. Default-3 keeps existing users on identical behavior.
- **PR #2696** by @Michaelyklam — RuntimeAdapter slice 4c — feature-flagged runner backend selection. The existing `HERMES_WEBUI_RUNTIME_ADAPTER` env var gains a new `runner-local` mode that wires up a `RunnerRuntimeAdapter` factory and adds a restart/reattach harness gate before the runner backend is used at the dispatcher. No user-visible change in this slice — unset / `legacy-direct` keeps existing behavior intact, and no production caller wires the new adapter yet. The slice exists so future work can land a sidecar runner without changing the runtime contract for existing users.
### Fixed
- **PR #2703** by @Michaelyklam (closes #2691) — System panel now detects the Hermes Agent version in Docker two-container deployments where the WebUI sees a copied Agent source volume instead of a live git checkout. The new detection cascade reads `VERSION` if present, falls back to the package metadata (`hermes_cli`), and finally to a `.git` describe if either is available, so the System panel reports the right version even when both `VERSION` and `.git` are absent in the copied source.
## [v0.51.104] — 2026-05-21 — Release CB (stage-397 — 9-PR batch — i18n zh-CN/zh-TW cron status + geist-contrast skin polish + tablet hardware Enter + stale Codex slash model state + SSE reconnect jitter + cron run inline expansion + inflight send race + new-chat model provider sync + virtualized sidebar scroll-clamp resync + transcript cache invalidation on same-count content)
### Fixed
- **PR #2690** by @laiaman — Correct the zh-CN and zh-Hant translations for the `cron_status_active` label so it reads "enabled / scheduled" (`已启用` / `已啟用`) instead of "running" (`运行中` / `活躍中`). The English source is "active" (enabled, scheduled), and the prior Chinese strings conflated it with the separate `cron_status_running` "currently executing" state, making both labels look identical when a job was both scheduled and not currently firing.
- **PR #2701** by @jasonjcwu — Geist-contrast skin composer polish: force `--user-bubble-text` to `#111` in light mode so typed text is black on the light input background; hide the textarea scrollbar to match the rest of the skin; recolor the send button so it reads correctly against the contrast palette.
- **PR #2706** by @dobby-d-elf — Tablet (iPad-class) devices with an attached hardware keyboard now send on Enter and newline on Shift+Enter, matching desktop behavior. The prior touch-primary check forced Enter→newline on every touch device, but tablets with hardware keyboards have a physical Shift key and should follow the desktop contract. Detection uses `matchMedia('(pointer:coarse)')` + a `window.visualViewport` height-delta probe (>120px shrink = software keyboard open) so an iPad with hardware keyboard (viewport not shrunk) treats Enter as send, while a phone tapping into the composer (soft keyboard shrinks the viewport) keeps Enter as newline. Falls back to the legacy touch behavior when `visualViewport` is unavailable.
- **PR #2684** by @ai-ag2026 — Repair stale `openai/...` slash-qualified model IDs when the active/session provider is `openai-codex`. A stale browser/localStorage selection of `openai/gpt-5` against an `openai-codex` provider previously routed the chat to OpenAI directly instead of through Codex. The cross-provider model-switch resolver now detects the mismatch and re-resolves the model to the matching `codex/...` ID before the request goes out. Explicit OpenRouter slash-qualified selections continue to fast-path through unchanged.
- **PR #2671** by @AJV20 (closes #2629 + #2661) — Session-list SSE reconnects now use bounded jitter/backoff (each retry delay is `base*0.75 + random*(base*0.35)` where `base = min(30000, 5000 * 2^attempt)`, capped at 30s) instead of a fixed 5-second retry, so tabs that all dropped at the same time (server restart, network drop) don't all retry in lockstep. Expanded cron run rows now render the full output inline immediately on click; the truncated preview remains only for collapsed rows, and the full-output fallback no longer drops content when Markdown rendering is unavailable.
- **PR #2689** by @ai-ag2026 — Preserve the optimistic in-flight message array across the `/api/chat/start` await window so a fast back-to-back send doesn't clear the user's message before the stream ID arrives. The fix snapshots the inflight entry before the await, recreates it if a sidebar/session refresh pruned it during that window, and skips stale-inflight cleanup for the submitting session until a stream ID is bound. Regression test covers the race.
- **PR #2674** by @AJV20 — Resync the new-chat model picker when the server-created session has the same model ID as the current dropdown but a different provider. New conversations now resync to the configured default model provider instead of inheriting a stale persisted picker selection (e.g. `openai/gpt-5` from a previous session). Without this, the dropdown text matched the new session's model, but the provider attribute still pointed at the stale choice.
- **PR #2688** by @ai-ag2026 — Resync the virtualized session sidebar after restoring a saved scroll position if the browser clamps or rejects that scroll position. Without this, date-group headers could render without their session rows beneath them until the user manually scrolled or a later refresh recomputed the virtual window. Regression test pins the recompute path.
- **PR #2692** by @ai-ag2026 (refs #2613) — Invalidate the transcript render cache on same-count content changes, not just on count changes. The prior cache key was `(message_count, render_window_size)`, which silently reused a cached transcript whenever a same-count edit produced visibly different content (e.g. a tool retry that replaces a single assistant message with corrected text). The new cache signature folds a content hash into the key so any visible change forces a fresh render. Regression test asserts cache-bust on same-count content swap.
## [v0.51.103] — 2026-05-21 — Release CA (stage-396 — 1-PR follow-on — Settings → Plugins distinguishes exclusive/provider activation)
### Fixed
- **PR #2663** by @Fail-Safe (closes #2659) — Settings → Plugins panel now distinguishes exclusive plugins (memory providers, web backends, browser providers activated via `<category>.provider` config) from disabled-or-broken plugins. The `/api/plugins` payload gains `kind` + `activation` fields; cards render a new "Active (provider)" badge variant for exclusive activation instead of mislabeling these plugins as "Disabled / No registered lifecycle hooks". Purely additive — for users without an exclusive provider configured, the panel renders "Enabled" and "Disabled" badges exactly as before. The legacy `enabled` boolean is preserved on the payload for back-compat with older WebUI clients; new clients read `activation` first with a fallback. 3 new behavioral tests cover the exclusive, model-provider, and standalone code paths.
## [v0.51.102] — 2026-05-21 — Release BZ (stage-395 — 1-PR follow-on — capped CLI sidebar candidate window now keyed on last-activity not start time)
### Fixed
- **PR #2662** by @Michaelyklam (closes #2656) — Capped CLI/agent sidebar scans now order the candidate CTE by `COALESCE(MAX(messages.timestamp), s.started_at)` instead of `s.started_at` alone. Long-lived CLI sessions that were resumed days later (old `started_at`, recent message activity) stay visible in the candidate window instead of falling outside the 8×limit oversample. Closes the regression I filed against v0.51.99's #2647 sidebar candidate-window narrowing. New regression test creates an old session with a recent message timestamp and asserts it surfaces at the top.
## [v0.51.101] — 2026-05-20 — Release BY (stage-394 — 2-PR deep-review batch — workspace Git backend + sidebar tab visibility toggle)
### Added
- **PR #2625** by @stocky789 — Add backend Git operations for the workspace panel. New `api/workspace_git.py` module exposes read-only ops (`/api/git/status`, `/api/git/branches`, `/api/git/diff`, `/api/git/commit-message[-selected]`) unconditionally and mutating ops (`stage`, `unstage`, `discard`, `commit`, `commit-selected`, `checkout`, `stash-checkout`, `pull`, `push`) only when `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1` is set in the environment — default OFF so existing deployments are unaffected. All subprocess calls use `["git", *args]` with `shell=False`, all branch/ref names go through `git check-ref-format --branch` validation before flowing to `git switch -c`, and `subprocess.env` is scrubbed of `GIT_DIR`/`GIT_WORK_TREE`/`GIT_CONFIG_GLOBAL`/`GIT_CONFIG_SYSTEM`/`GIT_CONFIG_COUNT`/`GIT_CONFIG_PARAMETERS` plus the full `GIT_CONFIG_KEY_*`/`GIT_CONFIG_VALUE_*` namespace before every invocation. `GIT_INDEX_FILE` is intentionally preserved to drive selected-file commits through a private temporary index. Paths are bound to the workspace root via `safe_resolve_ws()` + `Path.relative_to()` enforcement (rejects `..` traversal and symlinked escapes); active-stream gate prevents mutations during a running agent turn. Documented in `docs/workspace-git.md` with the full trust model (hooks-as-RCE warning, default-allowed vs gated lists, env-scrub enumeration). Frontend UI ships in a follow-up PR.
- **PR #2636** by @FrancescoFarinola — Per-tab sidebar visibility toggle in Settings → Appearance. Power users can hide unused rail tabs (Tasks, Kanban, Skills, Memory, Spaces, Profiles, Todos, Insights, Logs) while keeping Chat and Settings always reachable. Settings is per-profile so each profile can have its own hidden-tabs preference; an inline `<script>` in `<head>` applies `nav-tab-hidden` from `localStorage` before first paint so toggled-off tabs don't flash visible on reload. Default off — no tabs are hidden out of the box; existing deployments are unaffected. Chips use `role="switch"` + `aria-checked` for clear screen-reader narration, and the container has `role="group"` + `aria-labelledby` pointing at its label. Backend validator strips `chat` and `settings` from `hidden_tabs` at save time as a belt-and-suspenders against tampered POSTs. Profile switch reconciliation: `_refreshProfileSwitchBackground` re-fetches `/api/settings` and re-applies `hidden_tabs` after a profile change so the new profile's preference takes effect immediately.
### Maintainer additions during stage
- `_refreshProfileSwitchBackground` profile-switch reconciliation for #2636 (Profile A's hidden-tabs no longer bleeds into Profile B until Settings is opened).
- `role="switch"` + `aria-checked` chip a11y for #2636 (was `aria-pressed` — confusing polarity for users where chip-off looks like the off state).
- Server-side `hidden_tabs` validator strip of `chat`/`settings` for #2636.
- CSS contrast fix for #2636 — `color: #1a1a1a` + `font-weight: 600` on filled chips (was `color: var(--bg-page)` which resolved to white in dark theme and was barely readable on the gold accent).
- 3 new regression tests for the #2636 maintainer additions (profile-switch wiring, chat/settings server-side strip, a11y switch role).
### UX approval
PR #2636 went through the full multi-viewport screenshot gate (390 mobile, 1280 laptop, 1440 desktop, 1920 wide; both light and dark themes; default-on and 3-off mixed states; rail-effect proof showing hidden tabs collapse cleanly). Approved via Telegram for merge.
## [v0.51.100] — 2026-05-20 — Release BX (stage-393 — 3-PR deep-review batch — lazy journal recovery retry + faster profile-switch + cross-tab session list SSE sync)
### Fixed
- **PR #2615** by @Isla-Liu — Self-heal stalled interrupted-turn markers by lazily retrying run-journal recovery on subsequent sidebar/full-load reads. When `_apply_core_sync_or_error_marker` lands in the "interrupted, no journal yet" branch, the session now carries `_pending_journal_recovery` metadata so the next `get_session()` call re-runs `_append_journaled_partial_output(..., dedupe_existing=True)` if a fresh journal has materialized in the meantime. Bounded by three independent ceilings: `_JOURNAL_RETRY_MAX_ATTEMPTS = 12` (attempts on sealed-but-unrecoverable journals), `_JOURNAL_RETRY_GIVEUP_SECONDS = 24 * 3600` (wall-clock cap that fires even when the journal never materializes), and an explicit "no stream id → demote immediately" branch. Per-session `threading.Lock` (non-blocking acquire) guards concurrent `get_session()` callers; second caller no-ops to `False`. Closes a real WSL2/network-FS class of bug where the recovery marker stuck on a transiently-missing journal and never self-healed without a server restart.
### Changed
- **PR #2633** by @dobby-d-elf — Make agent profile switching and initial app boot dramatically faster (~25× on the contributor's benchmark). Switch-time: applies the selected profile's defaults immediately (resolved via a per-profile `_profile_default_model_state(profile)` config read), refreshes the visible/current screen, schedules non-visible workspace/model metadata in the background after the current view settles. A `_profileSwitchGeneration` counter is checked at five `await` boundaries to abort stale switches when the user re-toggles mid-refresh. Boot-time: workspace and onboarding fetches run in parallel instead of serial; onboarding short-circuits with `Promise.resolve(false)` when `onboarding_completed` is true. Boot synthetic model options now carry `data-provider` metadata so a pre-catalog `/api/chat/start` doesn't fuzzy-fallback to the wrong provider. FLIP animation capture/play is index-pinned by a new positional test; `prefers-reduced-motion` is honored in both JS and CSS. Server-side `new_session()` resolves the requesting profile's `config.yaml` directly rather than the process-global, preventing cross-tagging when the URL omits `model`/`model_provider`.
### Added
- **PR #2637** by @dobby-d-elf — Push lightweight session-list invalidation events to connected browsers via a new in-process pub/sub bus (`api/session_events.py`) and SSE endpoint (`/api/sessions/events`). Triggered on every visible-session mutation: new (only after worktree creation or first-message reveal), delete, archive, move, pin, rename, duplicate, import, and cron-job completion. Per-subscriber `Queue(maxsize=1)` with latest-wins drain — under burst, browsers see the freshest payload, not a backlog. Disconnect cleanup runs in the handler's `finally:` block, scoped to the shared `_CLIENT_DISCONNECT_ERRORS` tuple so socket-failure modes beyond `BrokenPipeError` also trigger unsubscribe. Cron-scheduler integration uses a `_cron_profile_context_depth()` gate to avoid double-publish across manual vs scheduled cron run paths. **Follow-on issue filed:** the bus has no profile identity in its payload — a cron tick in profile A wakes the SSE handler for every browser regardless of which profile its session cookie selects, costing wasted `/api/sessions` round-trips on other-profile tabs (no data leak; the GET is profile-scoped). Filing tracker for a follow-up that snapshots the active profile at publish time.
### Test infrastructure
- HTTP integration tests for `/api/sessions/events`: handshake (200 + `text/event-stream`), event delivery driven by a side-effect POST, rapid open/close burst that survives a subsequent `/api/sessions` GET, and a source-level guard that the handler's `except _CLIENT_DISCONNECT_ERRORS:` clause stays wired to the shared tuple. Added as stage-393 follow-up to Opus advisor's blocking note on PR #2637 (the existing tests covered the in-process bus but never opened a real HTTP connection).
## [v0.51.99] — 2026-05-20 — Release BW (stage-392 — 5-PR batch — compact tool activity grouping + CLI sidebar scan cap + title-generation API key forwarding + post-compression replay dedup + clarify popup stability)
### Fixed
- **PR #2638** by @dobby-d-elf — Keep compact tool activity grouped under a single Activity disclosure within an assistant turn. The compact renderer was splitting Thinking and interim tool activity into multiple visible fragments inside the same turn. Composes cleanly with v0.51.96's #2620 (second-level dedup key) — the bundled `merge_session_messages_append_only()` change now only adds to `seen_message_keys` when the key is an authoritative `message_id`-prefixed key, preserving the v0.51.96 invariant that two distinct state-only same-second rows must remain visible.
- **PR #2647** by @Michaelyklam (closes #2628) — Cap the CLI/agent session sidebar bridge to a recent-candidate window before joining message rows. Previously `show_cli_sessions` ran a `LEFT JOIN messages ... GROUP BY s.id` across the entire Hermes `state.db`, aggregating 100k+ message rows before applying the visible sidebar cap. Large installs paid that cost on every sidebar read. The capped path now selects an oversampled candidate set (8× the visible limit) ordered by `started_at DESC`, then runs the message aggregate inside that window. Uncapped callers (full scans, exports) are unchanged.
- **PR #2650** by @starship-s — Forward the configured `auxiliary.title_generation.api_key` for config-derived title routes. Completes #2612 / v0.51.96 — the prior PR routed provider/model/base_url through but the API key was left to fall back to the chat client's key, which doesn't match the Hermes Agent task-config shape and silently failed for setups where the title model lives behind a different account. The new `caller_supplied_route` guard prevents leakage of the title-generation key to caller-supplied (active-agent fallback) routes.
- **PR #2651** by @LumenYoung (refs #1217) — Dedupe replayed active-context tails before appending agent result deltas to the WebUI display transcript, and apply the same replay protection to persisted `context_messages`. Without this, post-compression continuation re-fed an already-present tail into the next model turn, inflating the model-facing context and bloating the visible transcript with duplicate assistant cards.
- **PR #2643** by @arshkumarsingh (closes #2639) — Require a stable `clarify_id` and wait for the backend ack before hiding the WebUI "Clarification needed" popup. Three bugs conspired to cause stale clarifications to silently fail: (a) `_ClarifyEntry` had no unique identifier so the frontend couldn't reference a specific pending prompt; (b) the backend used FIFO resolution which silently dropped late/stale responses; (c) the frontend hid the popup before the POST returned, so users saw a successful submit while the agent fell back to its best-judgement timeout path. Server-side `clarify_id` is now generated in `_ClarifyEntry.__init__` (UUID-based), propagated through SSE/poll payloads, sent back by the browser, and matched via `resolve_clarify_by_id()`. The popup stays visible until the POST returns; a 409/`stale:true` response keeps the draft and shows a toast. The legacy `not bool(clarify_id)` quirk that always returned `ok:true` is gone.
## [v0.51.98] — 2026-05-20 — Release BV (stage-391 — 1-PR follow-on — custom_providers allowlist priority over live /v1/models)
### Fixed
- **PR #2640** by @colin-chang — When a `custom_providers` entry in `config.yaml` declares a curated `models:` allowlist (e.g. a ZenMux or other aggregator gateway), respect the curated list instead of also fetching the live `/v1/models` catalog. Without this guard the picker rendered hundreds of online models alongside the user's curated three, swamping the intended selection. The allowlist guard skips the live probe entirely; the existing fall-through to a live probe still runs when no `models:` list is configured. Composes cleanly with #2626 / v0.51.96 — when the live probe is skipped, no `models_endpoint_error` is surfaced (the curated list is the source of truth and probe failures should not show as a user-facing diagnostic in that case).
## [v0.51.97] — 2026-05-20 — Release BU (stage-390 — 2-PR batch — startup session-index rebuild + config-managed custom-provider cards)
### Fixed
- **PR #2642** by @dso2ng — Rebuild the WebUI session index during startup recovery when `_index.json` is missing, even when no `.bak` session restore occurs. Previously the rebuild only ran after a `.bak` restore path, leaving large state directories on a repeated full-scan `/api/sessions` fallback after an index loss. Startup recovery now unconditionally calls `_write_session_index()` when no `_index.json` is present on disk, restoring the O(1) sidebar read path.
### Added
- **PR #2634** by @Michaelyklam (closes #2632) — Show `custom_providers` entries created by `hermes model` (CLI) in Settings → Providers as read-only config-managed provider cards, including their configured models and key status, instead of filtering them out because they are not WebUI-editable API-key providers. The new card variant displays configured models, key-env status (set/unset), and a "config-managed" badge that links to the CLI as the canonical edit surface.
## [v0.51.96] — 2026-05-20 — Release BT (stage-389 — 8-PR batch — IPv6 dashboard link normalization + configured title-generation provider routing + sidebar pinned-session 3-cap + external-refresh sidecar count preference + Hermes overview docs relocation + legacy dedup timestamp granularity + custom provider /models endpoint error surfacing + RuntimeAdapter Slice 4c harness gate RFC)
### Fixed
- **PR #2610** by @AJV20 — Preserve square brackets around IPv6 hosts when normalizing browser-only dashboard URLs, so links like `http://[::1]:9119` remain valid after saving instead of being mangled into invalid IPv6 forms. Closes the regression introduced by the URL-sanitization path added in #2533 / v0.51.95 — bracketed IPv6 hosts now round-trip through the dashboard-link save flow unchanged.
- **PR #2612** by @AJV20 — Route WebUI session title generation through the configured `auxiliary.title_generation` provider, model, and base URL when present in config, instead of leaving the auxiliary client to silently fall back to the chat model. Users who configure a smaller/cheaper model for title generation (e.g. a fast 8B model on a separate provider) now have that selection honored end-to-end.
- **PR #2618** by @LumenYoung — Prefer the persisted sidecar `message_count` over the session-index stored count during external-refresh polling. The metadata-only `/api/session?messages=0` path now reads `Session._metadata_message_count` when sidecar data is available, so legacy sessions whose state.db retains old rows still trip the external-refresh signal correctly on sidecar updates. Composes cleanly with #2604 (the legacy-fallback only applies when the reconciled merged count is zero).
- **PR #2620** by @bengdan — Use second-level timestamp granularity in the legacy message-dedup key. Drops the microsecond fallback in `_normalized_message_timestamp_for_dedup_key()` so transcripts that encode timestamps at different sub-second precisions (e.g. `"10.0"` vs `10.000000`) collapse to the same dedup bucket. Retroactively de-duplicates the dominant failure mode in #2616 without requiring an on-disk session rewrite.
- **PR #2626** by @Michaelyklam (closes #2540) — Surface named custom-provider `/models` endpoint failures in the model picker instead of silently showing an empty provider group. `_read_custom_endpoint_models` now returns `(models, error)`, so auth/network/HTTP failures propagate as structured `models_endpoint_error` hints on `/api/models` per affected provider. The composer model picker renders the hint as a quiet disabled-option diagnostic; configured fallback models remain selectable. 124 LOC of new regression coverage spans 401/network-error/5xx failure modes plus frontend hook validation.
### Added
- **PR #2614** by @Michaelyklam (refs #2508) — Cap sidebar-active pinned sessions at three. Right-clicking a conversation row opens the existing action menu, attempted pins beyond the cap render the menu item as disabled with an explanatory tooltip, and the backend rejects a fourth pin attempt with a structured error so the optimistic frontend can roll back the click. Settles the open question from #2508 on whether pin count is bounded — the answer is three, configurable in a future PR if user demand surfaces.
### Documentation
- **PR #2619** by @Michaelyklam (closes #2595) — Move the long human-facing Hermes comparison document from root `HERMES.md` to `docs/why-hermes.md` so Hermes Agent sessions opened in this repository load `AGENTS.md` as the project-specific assistant guidance instead of the marketing overview. README links now point to the new docs path and a regression test (`tests/test_agent_context_docs.py`) prevents root `HERMES.md` / `.hermes.md` context files from silently reappearing.
- **PR #2627** by @Michaelyklam (refs #1925) — Advance the RuntimeAdapter RFC after the Slice 4b `RunnerRuntimeAdapter` facade shipped in v0.51.94. The RFC now defines the next Slice 4c runner-backend harness gate: feature-flagged runner backend selection, explicit start payload validation, durable status/event observation across WebUI adapter recreation, bounded controls, and a deterministic harness for proving the facade's protocol-translation invariants without requiring the future runner/sidecar to exist.
## [v0.51.95] — 2026-05-20 — Release BS (stage-388 — 5-PR batch — live tool callback event dedup + browser-only dashboard links + messaging transcript merge alignment + Geist Contrast skin + SSE runtime diagnostics)
### Fixed
- **PR #2598** by @AJV20 — Surface live tool activity when Hermes Agent reports tools through its dedicated `tool_start_callback` / `tool_complete_callback` path, so browser chat shows the existing running tool cards instead of appearing idle until the final answer. The legacy `on_tool` callback path now early-returns for `tool.started` and `tool.completed` events when the structured callback path is already wired, preventing the same tool event from being emitted twice to the SSE stream.
- **PR #2533** by @AJV20 — Allow Settings → System to save public browser-only Official Hermes Dashboard links (for reverse-proxy URLs) without treating them as server-side probe targets. URL sanitization runs against the configured link before save; the dashboard probe is skipped for browser-only links.
- **PR #2607** by @AJV20 — Deduplicate messaging/CLI session transcript rows when the sidecar and state store encode the same no-id message with equivalent timestamps in different formats (e.g. `"10.0"` vs `10`), preventing repeated visible chat rows after session reconstruction. The messaging-display merge now reuses `api.models._session_message_merge_key(...)` instead of an ad-hoc dedup key, aligning with the existing append-only merge path.
### Added
- **PR #2521** by @intellectronica — Add the Geist Contrast skin to the appearance picker. New light + dark variant pair with a high-contrast yellow-on-black accent and Geist editorial typography. Default unchanged — opt-in via Settings → Appearance → Skin → Geist Contrast. Slash command `/theme geist-contrast` now resolves correctly because the lookup matches against `skin.value` rather than `skin.name`. Documented in `THEMES.md` with a forward-compatible skin count (no hard-coded value).
- **PR #2524** by @AJV20 — Add non-sensitive SSE stream runtime diagnostics to deep health checks (`/health?deep=1`), including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Read-only telemetry; existing surfaces unchanged.
## [v0.51.94] — 2026-05-19 — Release BR (stage-387 — 10-PR full sweep batch — Slice 4b runner adapter facade + folder zip download + partial recovery marker dedupe + browser api() client-side timeout + auto-compression card rotation finish + composer draft rollback fix + metadata count reconciliation + active-session refresh on external sidecar updates + indexed context metadata + gateway-queues approval peek)
### Fixed
@@ -37,7 +244,6 @@
- **PR #2587** by @AJV20 — Allow a still-running stream that was mistakenly marked interrupted by stale-pending recovery to replace its own recovery marker when it later finishes, while continuing to block stale writeback after any newer turn appends transcript content. Three new tests in `tests/test_session_sidecar_repair.py` cover the supersede-allowed and the two refuse cases.
- **PR #2588** by @Michaelyklam (refs #2569) — Preserve the configured provider when choosing a configured model from the composer picker. `_getOptionProviderId()` now reads `data-provider` from temporary `<option data-custom="1">` rows (created by `selectModelFromDropdown` for configured models outside the native catalog), so the next send routes through the correct provider instead of falling back to whatever provider was already active.
### Changed
- **PR #2581** by @LumenYoung (refs #2194) — First recovery slice from the closed reconciliation PR #2194. Routes streaming session reconstruction and sidebar metadata through the reconciled state.db/session-summary path with a metadata-only fast path for sidebar polls and a single-snapshot reuse on the streaming hot path. Includes the reviewer-requested `_new_turn_context_from_messages` extraction so both legacy and streaming paths share the `_drop_checkpointed_current_user_from_context` + casual-fresh-chat suppression behavior (refs #1217 / #2308). 923 LOC across `api/models.py`, `api/routes.py`, `api/streaming.py`, `static/sessions.js` + four new test files; second-pass agent diff review LGTM after the streaming-path regression was caught and fixed.
@@ -97,6 +303,10 @@
- **PR #2511** by @franksong2702 (refs #2502 / #2503) — Update the `docs/ui-ux/` demo appearance controls to initialize as `class="dark" data-skin="slate"` instead of the deprecated `data-theme`-only buttons and legacy theme names. Brings the demo pages in line with the live Theme + Skin contract referenced from the new `docs/CONTRACTS.md` so contributors following the contract-index path don't land on stale demos.
- **PR #2509** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 3b approval/clarify seam shipped in v0.51.89. The RFC now marks Slice 3b as shipped and defines the next Slice 3c queue/continue + goal control gate: route those controls through `RuntimeAdapter.queue_message(...)` / `update_goal(...)` only after pinning stable response contracts, bounded unavailable-control behavior, replayable lifecycle/status evidence, ordering/idempotency expectations, and explicit non-goals for runner/sidecar ownership or a WebUI-owned queue/goal scheduler. Docs + adapter-seam regression test only — no runtime/control routing changes in this PR.
### Added
- **Geist Contrast skin** — Add a new Geist-inspired `geist-contrast` skin with neutral monochrome surfaces, restrained selected/sidebar states, and dark-mode `#FFF175` primary accents with black foreground on solid accent controls.
## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index)
### Changed
@@ -153,7 +363,6 @@
- **PR #2490** by @nesquena-hermes — Add a Docker runtime smoke gate (`.github/workflows/docker-smoke.yml`) triggered on PRs and pushes to `master` that modify `Dockerfile`, `docker_init.bash`, `docker-compose*.yml`, `.dockerignore`, or `.env.docker.example`. Validates every compose file parses (`docker compose config`), then matrix-runs the single, two-container, and three-container variants end-to-end: rebuilds the local `Dockerfile` and re-tags it as `ghcr.io/nesquena/hermes-webui:latest` so the multi-container variants exercise PR-level changes rather than the previously-released registry image, `docker compose up -d --wait`s with a 120s health window, probes `/health`, and greps startup logs for known-bad signatures (`EROFS`, `Traceback`, `PermissionError`, `error_exit`, `!! ERROR`, `!! Exiting script`, `groupmod: cannot`, `usermod: cannot`, `Failed to set`). Closes the source-only-test gap that let v0.51.84's `:ro`-mount × `chown -h ... {} +` startup regression reach review with 5800+ green pytests. Workflow runs with `permissions: contents: read`, uses per-run project names and a pre-flight orphan reaper for safe concurrency, and unconditionally tears down all volumes/networks in an `EXIT` trap. Two new source-level invariants in `tests/test_docker_docs_and_readonly.py` pin the staging path so the underlying `:ro`-incompatible call doesn't regress.
## [v0.51.86] — 2026-05-17 — Release BJ (stage-379 — 4-PR review-bypass batch — memory-provider session lifecycle + cross-provider /model alias + RuntimeAdapter cancel seam + Fork-from-here messaging coord)
### Fixed
@@ -166,7 +375,6 @@
- **PR #2479** by @Michaelyklam (refs #1925) — Route Stop Generation through the default-off `RuntimeAdapter.cancel_run(...)` seam when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled. Implements the first code slice of the Slice 3a cancel-control gate accepted in #2469 / v0.51.85. The default `legacy-direct` path still calls `cancel_stream(...)` directly; the adapter branch preserves the existing `{ok, cancelled, stream_id}` JSON response contract. No new cancellation registry, runner, sidecar, approval/clarify, queue/goal, or cached-agent state is introduced — adapter remains a pure protocol translator.
## [v0.51.85] — 2026-05-17 — Release BI (stage-378 — 3-PR batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC)
### Fixed
@@ -423,7 +631,6 @@
### Added
- **PR #2319** by @Michaelyklam — Chat file uploads now land in a session-scoped attachment inbox instead of cluttering the active workspace root. By default uploads are stored under `~/.hermes/webui/attachments/<session_id>/`; operators can override the root with `HERMES_WEBUI_ATTACHMENT_DIR`, and the agent still receives the absolute uploaded file path for context. Archive extraction stays workspace-scoped (it's an explicit workspace operation). README updated to document the new default location. **Stage-361 maintainer fix applied inline**: Opus advisor caught that `_build_native_multimodal_message` at `api/streaming.py:787` required uploads to be under `workspace_root`, which would have silently dropped every image upload for vision-capable models once the inbox moved outside the workspace. The fix adds `_attachment_root()` (from `api/upload.py`) as a second allowed location, with 3 regression tests covering the new code path AND verifying the original workspace + cross-root rejection paths still work.
### Fixed
@@ -450,7 +657,6 @@
### Added
- **PR #2287** by @mslovy (refs #2284) — Upload size limit is now runtime-configurable via the `HERMES_WEBUI_MAX_UPLOAD_MB` environment variable. Previously the effective 20 MB cap was hard-coded across multiple layers. Server-side upload limit moves to runtime config; browser-side preflight check stays aligned with the effective backend limit; archive extraction guard continues to scale with the same configured cap. New `_env_mb_bytes()` helper in `api/config.py` parses `HERMES_WEBUI_MAX_UPLOAD_MB`.
- **PR #2291** by @linuxid10t — New "Nous Research" skin option in the Settings → Appearance picker, inspired by [nousresearch.com](https://nousresearch.com). Monospace typography, steel blue (#4682B4) accent, cool gray text, sharp 1-2px corners, thin dashed borders, technical aesthetic.
@@ -461,7 +667,6 @@
### Fixed
- **PR #2275** by @ai-ag2026 — CLI/messaging continuation sessions (sessions stitched from a `parent_session_id` chain) now return their full transcript instead of an empty list. Pre-fix, `get_cli_session_messages()` called `_is_continuation_session()` while walking the parent chain, but `api/models.py` didn't import that helper. The exception was swallowed by `except Exception: return []`, so valid external sessions could fall through silently. Adds regression coverage that a stitched continuation chain returns a non-empty transcript.
- **PR #2277** by @eleboucher — Rootless container runtimes (k8s `runAsNonRoot: true`, OpenShift restricted SCC, `docker --user`, rootless Podman) no longer hit a cascade of permission errors at startup. Pre-fix, the rootless branch skipped the root init phase entirely, but root init also did rsync, `/uv_cache` permissions, `~/hermeswebui` home directory creation, and `/workspace` writability. `docker_init.bash` now distinguishes "no root init available" from "root init available but skipped", running the work that doesn't need root in the rootless branch too.
@@ -792,7 +997,6 @@
- **PR #2116** by @starship-s — OpenAI Codex provider quota card no longer reports "unavailable" when Codex chat requests actually work. Runtime requests authenticate via the modern `agent.credential_pool`, but the account-usage probe only tried the legacy singleton Codex token path. Adds a Codex-only credential-pool fallback inside the existing isolated `_account_usage_subprocess`: when `agent.account_usage.fetch_account_usage()` returns no available snapshot, the fallback selects the active `openai-codex` credential-pool entry, derives the Codex usage endpoint from the runtime base URL (handles `/backend-api/codex` → `/wham/usage` and custom bases → `/api/codex/usage`), and serializes the existing snapshot shape expected by the WebUI. Stays inside the child process so active Hermes profile context remains isolated; legacy unavailable diagnostics preserved when the pool fallback can't produce a usable result; non-Codex providers unchanged. Returns only quota display data — never credential labels, access tokens, or raw exception strings. 151-line regression suite covers the success path, both URL-resolution branches, and the unavailable-fallthrough case.
### Stage-342 maintainer fixes
- **`api/worktrees.py:_run_git` default timeout 5s → 2s** — Opus SHOULD-FIX from stage-342 review: PR #2109's new `/api/session/worktree/status` endpoint runs up to four `git` subprocess calls per request, each defaulting to a 5-second timeout. Worst case 20 seconds per polling request piling up on the `ThreadingHTTPServer` thread pool is risky given today's `_cron_env_lock` near-miss on production 8787. Status probes should fail fast — a worktree that takes longer than 2 seconds to enumerate is already in trouble, and the client can retry. Mechanical 1-LOC default-arg change; all four call sites already pass `cwd` positionally and rely on the default. ~1 LOC.
@@ -817,7 +1021,6 @@
- **`api/streaming.py` short-circuit observability** — Opus SHOULD-FIX from stage-341 review: PR #2107's new `_title_should_skip_remaining_attempts` short-circuit `break` was silent in both the aux and agent title-generation paths. Added a `logger.debug` call before each `break` so production logs surface why the prompt-iteration loop exited early (nesquena flagged this as non-blocking; landed as polish in the same release). Also expanded the function's docstring to document the membership criterion explicitly so future additions (`llm_safety_blocked`, `llm_oauth_quota`, etc.) have a clear inclusion test. ~16 LOC.
## [v0.51.47] — 2026-05-11 — Release W (4-PR contributor batch — per-cron toast toggle + Italian locale + stale-gateway agent-health fix + CI/console hygiene)
### Added
@@ -898,7 +1101,6 @@
- **`api/providers.py:1049`** — Custom provider entries that slugify to an empty string were silently dropped, which made misconfigurations hard to diagnose. `logger.warning()` now surfaces the bad config entry. ~4 LOC; pure observability change.
## [v0.51.44] — 2026-05-11 — Release T (5-PR contributor batch — security + worktree sessions + LM Studio + onboarding docs + transcript dedup, plus comprehensive test-suite network isolation)
### Added
@@ -1025,7 +1227,6 @@
- Test isolation: at least one test in `test_update_banner_fixes.py` or `test_updates.py` triggers a real `os.execv` that re-executes the entire pytest suite. Suite still passes (~5108 each loop) but full run takes 4× the time. Worth a targeted fix in the next maintenance batch.
## [v0.51.40] — 2026-05-11 — Release P (4-PR contributor batch — quota subprocess hardening + env-lock prewarm + cron one-shot warning + Xiaomi env key)
### Fixed
@@ -1332,7 +1533,6 @@ Three duplicate-PR clusters consolidated to one canonical PR each, with `Co-auth
- **Race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded`** — with both opt-ins ON, an in-flight prefetch (started by 1.5x-viewport trigger) racing with `jumpToSessionStart` → `_ensureAllMessagesLoaded` could produce duplicate messages if the prefetch resolves last. Narrow window, but the fix is to gate `_ensureAllMessagesLoaded` on the existing `_loadingOlder` flag. **Resolved in Unreleased — see #1937 entry above; final fix uses generation-token + mutex rather than the originally-suggested flag gate, which would not have closed the race.**
- **#1928 locale parity** — `session_jump_*` and `settings_*_session_jump_buttons` keys are English literals in ja/ru/es/de/zh/zh-Hant/pt/ko. Default-OFF + English fallback works, but breaks the locale-parity standard set by #1929 and #1891 in the same release.
### Added (1 PR)
- **PR #1919** by @franksong2702 — Persist login rate limit attempts (closes #1910). Stores failed-login buckets in `STATE_DIR/.login_attempts.json` instead of in-process memory, so password-auth deployments keep the same failed-attempt window across restarts. Atomic temp+rename writes, `0600` permissions, prunes expired entries on load. If the file is missing, malformed, or unwritable, the auth path falls back to current in-memory behavior with debug-level logging — no infinite-loop risk.
@@ -1363,7 +1563,6 @@ Three duplicate-PR clusters consolidated to one canonical PR each, with `Co-auth
- v0.51.28 carry-overs verified preserved (no in-batch changes to `api/routes.py:_strip_workspace_prefix`, `api/streaming.py:evaluate_goal_after_turn`, `api/profiles.py:_profiles_match`, `tests/test_mcp_server.py` module-restoration logic).
- Pre-stamp re-fetch of all 6 PR heads: no contributor force-push during Opus window.
### Added (2 PRs)
- **PR #1895** by @samuelgudi — MCP server Option A rewrite (#1616). Replaces the fragile MCP integration with a clean `mcp_server.py` (567 LOC) implementing project CRUD, session listing, and session mutations (rename/move) over Hermes's HTTP API. Imports `api.models` / `api.profiles` canonically rather than carrying duplicate slug-matching helpers. Relocates `_profiles_match` from `api/routes.py` into `api/profiles.py` as the single source of truth (mcp_server.py and api/routes.py both now import the canonical helper — re-introducing a local copy in either module trips a parity test immediately). Adds env-aware WEBUI_URL (`HERMES_WEBUI_HOST` / `HERMES_WEBUI_PORT`). New behaviour: `delete_project` REFUSES to touch session JSONs when `HERMES_WEBUI_PASSWORD` is unset, returning `{ok:true, unassigned_sessions:0, warning:"…"}` instead — preventing data-loss when an MCP client tries to delete a project on an unauthenticated server. 53-test coverage in `tests/test_mcp_server.py` (914 LOC) including HTTP wire-format integration tests, profile-scoped isolation, legacy untagged row visibility, and `--profile foo` CLI ordering regression. Closes #1616.
@@ -1395,7 +1594,6 @@ Three duplicate-PR clusters consolidated to one canonical PR each, with `Co-auth
- **Goal hook fires on unrelated turns** — while a goal is `active`, every completed assistant turn runs `evaluate_goal_after_turn` and ticks `state.turns_used += 1`, even on user messages unrelated to the goal. UX surprise but not bug-broken; consider gating on `user_initiated` or a goal-context flag.
- **English-only runtime strings in goal UI** — `messages.js:889` ("Evaluating goal progress…"), `commands.js:651` ("Working toward goal…"), `messages.js:914` ("Continuing toward goal…" toast); also backend strings in `goals.py` (`status_line`, "⊙ Goal set …", "⏸ Goal paused …", "↻ Continuing …"). The `cmd_goal` autocomplete description IS localized across all 9 locales — only the runtime status strings are missed.
### Fixed (4 PRs)
- **PR #1916** by @Michaelyklam — Make Kanban detail view scrollable. The app shell sets `body { overflow: hidden }`, so the Kanban main view must own vertical scrolling. Pre-fix, a selected task with a long body could push the board below the viewport with no way to reach it. Fix: add `overflow-y: auto` to `main.main.showing-kanban > #mainKanban` (one CSS property + regression test). Closes #1915.
@@ -2116,7 +2314,6 @@ Closes #1362, #1731.
Closes #1714, #1715, #1716.
## [v0.51.5] — 2026-05-05 — 4-PR full-sweep batch
### Added
@@ -2157,7 +2354,6 @@ Closes #1714, #1715, #1716.
Closes #693, #1690, #1710.
## [v0.51.5] — 2026-05-05 — single-PR hotfix (#1707)
### Fixed
@@ -2198,7 +2394,6 @@ Closes #693, #1690, #1710.
@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
@@ -2244,7 +2439,6 @@ PRs #1671, #1673, #1676 had been UX-gated in the v0.51.1 sweep, then on second-l
UX label cleared, Aron stand-down comments deleted on all 3, all 3 swept into this batch.
## [v0.51.2] — 2026-05-04 — 3-PR follow-up batch (deferred from v0.51.1) + sidebar scroll hotfix
### Fixed
@@ -2289,7 +2483,6 @@ All 3 PRs branched off pre-Kanban-v1 master, producing multi-region conflicts in
Both #1664 and #1662 rebased branches were force-pushed back to @Michaelyklam's fork via maintainer write access (preserving `Co-authored-by:` attribution). #1587 stayed local since the maintainer token doesn't have write access to franksong2702's fork.
## [v0.51.1] — 2026-05-04 — 11-PR contributor batch from @Michaelyklam
### Added — 11 PRs from a single overnight burst, all per-PR Phase-0 fit-screened
@@ -2351,7 +2544,6 @@ Both #1664 and #1662 rebased branches were force-pushed back to @Michaelyklam's
This release ships a contributor-burst pattern (17 PRs from @Michaelyklam in 51 minutes overnight). Despite the volume, per-PR claim-vs-diff verification showed no AI-tells, all PR descriptions matched their diffs, all `closes #N` references pointed at real open issues, and security-relevant code paths (file-system reads, outbound HTTP, PID handling, schema redaction) check out under independent review. Eleven PRs landed cleanly in this batch; the remaining six were either deferred for conflict resolution or already in held-state with maintainer-review labels.
## [v0.51.0] — 2026-05-04 — Kanban v1
### Added — Kanban v1: complete first-party Kanban for Hermes (closes #1645, #1646, #1647, #1649, #1654, #1655, #1660, #1675)
@@ -2679,7 +2871,6 @@ Closes #1360, #1451, #1463, #1618, #1619 (5 issues).
Closes #1578, #1583, #1584, #1595, #1613, #1620.
## [v0.50.291] — 2026-05-04
### Fixed (1 PR — "What's new?" link 404 — closes #1579)
@@ -2786,7 +2977,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
- **PR rebase verified clean** (REBASE-DEFAULT rule applied). All 3 PR branches were on or near current master; rebase was no-op.
- **#1571 post-review fix combination**: contributor's `df03055` (post-review tightening) was on `pull/1571/head` while reviewer's `d83e1d8` (test-skip-on-missing-agent) was on `origin/fix/scheduled-jobs-profile-isolation`. Cherry-picked the test-skip commit onto the contributor branch to combine both fixes before merging into stage.
## [v0.50.287] — 2026-05-03
### Fixed (1 PR — closes another vector for the pending-message-loss class)
@@ -2804,7 +2994,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
- Browser sanity (HTTP API checks against port 8789): 11/11 endpoints verified.
- Pre-release Opus advisor: SHIP AS-IS — all 5 verification questions resolved (race-window bounded, lock ordering safe, no deadlock, frontend integration clean, test isolation robust against assertion failures).
## [v0.50.286] — 2026-05-03
### Fixed (1 PR — closes #1560)
@@ -2823,7 +3012,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
- All modified JS files (`static/i18n.js`, `static/panels.js`) pass `node -c` syntax check.
- PR rebase verified clean: `git diff origin/master --stat` shows ONLY the 6 files PR #1561 touches (no spurious deletions of v0.50.284/v0.50.285 test files that the older PR base would have dropped).
## [v0.50.285] — 2026-05-03
### Fixed (1 PR — same-day hotfix-of-hotfix)
@@ -2844,7 +3032,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
v0.50.284 promised that "the first server start after deploying v0.50.284 will auto-restore any session that was wiped between deploys." That promise was broken in production by the `_index.json` shape mismatch — the recovery silently never fired. Affected users (the original reporter on v0.50.282 with the 1000+ message session that disappeared) had `<sid>.json.bak` files on disk but those files would never be processed. Same-day hotfix restores the promise.
## [v0.50.284] — 2026-05-03
### Fixed (2 PRs — P0 streaming hotfix batch — closes #1533, #1558)
@@ -2874,7 +3061,6 @@ v0.50.284 promised that "the first server start after deploying v0.50.284 will a
- Browser sanity (HTTP API checks against port 8789): 11 endpoints verified.
- Pre-release Opus advisor: SHIP AS-IS — all 5 verification questions cleared (conflict-resolution order, deadlock risk none, Layer 3 backup interaction, startup self-heal vs concurrent saves, test-isolation fix correctness). Two SHOULD-FIX items absorbed in-release.
## [v0.50.283] — 2026-05-03
### Fixed (8 PRs — full sweep batch — closes #1426, #1481, #1512, #1468, #1424, #1457, #1401)
@@ -2911,7 +3097,6 @@ v0.50.284 promised that "the first server start after deploying v0.50.284 will a
- `static/i18n.js`: pt locale `reveal_in_finder` / `reveal_failed` translations added (Opus-flagged, 2 LOC).
- `tests/test_minimax_provider.py::test_minimax_fallback_provider_label` — scoped to direct-MiniMax routes (filter by `minimax/` prefix, exclude `:free`) since #1548's `minimax/minimax-m2.5:free` correctly carries `provider='OpenRouter'` (it routes via OpenRouter, not direct MiniMax).
## [v0.50.282] — 2026-05-03
### Fixed (1 PR — closes #1538, #1539)
@@ -3152,7 +3337,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **MIME map expansion** — adds `ttf` (`font/ttf`), `otf` (`font/otf`), and `wasm` (`application/wasm`) to the served-MIME table. `.wasm` specifically would fail to instantiate in Chrome served as `text/plain`; the others are ergonomic for older font formats. (`api/extensions.py`)
- **5 regression tests** in `tests/test_pr1445_opus_followups.py` pin the new invariants: quadruple-encoded `..` collapses correctly, the same URL is now rejected by the validator, URL list caps at the configured max with a warning log, rejected URLs log exactly once per process, and the expanded MIME map serves `.ttf`/`.otf`/`.wasm` with the correct Content-Type without charset suffixes for binary types. (`tests/test_pr1445_opus_followups.py`)
## [v0.50.264] — 2026-05-02
### Added
@@ -3225,7 +3409,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
### Changed (Opus pre-release advisor)
- **`HERMES_HOME_MODE` semantic asymmetry warning** — Opus advisor caught a footgun in my initial draft: `HERMES_HOME_MODE` means **different things** in the WebUI vs. the agent image. WebUI's `HERMES_HOME_MODE` is a credential-FILE mode threshold (e.g. `0640` allows group bits on `.env`), but the agent's `HERMES_HOME_MODE` is the HERMES_HOME *directory* mode (default `0700`). `0640` on a directory has no owner-execute bit, so the agent can't traverse its own home directory and bricks. My initial draft recommended `HERMES_HOME_MODE=0640` as the example value in agent service blocks — corrected to `0750` (group-traversable) for multi-container setups. All three surfaces now match: compose files (per-service comments), `.env.docker.example` (multi-container warning section), `docs/docker.md` (failure mode #2 callout). 3 new regression tests pin the asymmetry: `test_agent_service_does_not_recommend_invalid_home_mode`, `test_compose_files_warn_about_home_mode_asymmetry`, `test_env_docker_example_warns_about_home_mode_asymmetry`. (`docker-compose.two-container.yml`, `docker-compose.three-container.yml`, `.env.docker.example`, `docs/docker.md`, `tests/test_v050260_docker_invariants.py`)
## [v0.50.259] — 2026-05-01
### Fixed
@@ -3234,7 +3417,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
### Changed (Opus pre-release advisor)
- **Same FD-leak fix applied to LRU eviction path** — `SESSION_AGENT_CACHE.popitem(last=False)` was dropping the evicted agent on the floor with `evicted_sid, _ = ...`. The agent's `_session_db` would only release its FDs when GC eventually finalized the agent — which on a long-running server may be never. Now captures the evicted entry, calls `_evicted_agent._session_db.close()` explicitly. Same shape as #1421's fix on the cached-agent reuse path. 5 regression tests in `test_v050259_sessiondb_fd_leak.py` cover both paths plus `SessionDB.close()` idempotency. (`api/streaming.py`, `tests/test_v050259_sessiondb_fd_leak.py`)
## [v0.50.258] — 2026-05-01
### Fixed
@@ -3243,7 +3425,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
### Changed (Opus pre-release advisor)
- **Login redirect URL encoding fix — multi-param queries no longer truncated** — the original PR #1419 implementation built the outer `?next=` parameter via `quote(path, safe='/:@!$&\'()*+,;=')` which kept `?` and `&` literal. Two problems: (a) paths with multi-param queries (e.g. `/api/sessions?limit=50&offset=0`) round-tripped as `/api/sessions?limit=50` because the inner `&` terminated the outer `next` value, (b) attacker-controlled paths with embedded `&next=...` injected a second top-level `next` parameter (browsers parse first-match, Python parse_qs parses last-match — parser-divergence footgun even though `_safeNextPath()` rejects the actual exploit). Fix encodes the entire `path?query` blob with `safe='/'` so `?`, `&`, `=` all percent-encode. The outer `next` then holds exactly one path-with-query string. 6 regression tests in `test_v050258_opus_followups.py` pin the round-trip behavior across simple paths, single-query paths, multi-param queries, and attacker-injection neutralization. (`api/auth.py`, `tests/test_v050258_opus_followups.py`)
## [v0.50.257] — 2026-05-01
### Added
@@ -3265,7 +3446,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **CRITICAL: per-session toolset override (#493) was non-functional** — `_run_agent_streaming` called `_session_meta.get('enabled_toolsets')` on the result of `Session.load_metadata_only()`, which returns a Session **instance** (not a dict). The `AttributeError` was swallowed by the surrounding `except Exception:` block, so the user's toolset chip silently no-op'd every time and the agent always ran with the global toolsets. Caught by Opus pre-release advisor on the empirical streaming path (CI green, contributor tests green — would have shipped non-functional). Fix uses `getattr(_session_meta, 'enabled_toolsets', None)`. Source-level negative-pattern test prevents the dict-access shape from returning. (`api/streaming.py`, `tests/test_v050257_opus_followups.py`)
## [v0.50.256] — 2026-05-01
### Fixed
@@ -3319,7 +3499,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **`popstate` handler refuses to switch sessions mid-stream** — Opus pre-release follow-up. Mirrors the same `S.busy` guard the cross-tab storage handler had. A user mid-stream who absent-mindedly hits browser Back used to lose their active turn (PR #1392 introduced the popstate listener without the guard). Now shows a toast and stays on the current session. 1 regression test in `test_v050254_opus_followups.py`. (`static/sessions.js`)
### Added
- **Messaging sessions get a WebUI handoff path without exposing every raw channel segment** — Weixin and Telegram sessions imported from Hermes Agent are now treated as messaging-source conversations: sidebar results keep only the latest visible session per channel, preserve source metadata through compact/import paths, and avoid destructive/duplicating menu actions that would imply WebUI owns the external channel history. Messaging sessions with enough external conversation rounds show a composer-docked handoff prompt; clicking it generates a transcript card summary for the user without inserting a fake command bubble. This is PR2 for the #1013 channel-handoff direction and intentionally does not cover the separate CLI Session follow-up. (`api/models.py`, `api/routes.py`, `static/index.html`, `static/messages.js`, `static/sessions.js`, `static/style.css`, `static/ui.js`, `tests/test_gateway_sync.py`, `tests/test_issue1013_handoff_dock.py`) @franksong2702 — refs #1013
@@ -3338,7 +3517,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **Strip 9 orphan `wiki_*` i18n keys** — Opus pre-release follow-up. Commit `52bfcea` (#1342) leaked `wiki_panel_title`, `wiki_panel_desc`, `wiki_status_label`, `wiki_entry_count`, `wiki_last_modified`, `wiki_not_available`, `wiki_enabled`, `wiki_disabled`, `wiki_toggle_failed` across all 8 locales (72 lines total) from a different branch — zero references outside `i18n.js`. Stripped, with regression test pinning that they don't return. (`static/i18n.js`, `tests/test_v050253_opus_followups.py`)
## [v0.50.252] — 2026-05-01
### Fixed
@@ -3362,7 +3540,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **Reasoning trace, tool calls, and partial output preserved on Stop/Cancel** — three distinct data-loss paths fixed: §A reasoning text accumulated in a thread-local `_reasoning_text` was invisible to `cancel_stream()` because it went out of scope when the thread was interrupted; §B live tool calls in thread-local `_live_tool_calls` were similarly lost; §C reasoning-only streams (no visible tokens) produced no partial assistant message because the thinking-block regex strip returned empty string and the `if _stripped:` guard skipped the append. The fix mirrors the existing `STREAM_PARTIAL_TEXT` pattern (#893) by adding two new shared dicts (`STREAM_REASONING_TEXT`, `STREAM_LIVE_TOOL_CALLS`) populated during streaming and read by `cancel_stream()`. The cancel path now appends the partial assistant message when content text, reasoning trace, OR tool calls exist (not just text). Eliminates "paid tokens disappeared" reports on Stop. 8 regression tests covering all three sections plus tools+text combinations. (`api/config.py`, `api/streaming.py`, `tests/test_issue1361_cancel_data_loss.py`) @bergeouss — PR #1375, fixes #1361
- **New profiles route sessions to the profile dir on first use, not back to default** — `get_hermes_home_for_profile()` had a `if profile_dir.is_dir(): return profile_dir; return _DEFAULT_HERMES_HOME` fallback. New profiles (no session yet, so no dir) routed every session back to default until the directory existed on disk — making profile switching silently broken for the first session of every new profile. Removed the `is_dir()` guard; the profile path is now returned unconditionally and the directory is created on first use by the agent/session layer. Path traversal is still blocked by the `_PROFILE_ID_RE` regex (`^[a-z0-9][a-z0-9_-]{0,63}$`); R19j tests were updated to pin that the regex is now the sole defense. R19c was tightened to assert the new behavior. 5 regression tests in `test_issue1195_session_profile_routing.py` covering existing-profile, non-existent-profile (the core fix), None, empty-string, and 'default' return paths. (`api/profiles.py`, `tests/test_issue798.py`, `tests/test_issue1195_session_profile_routing.py`) @bergeouss — PR #1373, fixes #1195
## [v0.50.250] — 2026-04-30
### Fixed
@@ -3623,7 +3800,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **Pasted/dragged images render inline** — image attachments now show as `<img>` with click-to-fullscreen instead of a paperclip badge. Hoisted `_IMAGE_EXTS` to module scope (was causing `ReferenceError` in `renderMessages`); added `avif` support. (`static/ui.js`) [#1109 @bergeouss] Closes #1095
- **Copy buttons on HTTP** — `_copyText()` helper checks `isSecureContext` and falls back to `execCommand('copy')` for plain-HTTP self-hosted installs. Silent failure in `addCopyButtons` fixed with error feedback. All 6 locales get `copy_failed` key. (`static/ui.js`, `static/i18n.js`) [#1107 @bergeouss] Closes #1096
## v0.50.220 — 2026-04-26
### Fixed
@@ -4346,7 +4522,6 @@ Co-authored by @bergeouss.
newly created ones). Added `autocomplete="off"` to the search input and an explicit
value-clear at boot before the first render. Closes #822. (#830)
## [v0.50.140] — 2026-04-22
### Fixed
@@ -4673,7 +4848,6 @@ Co-authored by @bergeouss.
### Fixed
- **Auto-title extraction improved for tool-heavy first turns** — sessions where the agent's first response involved tool calls (e.g. memory lookups, file reads) were generating poor titles because the title extractor skipped all assistant messages with `tool_calls`, even when those messages contained substantive visible text. The extractor now picks the first pure (non-tool-call) assistant reply as the title source, using `_looks_invalid_generated_title()` to distinguish meta-reasoning preambles from real agentic replies. Also fixes `_is_provisional_title()` to normalize whitespace before comparing, so CJK text truncated at 64 characters correctly re-triggers title updates. (Closes #639, PR #640 by @franksong2702)
## [v0.50.80] — 2026-04-18
### Fixed
@@ -4768,7 +4942,6 @@ Co-authored by @bergeouss.
- **`HERMES_WEBUI_SKIP_ONBOARDING=1` now works unconditionally** — previously the env var was gated on `chat_ready=True`, so hosting providers (e.g. Agent37) that set it but hadn't yet wired up a provider key would still see the wizard on every page load. The var is now honoured as a hard operator override regardless of `chat_ready`. If you set it, the wizard is gone. (Fixes skip-onboarding regression)
- **Onboarding wizard can no longer overwrite config or env files when `SKIP_ONBOARDING` is set** — `apply_onboarding_setup` now checks the env var first and refuses to touch `config.yaml` or `.env` if it is set. This is a belt-and-suspenders guard: even if a stale JS bundle somehow triggers the setup endpoint while `SKIP_ONBOARDING` is active, no files are written.
## [v0.50.64] — 2026-04-16
### Changed
@@ -4872,7 +5045,6 @@ calls `sel.onchange`), and uses `S.messages` not `S.session.messages`.
Total tests: 1272 (was 1268)
## [v0.50.47] fix/feat: batch fixes — root workspace, custom providers, cron cache, system theme
Synthesized from PRs #506, #507, #508, #509, #510, #514, #515, #519, #521.
@@ -4923,7 +5095,6 @@ via `window.matchMedia`. Changes:
Total tests: 1268 (was 1246)
## [v0.50.46] feat: clarify dialog flow and refresh recovery (#520)
Adds a full clarify dialog UX for interactive agent questions — modeled after
@@ -4978,7 +5149,6 @@ Total tests: 1246 (was 1209)
Co-authored-by: franksong2702
## [v0.50.45] fix: suppress N/A source_tag in session list (#429)
Feishu and WeChat sessions (and any session with an unrecognised or legacy
@@ -5191,7 +5361,6 @@ New i18n keys for password replacement/keep-existing states (en, es, de, zh, zh-
- Total tests: 1078 (was 1075)
## [v0.50.38] feat: mobile nav cleanup, Prism syntax highlighting, zh-CN/zh-Hant i18n
Three community contributions combined:
@@ -5243,7 +5412,6 @@ and could silently overwrite their working config.
- Total tests: 1063 (was 1055)
## [v0.50.36] fix: workspace list cleaner — allow own-profile paths, remove brittle string filter
Two bugs in `_clean_workspace_list()` caused workspace additions to silently disappear on the next `load_workspaces()` call, breaking `test_workspace_add_no_duplicate` and `test_workspace_rename` (and potentially causing real-world workspace list corruption):
@@ -5455,7 +5623,6 @@ The hardcoded lists in `_PROVIDER_MODELS` remain as credential-missing / network
- **`static/ui.js`**: `populateModelDropdown()` now calls `_fetchLiveModels()` in the background after rendering the static list. Live models that aren't already in the dropdown are appended to the provider's optgroup. Results are cached per session so only one fetch per provider per page load. Skips Anthropic and Google (unsupported). Falls back to static list silently if the fetch fails.
- 25 new tests in `tests/test_issues_373_374_375.py`; 949 tests total (up from 924)
## [v0.50.19] Fix UnicodeEncodeError when downloading files with non-ASCII filenames (PR #378)
- **Workspace file downloads no longer crash for Unicode filenames** (`api/routes.py`): Clicking a PDF or other file with Chinese, Japanese, Arabic, or other non-ASCII characters in its name caused a `UnicodeEncodeError` because Python's HTTP server requires header values to be latin-1 encodable. A new `_content_disposition_value(disposition, filename)` helper centralises `Content-Disposition` generation: it strips CR/LF (injection guard), builds an ASCII fallback for the legacy `filename=` parameter (non-ASCII chars replaced with `_`), and preserves the full UTF-8 name in `filename*=UTF-8''...` per RFC 5987. Both `attachment` and `inline` responses use it.
+4 -4
View File
@@ -68,7 +68,7 @@ What makes it different from other agentic tools:
the results back into its own memory
- **Self-hosted** — your conversations, your memory, your hardware
**vs. the field** *(landscape is actively shifting — see [HERMES.md](HERMES.md) for the full breakdown)*:
**vs. the field** *(landscape is actively shifting — see [docs/why-hermes.md](docs/why-hermes.md) for the full breakdown)*:
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Hermes |
|---|---|---|---|---|---|
@@ -89,7 +89,7 @@ with memory, cron, and messaging. The key differences: Hermes writes and saves i
automatically as a core behavior (OpenClaw's skill system centers on a community marketplace);
Hermes is more stable across updates (OpenClaw has documented release regressions and ClawHub
has had security incidents involving malicious skills); and Hermes runs natively in the Python
ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side.
ecosystem. See [docs/why-hermes.md](docs/why-hermes.md) for the full side-by-side.
---
@@ -469,7 +469,7 @@ Production data and real cron jobs are never touched. Current snapshot:
### Themes
- Appearance is split into two axes: Theme (`system`, `dark`, `light`) and Skin
(`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`, `charizard`,
`sienna`, `catppuccin`, `nous`)
`sienna`, `catppuccin`, `nous`, `geist-contrast` / Geist Contrast)
- Switch via Settings -> Appearance (instant live preview) or `/theme <theme-or-skin>`
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
- Skins use `data-skin` plus CSS variables; dark mode resolves through the
@@ -552,7 +552,7 @@ State lives outside the repo at `~/.hermes/webui/` by default
## Docs
- `HERMES.md` -- why Hermes, mental model, and detailed comparison to Claude Code / Codex / OpenCode / Cursor
- `docs/why-hermes.md` -- why Hermes, mental model, and detailed comparison to Claude Code / Codex / OpenCode / Cursor
- `ROADMAP.md` -- feature roadmap and sprint history
- `ARCHITECTURE.md` -- system design, all API endpoints, implementation notes
- `TESTING.md` -- manual browser test plan and automated coverage reference
+4 -3
View File
@@ -4,7 +4,7 @@ Hermes Web UI splits **appearance** into two independent pickers:
- **Theme** — the mode: `System`, `Dark`, or `Light`. Drives the background,
text, surface, and chrome colors.
- **Skin** — the accent palette: ten named skins ship built-in. Drives only
- **Skin** — the accent palette: built-in skins ship as named keys. Drives only
the `--accent` family (active states, links, focus rings, primary actions).
You pick one of each and they combine, so the look adapts to your environment
@@ -15,13 +15,13 @@ without losing your favorite accent — pure CSS, no Python changes needed.
## Switching Appearance
**Settings panel:** Click the gear icon → **Appearance**. The **Theme** card
toggles Light/Dark/System; the **Skin** grid offers ten accent palettes.
toggles Light/Dark/System; the **Skin** grid offers the built-in accent palettes.
Preview is instant — the UI updates as you click.
**Slash command:** Type `/theme <name>` in the composer. The command accepts
both theme names (`system`, `dark`, `light`) and skin names (`default`, `ares`,
`mono`, `slate`, `poseidon`, `sisyphus`, `charizard`, `sienna`,
`catppuccin`, `nous`). It updates the matching axis and leaves the other one
`catppuccin`, `nous`, `geist-contrast`). It updates the matching axis and leaves the other one
alone.
**Persistence:** Both choices are stored in `localStorage` for flicker-free
@@ -57,6 +57,7 @@ absent for light. System mode tracks the OS preference at runtime.
| **Sienna** | Warm clay and sand earth palette. Soft and natural. |
| **Catppuccin** | Catppuccin Latte/Mocha palette with Mauve accent. |
| **Nous** | Steel-blue accent with dashed technical surfaces. |
| **Geist Contrast** (`geist-contrast`) | Geist-inspired monochrome surfaces with a restrained dark-mode `#FFF175` accent. |
Each skin defines paired light + dark variants so it reads cleanly on either
theme. The skin is applied as `data-skin="<name>"` on `<html>` (the default
+50 -11
View File
@@ -401,7 +401,7 @@ def read_importable_agent_session_rows(
)
where_clauses = ["s.source IS NOT NULL"]
params: list[str] = []
params: list[object] = []
if exclude_sources:
excluded = tuple(str(source) for source in exclude_sources if source)
if excluded:
@@ -409,8 +409,7 @@ def read_importable_agent_session_rows(
where_clauses.append(f"s.source NOT IN ({placeholders})")
params.extend(excluded)
cur.execute(
f"""
select_sql = f"""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
{session_source_expr},
@@ -428,14 +427,54 @@ def read_importable_agent_session_rows(
COUNT(m.id) AS actual_message_count,
{user_message_count_expr} AS actual_user_message_count,
MAX(m.timestamp) AS last_activity
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE {' AND '.join(where_clauses)}
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
""",
params,
)
"""
if limit is not None:
result_limit = max(0, int(limit))
if result_limit == 0:
return []
# The sidebar only needs a small visible window. Bound the expensive
# messages join to a recent-activity candidate set instead of
# aggregating every historical Hermes state.db session before
# slicing in Python. The candidate ordering must include the latest
# message timestamp, not only ``started_at``: long-lived CLI sessions
# can be resumed days later and should still surface at the top.
# Oversampling preserves room for hidden compression segments or
# other rows filtered after projection.
candidate_limit = max(result_limit * 8, result_limit)
cur.execute(
f"""
WITH candidates AS (
SELECT s.id
FROM sessions s
WHERE {' AND '.join(where_clauses)}
ORDER BY COALESCE(
(SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id),
s.started_at
) DESC,
s.started_at DESC
LIMIT ?
)
{select_sql}
FROM sessions s
JOIN candidates c ON c.id = s.id
LEFT JOIN messages m ON m.session_id = s.id
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
""",
[*params, candidate_limit],
)
else:
cur.execute(
f"""
{select_sql}
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
WHERE {' AND '.join(where_clauses)}
GROUP BY s.id
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
""",
params,
)
projected = _project_agent_session_rows([dict(row) for row in cur.fetchall()])
projected = [_with_normalized_source(row) for row in projected]
projected = [row for row in projected if is_cli_session_row_visible(row)]
+35 -1
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import queue
import threading
import time
import uuid
from typing import Optional
@@ -25,12 +26,13 @@ _clarify_sse_subscribers: dict[str, list[queue.Queue]] = {}
class _ClarifyEntry:
"""One pending clarify request inside a session."""
__slots__ = ("event", "data", "result")
__slots__ = ("event", "data", "result", "clarify_id")
def __init__(self, data: dict):
self.event = threading.Event()
self.data = data
self.result: Optional[str] = None
self.clarify_id: str = data.get("clarify_id", "") or uuid.uuid4().hex[:12]
def register_gateway_notify(session_key: str, cb) -> None:
@@ -120,6 +122,11 @@ def submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
== list(data.get("choices_offered") or [])
):
entry = last
# Dedup re-uses the existing entry with its original clarify_id.
# If a future caller pre-populates clarify_id in data, it is
# silently discarded here — the original entry's id wins.
# Today no caller sets clarify_id (it's generated by __init__),
# so this is a non-issue.
cb = _gateway_notify_cbs.get(session_key)
# Keep _pending aligned to the oldest unresolved entry.
_pending[session_key] = gw_queue[0].data
@@ -131,6 +138,8 @@ def submit_pending(session_key: str, data: dict) -> _ClarifyEntry:
return entry
entry = _ClarifyEntry(data)
# Ensure clarify_id is present in the serialised data the frontend receives.
entry.data["clarify_id"] = entry.clarify_id
gw_queue.append(entry)
_pending[session_key] = gw_queue[0].data
cb = _gateway_notify_cbs.get(session_key)
@@ -179,3 +188,28 @@ def resolve_clarify(session_key: str, response: str, resolve_all: bool = False)
entry.event.set()
count += 1
return count
def resolve_clarify_by_id(session_key: str, clarify_id: str, response: str) -> bool:
"""Resolve a specific pending clarify request by its stable id.
Returns True if the id was found and resolved, False otherwise.
"""
with _lock:
q = _gateway_queues.get(session_key)
if not q:
_pending.pop(session_key, None)
return False
for i, entry in enumerate(q):
if entry.clarify_id == clarify_id:
q.pop(i)
if q:
_pending[session_key] = q[0].data
_clarify_sse_notify(session_key, dict(q[0].data), len(q))
else:
_clear_queue_locked(session_key)
_clarify_sse_notify(session_key, None, 0)
entry.result = response
entry.event.set()
return True
return False
+152 -15
View File
@@ -3169,18 +3169,45 @@ def get_available_models() -> dict:
models.append({"id": model_id, "label": label})
return models
def _custom_endpoint_error(
provider: str,
exc: Exception,
*,
code: int | None = None,
) -> dict:
provider_label = str(provider or "custom").replace("custom:", "")
status_code = code if code is not None else getattr(exc, "code", None)
if status_code in (401, 403):
return {
"kind": "auth",
"code": int(status_code),
"message": f"Models endpoint returned {status_code} — check the API key for {provider_label}.",
}
if isinstance(status_code, int):
return {
"kind": "http",
"code": int(status_code),
"message": f"Models endpoint returned {status_code} for {provider_label}; see logs.",
}
return {
"kind": "network",
"code": None,
"message": f"Models endpoint unreachable for {provider_label}; verify base_url.",
}
def _read_custom_endpoint_models(
base_url: object,
provider: str,
*,
api_key: object = "",
trusted_base_urls: tuple[object, ...] = (),
) -> list[dict]:
) -> tuple[list[dict], dict | None]:
base = str(base_url or "").strip()
if not base:
return []
return [], None
try:
import ipaddress
import urllib.error
import urllib.request
import socket
@@ -3226,10 +3253,15 @@ def get_available_models() -> dict:
req.add_header(k, v)
with urllib.request.urlopen(req, timeout=10) as response: # nosec B310
data = json.loads(response.read().decode("utf-8"))
return _extract_model_entries_from_payload(data, provider)
except Exception:
logger.debug("Custom endpoint unreachable or misconfigured for provider: %s", provider)
return []
return _extract_model_entries_from_payload(data, provider), None
except urllib.error.HTTPError as exc:
error = _custom_endpoint_error(provider, exc, code=getattr(exc, "code", None))
logger.debug("Custom endpoint models fetch failed for provider %s: %s", provider, error)
return [], error
except Exception as exc:
error = _custom_endpoint_error(provider, exc)
logger.debug("Custom endpoint unreachable or misconfigured for provider %s: %s", provider, error)
return [], error
# 4. Fetch models from custom endpoint if base_url is configured
auto_detected_models = []
@@ -3300,12 +3332,13 @@ def get_available_models() -> dict:
for _cp in _custom_providers_for_trust
if isinstance(_cp, dict) and _cp.get("base_url")
)
for auto_model in _read_custom_endpoint_models(
_active_endpoint_models, _active_endpoint_error = _read_custom_endpoint_models(
base_url,
provider,
api_key=api_key,
trusted_base_urls=tuple(_trusted_custom_bases),
):
)
for auto_model in _active_endpoint_models:
auto_detected_models.append(auto_model)
provider_key = provider.lower()
auto_detected_models_by_provider.setdefault(provider_key, []).append(auto_model)
@@ -3313,6 +3346,7 @@ def get_available_models() -> dict:
_custom_providers_cfg = cfg.get("custom_providers", [])
_named_custom_groups: dict = {}
_named_custom_errors: dict[str, dict] = {}
if isinstance(_custom_providers_cfg, list):
_seen_custom_ids = set()
for _cp in _custom_providers_cfg:
@@ -3330,12 +3364,39 @@ def get_available_models() -> dict:
_cp_key_env = str(_cp.get("key_env") or "").strip()
if _cp_key_env:
_cp_api_key = str(os.getenv(_cp_key_env) or "").strip()
_live_models = auto_detected_models_by_provider.get(_slug) or _read_custom_endpoint_models(
_cp_base_url,
_slug,
api_key=_cp_api_key,
trusted_base_urls=(_cp_base_url,),
# Check if user has configured models in config.yaml —
# configured models take priority over live /v1/models
# discovery (same as hermes-agent model_switch.py Section 4
# patch). Without this check, ZenMux and similar aggregator
# gateways would show hundreds of online models instead of
# the user's curated list.
_cp_configured_models = _cp.get("models")
_cp_has_configured_models = (
isinstance(_cp_configured_models, (dict, list))
and len(_cp_configured_models) > 0
)
_live_models = auto_detected_models_by_provider.get(_slug)
_live_error = None
if _cp_has_configured_models:
# Skip the live /v1/models probe when an allowlist
# exists — the curated list wins and probe failures
# should not surface as a user-facing diagnostic in
# that case. Still respect any pre-warm result that
# ``auto_detected_models_by_provider`` already
# populated (cheap to keep).
if _live_models is None:
_live_models = []
elif _live_models is None:
_live_models, _live_error = _read_custom_endpoint_models(
_cp_base_url,
_slug,
api_key=_cp_api_key,
trusted_base_urls=(_cp_base_url,),
)
if _live_error:
_named_custom_errors[_slug] = _live_error
detected_providers.add(_slug)
for _live_model in _live_models:
_live_id = str(_live_model.get("id") or "").strip()
if not _live_id:
@@ -3471,8 +3532,11 @@ def get_available_models() -> dict:
# changes (e.g. supporting model-less custom_providers entries).
if not _nc_models:
_nc_models = auto_detected_models_by_provider.get(pid, [])
if _nc_models:
groups.append({"provider": _nc_display, "provider_id": pid, "models": _nc_models})
if _nc_models or pid in _named_custom_errors:
group = {"provider": _nc_display, "provider_id": pid, "models": _nc_models}
if pid in _named_custom_errors:
group["models_endpoint_error"] = _named_custom_errors[pid]
groups.append(group)
continue
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
if pid == "openrouter":
@@ -3939,6 +4003,36 @@ def get_available_models() -> dict:
or (g.get("provider_id") or "").startswith("custom:")
]
# Sort groups: active provider first, then custom:* providers,
# then providers with configured keys, then the rest alphabetically.
_providers_with_keys: set[str] = set()
try:
_pool = auth_store.get("credential_pool", {}) if isinstance(auth_store, dict) else {}
if isinstance(_pool, dict):
for _pid in _pool:
_providers_with_keys.add(_resolve_provider_alias(str(_pid)))
except Exception:
pass
try:
_cfg_providers = cfg.get("providers", {})
if isinstance(_cfg_providers, dict):
for _pk, _pv in _cfg_providers.items():
if isinstance(_pv, dict) and (_pv.get("api_key") or _pv.get("key_env")):
_providers_with_keys.add(_resolve_provider_alias(str(_pk)))
except Exception:
pass
def _group_sort_key(g):
pid = g.get("provider_id") or ""
if pid == active_provider:
return (0, pid)
if pid.startswith("custom:"):
return (1, pid)
if pid in _providers_with_keys:
return (2, pid)
return (3, pid)
groups.sort(key=_group_sort_key)
# 12. Include model aliases so the WebUI frontend can resolve them.
model_aliases: dict[str, str] = {}
try:
@@ -4085,6 +4179,14 @@ class StreamChannel:
for q in subscribers:
q.put_nowait(item)
def diagnostic_snapshot(self) -> dict[str, int]:
"""Return non-sensitive stream observation counters for health checks."""
with self._lock:
return {
"subscriber_count": len(self._subscribers),
"offline_buffered_events": len(self._offline_buffer),
}
def create_stream_channel() -> StreamChannel:
return StreamChannel()
@@ -4239,6 +4341,7 @@ _SETTINGS_DEFAULTS = {
"send_key": "enter", # 'enter' or 'ctrl+enter'
"show_token_usage": False, # show input/output token badge below assistant messages
"show_quota_chip": False, # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media)
"hide_empty_state_suggestions": False, # hide the default new-chat suggestion buttons
"show_tps": False, # show tokens-per-second chip in assistant message headers
"fade_text_effect": False, # animate newly streamed words with a lightweight fade-in effect
"show_cli_sessions": False, # merge CLI sessions from state.db into the sidebar
@@ -4251,6 +4354,8 @@ _SETTINGS_DEFAULTS = {
"font_size": "default", # small | default | large | xlarge
"session_jump_buttons": False, # show Start/End transcript jump pills
"session_endless_scroll": False, # auto-load older transcript pages while scrolling upward
"pinned_sessions_limit": 3, # maximum active pinned sessions shown in the sidebar
"hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible
"language": "en", # UI locale code; must match a key in static/i18n.js LOCALES
"bot_name": os.getenv(
"HERMES_WEBUI_BOT_NAME", "Hermes"
@@ -4279,6 +4384,7 @@ _SETTINGS_SKIN_VALUES = {
"sienna",
"catppuccin",
"nous",
"geist-contrast",
}
_SETTINGS_LEGACY_THEME_MAP = {
# Legacy full themes now map onto the closest supported theme + accent skin pair.
@@ -4357,6 +4463,12 @@ def load_settings() -> dict:
stored.get("skin") if isinstance(stored, dict) else settings.get("skin"),
)
settings["default_model"] = get_effective_default_model()
try:
model_cfg = get_config().get("model", {})
if isinstance(model_cfg, dict) and model_cfg.get("provider"):
settings["default_model_provider"] = str(model_cfg.get("provider"))
except Exception:
logger.debug("Failed to resolve default model provider for settings")
return settings
@@ -4371,10 +4483,14 @@ _SETTINGS_ENUM_VALUES = {
"auto_title_refresh_every": {"0", "5", "10", "20"},
"busy_input_mode": {"queue", "interrupt", "steer"},
}
_SETTINGS_INT_RANGES = {
"pinned_sessions_limit": (1, 99),
}
_SETTINGS_BOOL_KEYS = {
"onboarding_completed",
"show_token_usage",
"show_quota_chip",
"hide_empty_state_suggestions",
"show_tps",
"fade_text_effect",
"show_cli_sessions",
@@ -4430,11 +4546,32 @@ def save_settings(settings: dict) -> dict:
# Validate enum-constrained keys
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
continue
# Validate bounded integer settings.
if k in _SETTINGS_INT_RANGES:
try:
v = int(v)
except (TypeError, ValueError):
continue
min_value, max_value = _SETTINGS_INT_RANGES[k]
if v < min_value or v > max_value:
continue
# Validate language codes (BCP-47-like: 'en', 'zh', 'fr', 'zh-CN')
if k == "language" and (
not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v)
):
continue
# Validate hidden_tabs: must be a list of non-empty strings.
# Belt-and-suspenders strip of "chat" and "settings" so a
# malicious POST cannot lock the user out of the always-visible
# nav tabs even though the client also filters them at apply time.
# Stage-394 follow-up to #2636 deep review.
if k == "hidden_tabs":
if not isinstance(v, list):
continue
v = [
s for s in v
if isinstance(s, str) and s.strip() and s not in {"chat", "settings"}
]
# Coerce bool keys
if k in _SETTINGS_BOOL_KEYS:
v = bool(v)
+52 -8
View File
@@ -12,7 +12,7 @@ import json
import logging
import os
import urllib.request
from urllib.parse import urlparse
from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__)
@@ -61,6 +61,44 @@ def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] |
return normalized_host, port, parsed.scheme, base
def normalize_dashboard_browser_url(raw_url: str | None) -> str:
"""Return a safe browser-only dashboard link URL.
Unlike the server-side probe target, this value is only returned to the
browser for navigation. It may point at a public reverse-proxy hostname, but
it still rejects credentials, paths, query strings, fragments, and non-HTTP
schemes so it cannot hide secrets or script URLs in config.
"""
raw = str(raw_url or "").strip()
if not raw:
return ""
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("invalid dashboard URL scheme")
if parsed.username or parsed.password:
raise ValueError("invalid dashboard URL credentials")
if not parsed.hostname:
raise ValueError("invalid dashboard URL host")
if parsed.params or parsed.query or parsed.fragment:
raise ValueError("invalid dashboard URL path")
path = parsed.path or ""
if path not in ("", "/"):
raise ValueError("invalid dashboard URL path")
try:
port = parsed.port
except ValueError as exc:
raise ValueError("invalid dashboard URL port") from exc
host = parsed.hostname.lower()
if ":" in host and not host.startswith("["):
host = f"[{host}]"
netloc = host
if port is not None:
if not (1 <= port <= 65535):
raise ValueError("invalid dashboard URL port")
netloc = f"{netloc}:{port}"
return urlunparse((parsed.scheme, netloc, "", "", "", ""))
def _looks_like_official_dashboard(payload: object) -> bool:
if not isinstance(payload, dict):
return False
@@ -132,8 +170,7 @@ def get_dashboard_config(config_data: dict | None = None) -> dict:
enabled = "auto"
raw_url = str(dashboard_cfg.get("url") or "").strip()
if raw_url:
# Normalize before echoing so the UI never displays unsafe/stale values.
_host, _port, _scheme, raw_url = normalize_dashboard_url(raw_url)
raw_url = normalize_dashboard_browser_url(raw_url)
return {"enabled": enabled, "url": raw_url}
@@ -143,9 +180,7 @@ def save_dashboard_config(payload: dict) -> dict:
if enabled not in _DASHBOARD_ENABLED_VALUES:
raise ValueError("invalid dashboard enabled mode")
raw_url = str((payload or {}).get("url", "") or "").strip()
normalized_url = ""
if raw_url:
_host, _port, _scheme, normalized_url = normalize_dashboard_url(raw_url)
normalized_url = normalize_dashboard_browser_url(raw_url) if raw_url else ""
from api import config as webui_config
@@ -186,9 +221,13 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:
raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or ""
try:
override = normalize_dashboard_url(raw_url)
browser_url = normalize_dashboard_browser_url(raw_url) if raw_url else ""
except ValueError:
return {"running": False, "enabled": enabled, "error": "invalid dashboard url"}
try:
override = normalize_dashboard_url(raw_url)
except ValueError:
override = None
targets: list[tuple[str, int, str, str]]
if override:
@@ -197,8 +236,10 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:
targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS]
if enabled == "always":
if browser_url and not override:
return {"running": True, "enabled": enabled, "url": browser_url, "browser_url": browser_url}
host, port, scheme, base = targets[0]
return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base}
return {"running": True, "enabled": enabled, "host": host, "port": port, "url": browser_url or base, "browser_url": browser_url or base}
if not _webui_bind_host_allows_auto_probe():
return {"running": False, "enabled": enabled}
@@ -207,5 +248,8 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:
result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme)
if result.get("running"):
result["enabled"] = enabled
if browser_url:
result["browser_url"] = browser_url
result["url"] = browser_url
return result
return {"running": False, "enabled": enabled}
+634 -47
View File
@@ -6,6 +6,7 @@ import hashlib
import json
import logging
import os
import re
import threading
import time
import uuid
@@ -109,19 +110,30 @@ def _write_session_index(updates=None):
# Lazy full-rebuild path — used when index doesn't exist yet.
if updates is None or not SESSION_INDEX_FILE.exists():
_cleanup_stale_tmp_files() # best-effort sweep on startup / first call
entries = []
entry_map: dict[str, dict] = {}
for p in SESSION_DIR.glob('*.json'):
if p.name.startswith('_'):
continue
try:
s = Session.load(p.stem)
if s:
entries.append(s.compact())
c = s.compact()
sid = c.get('session_id')
if sid:
# Dedup by session_id: prefer entry with more messages
# (handles old-format session_xxx.json files alongside
# WebUI-format xxx.json with the same session_id)
existing = entry_map.get(sid)
if existing is None or (
c.get('message_count', 0) > existing.get('message_count', 0)
):
entry_map[sid] = c
except Exception:
logger.debug("Failed to load session from %s", p)
entries = list(entry_map.values())
with LOCK:
existing_ids = {e.get('session_id') for e in entries}
existing_ids = set(entry_map.keys())
for s in SESSIONS.values():
if s.session_id not in existing_ids:
entries.append(s.compact())
@@ -201,7 +213,16 @@ def _write_session_index(updates=None):
def _active_stream_ids():
with STREAMS_LOCK:
return set(STREAMS.keys())
active_ids = set(STREAMS.keys())
# STREAMS tracks the browser/SSE observation path. A worker can still be
# running after the SSE stream entry disappears (for example while a request
# is blocked in the provider, unwinding after cancel, or otherwise detached
# from the client). Treat ACTIVE_RUNS as authoritative for worker liveness so
# stale-pending repair does not append a misleading restart/interrupted
# marker while the agent turn is still in flight.
with _cfg.ACTIVE_RUNS_LOCK:
active_ids.update(_cfg.ACTIVE_RUNS.keys())
return active_ids
def _append_recovered_turn_to_context(session, recovered: dict) -> None:
@@ -358,6 +379,16 @@ def _lookup_index_message_count(session_id):
return None
def _parse_nonnegative_int(value):
if isinstance(value, int) and value >= 0:
return value
try:
parsed = int(value)
except (TypeError, ValueError):
return None
return parsed if parsed >= 0 else None
class Session:
def __init__(self, session_id: str=None, title: str='Untitled',
workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL,
@@ -620,19 +651,16 @@ class Session:
parsed['messages'] = []
parsed['tool_calls'] = []
session = cls(**parsed)
metadata_message_count = _lookup_index_message_count(sid)
if metadata_message_count is None:
raw_count = parsed.get('message_count')
if isinstance(raw_count, int) and raw_count >= 0:
metadata_message_count = raw_count
else:
try:
parsed_count = int(raw_count)
except (TypeError, ValueError):
parsed_count = None
if parsed_count is not None and parsed_count >= 0:
metadata_message_count = parsed_count
session._metadata_message_count = metadata_message_count
index_message_count = _lookup_index_message_count(sid)
sidecar_message_count = _parse_nonnegative_int(parsed.get('message_count'))
# The sidebar index is a cache and can lag behind external sidecar
# appends/backfills. Prefer the largest known count so metadata-only
# active-session polls do not miss real remote updates.
known_counts = [
count for count in (index_message_count, sidecar_message_count)
if count is not None
]
session._metadata_message_count = max(known_counts) if known_counts else None
# Mark this session as a metadata-only stub. save() refuses to write
# such a session because doing so would atomically replace the
# on-disk JSON with messages=[], wiping the conversation. Any
@@ -734,27 +762,69 @@ def _get_profile_home(profile) -> Path:
return Path(os.environ.get('HERMES_HOME') or '~/.hermes').expanduser()
def _interrupted_recovery_marker(*, recovered_output: bool = False) -> dict:
_INTERRUPTED_RECOVERED_WORDING = (
'**Response interrupted.**\n\n'
'The WebUI process restarted before this turn finished. '
'The partial output above was recovered from the run journal, '
'but the interrupted agent process could not continue.'
)
_INTERRUPTED_NO_OUTPUT_WORDING = (
'**Response interrupted.**\n\n'
'The WebUI process restarted before this turn finished. '
'The user message above was preserved, but no agent output was recovered.'
)
_INTERRUPTED_PENDING_RETRY_WORDING = (
'**Response interrupted.**\n\n'
'The WebUI process restarted before this turn finished. '
'Recovering the partial output from the run journal — '
'reload this session to retry.'
)
# Neutral wording used when the lazy retry path gives up (max attempts reached
# or the marker has been pending longer than _JOURNAL_RETRY_GIVEUP_SECONDS).
_INTERRUPTED_NEUTRAL_WORDING = (
'**Response interrupted.**\n\n'
'The WebUI process restarted before this turn finished. '
'Partial output may have been lost.'
)
def _interrupted_recovery_marker(
*,
recovered_output: bool = False,
pending_retry: bool = False,
) -> dict:
"""Build the standard interrupted-turn marker.
``recovered_output=True`` means the run journal already yielded visible
text on this repair pass — the marker advertises that the partial output
has been recovered.
``pending_retry=True`` is the lazy-retry hook: the journal was unreadable
on this pass (page-cache loss, un-fsynced writes on slow FS, etc.). The
marker carries a ``_pending_journal_recovery`` flag so a later
``get_session()`` can re-attempt recovery without baking a permanent
"no output" claim into the transcript.
The two are mutually exclusive; ``recovered_output`` wins if both are
set so the caller cannot accidentally re-arm retry on a successful
repair.
"""
if recovered_output:
content = (
'**Response interrupted.**\n\n'
'The WebUI process restarted before this turn finished. '
'The partial output above was recovered from the run journal, '
'but the interrupted agent process could not continue.'
)
content = _INTERRUPTED_RECOVERED_WORDING
elif pending_retry:
content = _INTERRUPTED_PENDING_RETRY_WORDING
else:
content = (
'**Response interrupted.**\n\n'
'The WebUI process restarted before this turn finished. '
'The user message above was preserved, but no agent output was recovered.'
)
return {
content = _INTERRUPTED_NO_OUTPUT_WORDING
marker = {
'role': 'assistant',
'content': content,
'timestamp': int(time.time()),
'_error': True,
'type': 'interrupted',
}
if pending_retry and not recovered_output:
marker['_pending_journal_recovery'] = True
return marker
def _truncate_journal_tool_args(args, limit: int = 4) -> dict:
@@ -841,9 +911,33 @@ def _find_existing_assistant_for_journal_content(session, content: str) -> int |
return None
def _journal_tool_already_present(session, name: str, preview: str) -> bool:
def _journal_tool_already_present(
session,
name: str,
preview: str,
*,
stream_id: str | None = None,
) -> bool:
"""Return True when an equivalent tool card already exists.
Matching rule:
* If the existing tool card carries ``_recovered_stream_id``, that means a
previous journal-recovery run materialized it. The retry can safely
collapse against it only when both stream ids match — otherwise a
legitimately-repeated tool (e.g. a second ``terminal: ls`` in a
different turn) would be dropped.
* If the existing tool card has no ``_recovered_stream_id`` (a live tool
card, or a tool card carried over from a core transcript that pre-dates
stream-id tagging), the legacy name+preview match still wins. This
preserves the "core transcript already has this tool, don't duplicate
it" invariant the original repair path established.
* When ``stream_id`` is omitted, the helper degrades cleanly to its
pre-fix session-wide behaviour.
"""
candidate_name = str(name or '')
candidate_preview = _normalize_journal_recovery_text(preview)
candidate_stream = str(stream_id) if stream_id else None
for tool_call in session.tool_calls or []:
if not isinstance(tool_call, dict):
continue
@@ -852,8 +946,17 @@ def _journal_tool_already_present(session, name: str, preview: str) -> bool:
existing_preview = _normalize_journal_recovery_text(
tool_call.get('preview') or tool_call.get('snippet') or ''
)
if existing_preview == candidate_preview:
return True
if existing_preview != candidate_preview:
continue
if candidate_stream is not None:
existing_stream = tool_call.get('_recovered_stream_id')
# A tool card explicitly tagged with a recovered_stream_id that
# differs from ours belongs to another retry's turn — don't let
# it pre-empt this retry. Untagged tool cards (live or carried
# over from the core transcript) still match.
if existing_stream and str(existing_stream) != candidate_stream:
continue
return True
return False
@@ -882,6 +985,39 @@ def _run_journal_has_visible_output(session, stream_id: str | None) -> bool:
return False
def _journal_is_still_arriving(session, stream_id: str | None) -> bool:
"""Return True for journals that may become visible on a later read.
`read_run_events()` deliberately collapses missing files and empty files
into an empty event list, so the lazy retry path needs a small filesystem
visibility check to avoid burning all retry attempts while WSL2 / network
filesystems are still surfacing the journal. Non-empty journals are treated
as sealed enough for retry-budget accounting; if they contain no visible
output, the normal capped give-up path handles them.
"""
if not stream_id:
return False
try:
from api.run_journal import _run_path, latest_run_summary
path = _run_path(session.session_id, stream_id)
summary = latest_run_summary(session.session_id, stream_id)
if summary.get('terminal'):
return False
try:
return (not path.exists()) or path.stat().st_size == 0
except OSError:
return True
except Exception:
logger.debug(
"Session %s: failed to classify journal visibility for stream %s",
getattr(session, 'session_id', '?'),
stream_id,
exc_info=True,
)
return False
def _append_journaled_partial_output(
session,
stream_id: str | None,
@@ -996,7 +1132,9 @@ def _append_journaled_partial_output(
anchor_idx = ensure_assistant_anchor(created_at)
name = str(payload.get('name') or 'tool')
preview = str(payload.get('preview') or '')
if dedupe_existing and _journal_tool_already_present(session, name, preview):
if dedupe_existing and _journal_tool_already_present(
session, name, preview, stream_id=stream_id,
):
current_assistant_idx = anchor_idx
continue
recovered_tool_calls.append({
@@ -1038,6 +1176,273 @@ def _append_journaled_partial_output(
return appended_any
# ── Lazy run-journal recovery (read-side self-heal) ─────────────────────────
#
# When sidecar repair runs before the run-journal for the dead stream is
# visible on disk (page-cache loss on WSL2 9p / DrvFs, an un-fsynced journal
# tail, a slow network FS, …), `_append_journaled_partial_output` returns
# False even though the journaled events will appear on disk shortly. Without
# the helpers below the repair path baked a permanent "no agent output was
# recovered" claim into the marker, and a later session read could never
# correct it.
#
# The contract is:
#
# * Sidecar repair (`_apply_core_sync_or_error_marker`) writes a marker
# with `_pending_journal_recovery=True` whenever it could not recover
# visible output AND the stream id is known. Three retry-meta keys go
# onto the marker: `_journal_retry_stream_id`, `_journal_retry_attempts`,
# `_journal_retry_first_seen_ts`.
# * Every `get_session()` call that returns the full session checks the
# latest assistant marker; if the flag is set it re-runs
# `_append_journaled_partial_output` with `dedupe_existing=True`. On
# success the marker is promoted in place to the recovered-output
# wording, the journaled rows are reordered to sit above the marker,
# and all retry meta is stripped. If the journal is still missing or
# zero-byte, the retry is a no-op and does not consume attempt budget.
# Terminal/non-useful journals consume attempt budget and can demote
# immediately at the max-attempt cap.
# * After `_JOURNAL_RETRY_MAX_ATTEMPTS` failed retries or
# `_JOURNAL_RETRY_GIVEUP_SECONDS` of wall-clock age, the marker is
# demoted to the neutral wording ("Partial output may have been lost.")
# so users do not see "reload to retry" prompts forever.
_JOURNAL_RETRY_MAX_ATTEMPTS = 12
_JOURNAL_RETRY_GIVEUP_SECONDS = 24 * 3600
_JOURNAL_RETRY_LOCKS: dict[str, threading.Lock] = {}
_JOURNAL_RETRY_LOCKS_GUARD = threading.Lock()
def _journal_retry_lock_for_sid(sid: str) -> threading.Lock:
with _JOURNAL_RETRY_LOCKS_GUARD:
return _JOURNAL_RETRY_LOCKS.setdefault(str(sid), threading.Lock())
def _build_recovery_marker_with_retry_hook(
*, recovered_output: bool, stream_id: str | None,
) -> dict:
"""Build an interrupted-turn marker, arming the lazy-retry hook when
visible output was not recovered yet but a stream id is available."""
if recovered_output:
return _interrupted_recovery_marker(recovered_output=True)
if not stream_id:
return _interrupted_recovery_marker(recovered_output=False)
marker = _interrupted_recovery_marker(pending_retry=True)
marker['_journal_retry_stream_id'] = str(stream_id)
marker['_journal_retry_attempts'] = 0
marker['_journal_retry_first_seen_ts'] = int(time.time())
return marker
def _session_has_pending_journal_retry(session) -> bool:
"""Cheap short-circuit: scan from the tail until the most recent normal
assistant turn. Any `_pending_journal_recovery` flag found before then
means a retry is queued.
"""
messages = getattr(session, 'messages', None) or []
for msg in reversed(messages):
if not isinstance(msg, dict):
continue
if msg.get('_pending_journal_recovery'):
return True
if msg.get('role') == 'assistant' and not msg.get('_error'):
# A normal assistant turn after any pending marker — nothing to
# retry above this point.
return False
return False
def _strip_journal_retry_meta(marker: dict) -> None:
marker.pop('_pending_journal_recovery', None)
marker.pop('_journal_retry_stream_id', None)
marker.pop('_journal_retry_attempts', None)
marker.pop('_journal_retry_first_seen_ts', None)
def _reorder_journal_tail_above_marker(session, marker_idx: int) -> None:
"""Move `_recovered_from_run_journal=True` rows appended *after*
``marker_idx`` to sit immediately above the marker so chronological
order is preserved (journaled output happened during the turn, marker
annotates its end).
"""
messages = session.messages
if marker_idx < 0 or marker_idx >= len(messages):
return
tail = messages[marker_idx + 1 :]
if not tail:
return
journaled = [
m for m in tail
if isinstance(m, dict) and m.get('_recovered_from_run_journal')
]
if not journaled:
return
rest = [
m for m in tail
if not (isinstance(m, dict) and m.get('_recovered_from_run_journal'))
]
marker = messages[marker_idx]
new_messages = (
messages[:marker_idx]
+ journaled
+ [marker]
+ rest
)
# Rebase any tool_calls.assistant_msg_idx values that pointed into the
# journaled rows when they were appended at the tail.
old_journaled_idx_base = marker_idx + 1
new_journaled_idx_base = marker_idx
shift = new_journaled_idx_base - old_journaled_idx_base # = -1
for tool_call in session.tool_calls or []:
if not isinstance(tool_call, dict):
continue
idx = tool_call.get('assistant_msg_idx')
if isinstance(idx, int) and idx >= old_journaled_idx_base \
and idx < old_journaled_idx_base + len(journaled):
tool_call['assistant_msg_idx'] = idx + shift
session.messages = new_messages
def _try_retry_journal_recovery_in_place(session) -> bool:
sid = str(getattr(session, 'session_id', '') or '')
lock = _journal_retry_lock_for_sid(sid)
if not lock.acquire(blocking=False):
logger.debug("lazy journal-retry already running for session %s", sid)
return False
try:
return _retry_journal_recovery_in_place(
session, preserve_arriving_budget=True,
)
finally:
lock.release()
with _JOURNAL_RETRY_LOCKS_GUARD:
if _JOURNAL_RETRY_LOCKS.get(sid) is lock:
_JOURNAL_RETRY_LOCKS.pop(sid, None)
def _retry_journal_recovery_in_place(
session,
*,
preserve_arriving_budget: bool = False,
) -> bool:
"""Re-attempt run-journal recovery for the most recent pending marker.
Returns True if the marker was promoted to the recovered-output wording.
Never raises — caller is best-effort.
"""
try:
messages = session.messages or []
for idx in range(len(messages) - 1, -1, -1):
msg = messages[idx]
if not isinstance(msg, dict):
continue
if msg.get('role') == 'assistant' and not msg.get('_error') \
and not msg.get('_pending_journal_recovery'):
# Walked past the pending marker without finding it.
return False
if not (
msg.get('type') == 'interrupted'
and msg.get('_pending_journal_recovery')
):
continue
stream_id = msg.get('_journal_retry_stream_id')
first_seen = msg.get('_journal_retry_first_seen_ts') or 0
attempts = int(msg.get('_journal_retry_attempts') or 0)
now = time.time()
give_up = (
attempts >= _JOURNAL_RETRY_MAX_ATTEMPTS
or (
first_seen
and now - float(first_seen) > _JOURNAL_RETRY_GIVEUP_SECONDS
)
)
if not stream_id:
# No stream id to retry against; demote immediately.
msg['content'] = _INTERRUPTED_NEUTRAL_WORDING
_strip_journal_retry_meta(msg)
try:
session.save(touch_updated_at=False)
except Exception:
logger.debug(
"save() failed while demoting marker for session %s",
getattr(session, 'session_id', '?'),
exc_info=True,
)
return False
if give_up:
msg['content'] = _INTERRUPTED_NEUTRAL_WORDING
_strip_journal_retry_meta(msg)
try:
session.save(touch_updated_at=False)
except Exception:
logger.debug(
"save() failed while demoting marker for session %s",
getattr(session, 'session_id', '?'),
exc_info=True,
)
return False
tail_len_before = len(session.messages)
ok = _append_journaled_partial_output(
session, stream_id, dedupe_existing=True,
)
if ok:
msg['content'] = _INTERRUPTED_RECOVERED_WORDING
_strip_journal_retry_meta(msg)
# The journaled rows were appended at the end of messages;
# only the rows past the previous tail count as "newly
# journaled" and need to move above the marker.
_ = tail_len_before # informational; helper below scans
_reorder_journal_tail_above_marker(session, idx)
try:
session.save(touch_updated_at=False)
except Exception:
logger.debug(
"save() failed while promoting marker for session %s",
getattr(session, 'session_id', '?'),
exc_info=True,
)
logger.info(
"Session %s: lazy journal-recovery promoted marker for "
"stream %s after %d attempts",
getattr(session, 'session_id', '?'),
stream_id,
attempts,
)
return True
if (
preserve_arriving_budget
and _journal_is_still_arriving(session, stream_id)
):
logger.debug(
"Session %s: journal for stream %s still arriving; "
"preserving retry budget",
getattr(session, 'session_id', '?'),
stream_id,
)
return False
next_attempts = attempts + 1
if next_attempts >= _JOURNAL_RETRY_MAX_ATTEMPTS:
msg['content'] = _INTERRUPTED_NEUTRAL_WORDING
_strip_journal_retry_meta(msg)
else:
msg['_journal_retry_attempts'] = next_attempts
try:
session.save(touch_updated_at=False)
except Exception:
logger.debug(
"save() failed while updating retry counter for session %s",
getattr(session, 'session_id', '?'),
exc_info=True,
)
return False
return False
except Exception:
logger.exception(
"_retry_journal_recovery_in_place failed for session %s",
getattr(session, 'session_id', '?'),
)
return False
def _apply_core_sync_or_error_marker(
session,
core_path,
@@ -1105,11 +1510,16 @@ def _apply_core_sync_or_error_marker(
session,
stream_id_for_recheck or session.active_stream_id,
)
_stream_id = stream_id_for_recheck or session.active_stream_id
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
session.messages.append(_interrupted_recovery_marker(recovered_output=recovered_output))
session.messages.append(
_build_recovery_marker_with_retry_hook(
recovered_output=recovered_output, stream_id=_stream_id,
)
)
session.save(touch_updated_at=touch_updated_at)
logger.info(
"Session %s: recovered pending user turn (messages non-empty), added error marker",
@@ -1160,6 +1570,17 @@ def _apply_core_sync_or_error_marker(
session.messages.append(
_interrupted_recovery_marker(recovered_output=True)
)
# NOTE: when the core transcript was synced in but the run journal
# is not yet visible, intentionally do NOT append a lazy-retry
# marker here. In this branch the canonical history is the core
# transcript itself (which has already been written to s.messages
# above) and the marker is purely advisory — the existing contract
# is "marker only when there is a recovered partial turn to
# annotate". Adding a pending-retry marker on every empty-journal
# core-sync would surface a spurious "reload to retry" banner on
# sessions whose journal is legitimately absent (e.g. archived
# streams). The first and third branches handle the lost-response
# case where the marker is the only signal the user gets.
session.save(touch_updated_at=touch_updated_at)
logger.info(
"Session %s: synced %d messages from core transcript%s",
@@ -1182,11 +1603,16 @@ def _apply_core_sync_or_error_marker(
session,
stream_id_for_recheck or session.active_stream_id,
)
_stream_id = stream_id_for_recheck or session.active_stream_id
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
session.messages.append(_interrupted_recovery_marker(recovered_output=recovered_output))
session.messages.append(
_build_recovery_marker_with_retry_hook(
recovered_output=recovered_output, stream_id=_stream_id,
)
)
session.save(touch_updated_at=touch_updated_at)
logger.info("Session %s: no core transcript found, added error marker", sid)
return True
@@ -1306,9 +1732,19 @@ def get_session(sid, metadata_only=False):
actual message history (e.g., for fast sidebar switching).
"""
with LOCK:
if sid in SESSIONS:
cached = SESSIONS.get(sid)
if cached is not None:
SESSIONS.move_to_end(sid) # LRU: mark as recently used
return SESSIONS[sid]
if cached is not None:
if not metadata_only and _session_has_pending_journal_retry(cached):
try:
_try_retry_journal_recovery_in_place(cached)
except Exception:
logger.debug(
"lazy journal-retry failed on cache hit for session %s",
sid, exc_info=True,
)
return cached
if metadata_only:
s = Session.load_metadata_only(sid)
if s:
@@ -1324,6 +1760,18 @@ def get_session(sid, metadata_only=False):
if not metadata_only:
try:
repaired = _repair_stale_pending(s)
# If the stale-pending repair did not fire but the session
# already carries a pending-journal-retry marker (e.g. set on
# a previous repair pass), give the lazy-retry path one
# chance to self-heal on this read.
if not repaired and _session_has_pending_journal_retry(s):
try:
_try_retry_journal_recovery_in_place(s)
except Exception:
logger.debug(
"lazy journal-retry failed on cold load for session %s",
sid, exc_info=True,
)
# If repair had to bail because the per-session lock was held,
# do not pin the still-stale sidecar in the LRU cache forever.
# Leaving it cached would prevent future get_session() calls from
@@ -1340,6 +1788,27 @@ def get_session(sid, metadata_only=False):
return s
raise KeyError(sid)
def _profile_default_model_state(profile=None):
"""Return the default model/provider configured for *profile*."""
default_model = ""
default_provider = None
try:
from api.profiles import get_hermes_home_for_profile
config_path = Path(get_hermes_home_for_profile(profile)) / "config.yaml"
config_data = _cfg._load_yaml_config_file(config_path)
except Exception:
config_data = {}
model_cfg = config_data.get("model", {}) if isinstance(config_data, dict) else {}
if isinstance(model_cfg, str):
default_model = model_cfg.strip()
elif isinstance(model_cfg, dict):
default_model = str(model_cfg.get("default") or "").strip()
default_provider = str(model_cfg.get("provider") or "").strip() or None
return default_model or get_effective_default_model(), default_provider
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None, worktree_info=None):
"""Create a new in-memory session.
@@ -1372,15 +1841,35 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
profile = get_active_profile_name()
except ImportError:
profile = None
effective_model = model or get_effective_default_model()
if model:
effective_model = model
effective_model_provider = model_provider
else:
effective_model, effective_model_provider = _profile_default_model_state(profile)
if model_provider:
effective_model_provider = model_provider
# Read default personality from config display.personality
_default_personality = None
try:
from api.config import get_config as _get_cfg_for_personality
_cfg_personality = (_get_cfg_for_personality().get('display') or {}).get('personality')
if _cfg_personality and isinstance(_cfg_personality, str):
_cfg_personality = _cfg_personality.strip().lower()
if _cfg_personality and _cfg_personality not in ('default', 'none', 'neutral'):
_default_personality = _cfg_personality
except Exception:
pass
wt = worktree_info if isinstance(worktree_info, dict) else None
workspace_path = (wt.get('path') if wt and wt.get('path') else workspace) if wt else workspace
s = Session(
workspace=workspace_path or get_last_workspace(),
model=effective_model,
model_provider=model_provider,
model_provider=effective_model_provider,
profile=profile,
project_id=project_id,
personality=_default_personality,
worktree_path=wt.get('path') if wt else None,
worktree_branch=wt.get('branch') if wt else None,
worktree_repo_root=wt.get('repo_root') if wt else None,
@@ -2495,9 +2984,10 @@ def _normalized_message_timestamp_for_key(value):
timestamp = float(value)
except (TypeError, ValueError):
return str(value)
if timestamp.is_integer():
return str(int(timestamp))
return ("%.6f" % timestamp).rstrip("0").rstrip(".")
# Truncate to second-level granularity so that sub-second drift between
# the sidecar JSON write and the state.db created_at write does not cause
# the legacy dedup key to differ for the same logical message.
return str(int(timestamp))
def _message_timestamp_as_float(msg):
@@ -2528,6 +3018,60 @@ def _session_message_merge_key(msg: dict):
)
def _normalized_session_message_content(msg: dict) -> str:
if not isinstance(msg, dict):
return repr(msg)
return " ".join(str(msg.get("content") or "").split())
def _loose_session_message_content(value: str) -> str:
return " ".join(re.findall(r"\w+", str(value or "").casefold()))
def _session_message_content_key(msg: dict):
if not isinstance(msg, dict):
return ("non_dict", repr(msg))
return (
str(msg.get("role") or ""),
_normalized_session_message_content(msg),
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
)
def _session_message_visible_key(msg: dict):
if not isinstance(msg, dict):
return ("non_dict", repr(msg))
return (
str(msg.get("role") or ""),
_normalized_session_message_content(msg),
)
def _matching_visible_duplicate(visible_key: tuple, visible_keys: set[tuple]):
if visible_key in visible_keys:
return visible_key
role, content = visible_key
if not content:
return None
for existing_role, existing_content in visible_keys:
if role != existing_role or not existing_content:
continue
if content in existing_content or existing_content in content:
return (existing_role, existing_content)
loose_content = _loose_session_message_content(content)
loose_existing = _loose_session_message_content(existing_content)
if loose_content and loose_existing and (
loose_content in loose_existing or loose_existing in loose_content
):
return (existing_role, existing_content)
return None
def _has_visible_duplicate(visible_key: tuple, visible_keys: set[tuple]) -> bool:
return _matching_visible_duplicate(visible_key, visible_keys) is not None
def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list:
"""Merge sidecar/context and state.db messages without deleting local rows."""
sidecar_messages = list(sidecar_messages or [])
@@ -2539,6 +3083,11 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l
merged_messages = []
seen_message_keys = set()
seen_content_keys = set()
seen_visible_keys = set()
sidecar_visible_sequence = []
sidecar_visible_keys = set()
sidecar_visible_counts = {}
max_sidecar_timestamp = None
for msg in sidecar_messages:
timestamp = _message_timestamp_as_float(msg)
@@ -2546,10 +3095,34 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l
max_sidecar_timestamp = timestamp if max_sidecar_timestamp is None else max(max_sidecar_timestamp, timestamp)
key = _session_message_merge_key(msg)
seen_message_keys.add(key)
seen_content_keys.add(_session_message_content_key(msg))
visible_key = _session_message_visible_key(msg)
seen_visible_keys.add(visible_key)
sidecar_visible_keys.add(visible_key)
sidecar_visible_counts[visible_key] = sidecar_visible_counts.get(visible_key, 0) + 1
sidecar_visible_sequence.append(visible_key)
merged_messages.append(msg)
state_replay_idx = 0
skipped_state_visible_counts = {}
for msg in state_messages:
timestamp = _message_timestamp_as_float(msg)
key = _session_message_merge_key(msg)
visible_key = _session_message_visible_key(msg)
replays_sidecar_prefix = False
if state_replay_idx < len(sidecar_visible_sequence):
expected_visible_key = sidecar_visible_sequence[state_replay_idx]
if visible_key == expected_visible_key or _has_visible_duplicate(
visible_key, {expected_visible_key}
):
replays_sidecar_prefix = True
state_replay_idx += 1
if replays_sidecar_prefix:
matched_visible_key = _matching_visible_duplicate(visible_key, sidecar_visible_keys)
if matched_visible_key is not None:
skipped_state_visible_counts[matched_visible_key] = (
skipped_state_visible_counts.get(matched_visible_key, 0) + 1
)
continue
if max_sidecar_timestamp is not None and timestamp is not None and timestamp <= max_sidecar_timestamp:
if key in seen_message_keys:
continue
@@ -2557,12 +3130,23 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l
continue
if key in seen_message_keys:
continue
matched_visible_key = _matching_visible_duplicate(visible_key, sidecar_visible_keys)
if matched_visible_key is not None:
skipped_count = skipped_state_visible_counts.get(matched_visible_key, 0)
sidecar_count = sidecar_visible_counts.get(matched_visible_key, 0)
if skipped_count < sidecar_count:
skipped_state_visible_counts[matched_visible_key] = skipped_count + 1
continue
# State rows at or before the newest sidecar timestamp are normally
# assumed to have already been observed by the sidecar. The <= gate
# preserves sidecar-only ordering/metadata for equal timestamps and
# prevents duplicate legacy rows when timestamp precision differs
# between stores. Explicit message ids are authoritative, though: two
# equal-timestamp messages with different ids are distinct retries.
# between stores. State rows whose visible content already exists in
# the sidecar are also skipped even if state.db restamped them later
# during compaction/recovery; otherwise old prompts can be appended
# after the assistant tail and make /api/session look like the answer
# vanished. Explicit message ids are authoritative for distinct rows
# only when their visible content is not already present.
if (
key[0] != "message_id"
and max_sidecar_timestamp is not None
@@ -2570,7 +3154,10 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l
and timestamp <= max_sidecar_timestamp
):
continue
seen_message_keys.add(key)
if key[0] == "message_id":
seen_message_keys.add(key)
seen_content_keys.add(_session_message_content_key(msg))
seen_visible_keys.add(visible_key)
merged_messages.append(msg)
return merged_messages
@@ -2639,7 +3226,7 @@ def count_conversation_rounds(sid: str, since: float | None = None) -> int:
return 0
try:
with sqlite3.connect(str(db_path)) as conn:
with closing(sqlite3.connect(str(db_path))) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(
+10 -2
View File
@@ -19,6 +19,8 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Optional
from api.session_events import publish_session_list_changed
logger = logging.getLogger(__name__)
# ── Constants (match hermes_cli.profiles upstream) ─────────────────────────
@@ -411,8 +413,11 @@ def install_cron_scheduler_profile_isolation() -> None:
# the explicitly selected manual execution profile.
if _cron_profile_context_depth() > 0:
return original(job, *args, **kwargs)
with cron_profile_context_for_home(_home_for_scheduled_cron_job(job)):
return original(job, *args, **kwargs)
try:
with cron_profile_context_for_home(_home_for_scheduled_cron_job(job)):
return original(job, *args, **kwargs)
finally:
publish_session_list_changed("cron_complete")
_webui_profile_isolated_run_job._webui_profile_isolated = True
_webui_profile_isolated_run_job._webui_original_run_job = original
@@ -914,10 +919,12 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
cfg = {}
model_cfg = cfg.get('model', {})
default_model = None
default_model_provider = None
if isinstance(model_cfg, str):
default_model = model_cfg
elif isinstance(model_cfg, dict):
default_model = model_cfg.get('default')
default_model_provider = model_cfg.get('provider')
# Read the target profile's workspace directly from *home* rather than via
# get_last_workspace() which routes through the thread-local/process-global active
@@ -969,6 +976,7 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
'profiles': list_profiles_api(),
'active': name,
'default_model': default_model,
'default_model_provider': default_model_provider,
'default_workspace': default_workspace,
}
+14
View File
@@ -1984,8 +1984,10 @@ def get_providers() -> dict[str, Any]:
"display_name": cp_name,
"has_key": cp_has_key,
"configurable": False, # custom providers managed via config.yaml
"is_custom": True,
"key_source": "config_yaml" if cp_has_key else "none",
"models": cp_models,
"models_total": len(cp_models),
})
# Determine active provider
@@ -1994,6 +1996,18 @@ def get_providers() -> dict[str, Any]:
if isinstance(model_cfg, dict):
active_provider = model_cfg.get("provider")
# Sort providers: active first, then custom:*, then has_key, then rest.
def _provider_sort_key(p):
pid = p.get("id") or ""
if pid == active_provider:
return (0, pid)
if pid.startswith("custom:"):
return (1, pid)
if p.get("has_key"):
return (2, pid)
return (3, pid)
providers.sort(key=_provider_sort_key)
return {
"providers": providers,
"active_provider": active_provider,
+756 -32
View File
File diff suppressed because it is too large. Load diff
+42 -5
View File
@@ -1,11 +1,13 @@
"""RuntimeAdapter seam for WebUI-owned run execution.
This is the #1925 Slice 2 seam only. The default WebUI chat path remains the
This is the #1925 RuntimeAdapter seam. The default WebUI chat path remains the
legacy direct route; enabling ``HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal``
routes through this protocol-translator facade over the same legacy execution
path plus the Slice 1 run journal. This module intentionally does not own
AIAgent instances, cancellation flags, approval callbacks, clarify callbacks, or
new long-lived queues.
path plus the Slice 1 run journal. Slice 4 adds a default-off runner-local
selection point for tests and future runner backends, but live chat routes still
stay on the legacy path until a separate route-wiring slice is reviewed. This
module intentionally does not own AIAgent instances, cancellation flags,
approval callbacks, clarify callbacks, or new long-lived queues.
"""
from __future__ import annotations
@@ -17,7 +19,12 @@ from typing import Any, Callable, Iterable, Literal, Protocol
_RUNTIME_ADAPTER_ENV = "HERMES_WEBUI_RUNTIME_ADAPTER"
_RUNTIME_ADAPTER_DIRECT = "legacy-direct"
_RUNTIME_ADAPTER_JOURNAL = "legacy-journal"
_VALID_RUNTIME_ADAPTER_MODES = {_RUNTIME_ADAPTER_DIRECT, _RUNTIME_ADAPTER_JOURNAL}
_RUNTIME_ADAPTER_RUNNER_LOCAL = "runner-local"
_VALID_RUNTIME_ADAPTER_MODES = {
_RUNTIME_ADAPTER_DIRECT,
_RUNTIME_ADAPTER_JOURNAL,
_RUNTIME_ADAPTER_RUNNER_LOCAL,
}
@dataclass(frozen=True)
@@ -106,6 +113,36 @@ def runtime_adapter_enabled(environ: dict[str, str] | None = None) -> bool:
return runtime_adapter_mode(environ) == _RUNTIME_ADAPTER_JOURNAL
def runtime_adapter_runner_enabled(environ: dict[str, str] | None = None) -> bool:
return runtime_adapter_mode(environ) == _RUNTIME_ADAPTER_RUNNER_LOCAL
def build_runtime_adapter(
*,
environ: dict[str, str] | None = None,
legacy_adapter_factory: Callable[[], RuntimeAdapter] | None = None,
runner_client_factory: Callable[[], Any] | None = None,
) -> RuntimeAdapter | None:
"""Build the configured RuntimeAdapter without changing route behavior.
``None`` means the safe default ``legacy-direct`` path should keep using the
existing direct route. ``legacy-journal`` is opt-in and delegates to the
supplied legacy factory. ``runner-local`` is also opt-in and only constructs
a ``RunnerRuntimeAdapter`` around an injected client; this function does not
create process-global runner state or wire live chat to the runner backend.
"""
mode = runtime_adapter_mode(environ)
if mode == _RUNTIME_ADAPTER_DIRECT:
return None
if mode == _RUNTIME_ADAPTER_JOURNAL:
if legacy_adapter_factory is None:
raise NotImplementedError("legacy-journal mode requires a legacy adapter factory")
return legacy_adapter_factory()
if runner_client_factory is None:
raise NotImplementedError("runner-local mode requires a runner client factory")
return RunnerRuntimeAdapter(client=runner_client_factory())
def _cursor_to_after_seq(cursor: str | None) -> int | None:
if cursor in (None, ""):
return None
+45
View File
@@ -0,0 +1,45 @@
"""Lightweight in-process invalidation events for session sidebar state."""
import queue
import threading
_SESSION_EVENTS_LOCK = threading.Lock()
_SESSION_EVENTS_SUBSCRIBERS: set[queue.Queue] = set()
_SESSION_EVENTS_VERSION = 0
def publish_session_list_changed(reason: str = "session_changed") -> None:
"""Notify connected browsers that the session sidebar may be stale."""
global _SESSION_EVENTS_VERSION
with _SESSION_EVENTS_LOCK:
_SESSION_EVENTS_VERSION += 1
payload = {
"type": "sessions_changed",
"version": _SESSION_EVENTS_VERSION,
"reason": reason,
}
subscribers = list(_SESSION_EVENTS_SUBSCRIBERS)
for q in subscribers:
try:
q.put_nowait(payload)
except queue.Full:
try:
q.get_nowait()
except queue.Empty:
pass
try:
q.put_nowait(payload)
except queue.Full:
pass
def subscribe_session_events() -> queue.Queue:
q: queue.Queue = queue.Queue(maxsize=1)
with _SESSION_EVENTS_LOCK:
_SESSION_EVENTS_SUBSCRIBERS.add(q)
return q
def unsubscribe_session_events(q: queue.Queue) -> None:
with _SESSION_EVENTS_LOCK:
_SESSION_EVENTS_SUBSCRIBERS.discard(q)
+6 -5
View File
@@ -619,12 +619,13 @@ def recover_all_sessions_on_startup(
"If you weren't expecting this, check the session list for missing "
"messages — see #1558.", restored, scanned,
)
if rebuild_index:
try:
from api.models import _write_session_index
if rebuild_index:
try:
from api.models import SESSION_INDEX_FILE, _write_session_index
if restored or not SESSION_INDEX_FILE.exists():
_write_session_index(updates=None)
except Exception as exc:
logger.warning("recover_all_sessions_on_startup: index rebuild failed: %s", exc)
except Exception as exc:
logger.warning("recover_all_sessions_on_startup: index rebuild failed: %s", exc)
return {
"scanned": scanned,
"restored": restored,
+238 -19
View File
@@ -1209,17 +1209,23 @@ def _is_minimax_route(provider: str = '', model: str = '', base_url: str = '') -
return 'minimax' in text or 'minimaxi.com' in text
def _aux_title_configured() -> bool:
"""Return True when any auxiliary title_generation config field is meaningfully set."""
def _get_aux_title_config() -> dict:
"""Return title_generation auxiliary config, or an empty dict on errors."""
try:
from agent.auxiliary_client import _get_auxiliary_task_config
tg = _get_auxiliary_task_config('title_generation')
provider = tg.get('provider', '') or ''
model = tg.get('model', '') or ''
base_url = tg.get('base_url', '') or ''
return bool(model or base_url or (provider and provider.lower() != 'auto'))
return tg if isinstance(tg, dict) else {}
except Exception:
return False
return {}
def _aux_title_configured() -> bool:
"""Return True when any auxiliary title_generation config field is meaningfully set."""
tg = _get_aux_title_config()
provider = tg.get('provider', '') or ''
model = tg.get('model', '') or ''
base_url = tg.get('base_url', '') or ''
return bool(model or base_url or (provider and provider.lower() != 'auto'))
def _aux_title_timeout(default: float = 15.0) -> float:
"""Return the configured timeout (seconds) for auxiliary title generation.
@@ -1229,8 +1235,7 @@ def _aux_title_timeout(default: float = 15.0) -> float:
so mis-configurations are visible in server output.
"""
try:
from agent.auxiliary_client import _get_auxiliary_task_config
tg = _get_auxiliary_task_config('title_generation')
tg = _get_aux_title_config()
raw = tg.get('timeout')
if raw is None:
return default
@@ -1363,6 +1368,16 @@ def generate_title_raw_via_aux(
if not user_text or not assistant_text:
return None, 'missing_exchange'
qa, prompts = _title_prompts(user_text, assistant_text)
configured = _get_aux_title_config()
caller_supplied_route = bool(provider or model or base_url)
provider = provider or configured.get('provider', '') or ''
if str(provider).strip().lower() == 'auto':
provider = ''
model = model or configured.get('model', '') or ''
base_url = base_url or configured.get('base_url', '') or ''
api_key = ''
if not caller_supplied_route:
api_key = str(configured.get('api_key', '') or '').strip()
base_max_tokens = _title_completion_budget(provider, model, base_url)
reasoning_extra = {"reasoning": {"enabled": False}}
if _is_minimax_route(provider, model, base_url):
@@ -1384,6 +1399,7 @@ def generate_title_raw_via_aux(
provider=provider or None,
model=model or None,
base_url=base_url or None,
api_key=api_key or None,
messages=messages,
max_tokens=max_tokens,
temperature=0.2,
@@ -2042,6 +2058,14 @@ def _sanitize_messages_for_api(messages, *, cfg: dict = None):
# Skip persisted error markers — never send them to the LLM as prior context.
if msg.get('_error'):
continue
# Skip _partial markers with no visible content. Partial messages that
# carry actual text (e.g. "Python is a high-level…") are kept so the
# model can continue from the cut-off point (#893). But empty partials
# (reasoning-only or tool-only cancellations where thinking markup was
# stripped) have nothing for the model to continue from and cause
# API 400 errors on strict providers (empty assistant content).
if msg.get('_partial') and not str(msg.get('content') or '').strip():
continue
role = msg.get('role')
if role == 'tool':
tid = msg.get('tool_call_id') or ''
@@ -2075,6 +2099,10 @@ def _api_safe_message_positions(messages):
continue
if _is_reasoning_only_assistant_message(msg):
continue
if msg.get('_error'):
continue
if msg.get('_partial') and not str(msg.get('content') or '').strip():
continue
role = msg.get('role')
if role == 'tool':
tid = msg.get('tool_call_id') or ''
@@ -2086,6 +2114,26 @@ def _api_safe_message_positions(messages):
return out
def _deduplicate_context_messages(messages):
"""Remove duplicate messages from context by identity, keeping first occurrence.
Prevents the agent from seeing the same message twice in conversation_history
when result_messages contain duplicates that weren't caught by display-merge.
"""
if not messages:
return messages
seen = set()
deduped = []
for msg in messages:
key = _message_identity(msg)
if key is not None and key in seen:
continue
if key is not None:
seen.add(key)
deduped.append(msg)
return deduped
def _restore_reasoning_metadata(previous_messages, updated_messages):
"""Carry forward display-only metadata lost during API-safe history sanitization.
@@ -2170,6 +2218,22 @@ def _message_identity(msg):
# render two adjacent user bubbles ("Ok" and "[Workspace...]\nOk").
text = _strip_workspace_prefix(text, include_legacy=True)
if not text and not msg.get('tool_call_id') and not msg.get('tool_calls'):
# Empty assistant messages (e.g. _partial markers with no visible
# content) previously returned None, making them invisible to the
# merge dedup in _merge_display_messages_after_agent_result. This
# caused exponential accumulation: each turn's merge copied ALL
# prior _partial messages because they had no identity to track.
# Now, _partial messages with empty text get a stable identity
# keyed on their role + _partial flag + reasoning/tool metadata,
# so the merge can dedup identical empty partials.
if msg.get('_partial'):
reasoning_key = " ".join(str(msg.get('reasoning') or '').split())[:200]
return (
role,
'', # empty text
'', # no tool_call_id
'__partial__' + reasoning_key,
)
return None
return (
role,
@@ -2188,6 +2252,52 @@ def _messages_have_prefix(messages, prefix):
return True
def _message_replay_key(msg):
"""Return a stable comparison key for replay/overlap de-duplication."""
identity = _message_identity(msg)
if identity is not None:
return identity
if not isinstance(msg, dict):
return None
return (
str(msg.get('role') or ''),
_message_text(msg.get('content', '')),
str(msg.get('tool_call_id') or ''),
json.dumps(msg.get('tool_calls') or [], sort_keys=True, ensure_ascii=False),
)
def _strip_replayed_prefix(existing_messages, candidates):
"""Drop a candidate prefix that is already the suffix of existing_messages.
Compression/continuation can replay the active tail from state.db after the
previous WebUI context/display already contains it. Prefix-only merge logic
then treats that replayed tail as a fresh delta and duplicates a whole turn.
Strip the largest exact suffix/prefix overlap before appending.
"""
existing_messages = list(existing_messages or [])
candidates = list(candidates or [])
max_overlap = min(len(existing_messages), len(candidates))
for overlap in range(max_overlap, 0, -1):
left = [_message_replay_key(m) for m in existing_messages[-overlap:]]
right = [_message_replay_key(m) for m in candidates[:overlap]]
if left == right:
return candidates[overlap:]
return candidates
def _dedupe_replayed_active_context(previous_context, result_messages):
"""Keep model context append-only without re-appending a replayed tail."""
previous_context = list(previous_context or [])
result_messages = list(result_messages or [])
if not previous_context or not result_messages:
return result_messages
if not _messages_have_prefix(result_messages, previous_context):
return result_messages
candidates = result_messages[len(previous_context):]
return previous_context + _strip_replayed_prefix(previous_context, candidates)
def _is_context_compression_marker(msg):
if not isinstance(msg, dict):
return False
@@ -2425,6 +2535,30 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
only compaction marker messages plus the current user turn onward.
"""
previous_display = list(previous_display or [])
# Deduplicate stale _partial messages that accumulated in previous_display.
# A bug in cancel_stream() could insert multiple identical _partial messages
# when _stripped was empty but _has_reasoning/_has_tools was True. The
# merge's _message_identity previously returned None for empty _partial
# messages, so the seen-set couldn't catch them — they doubled each turn.
# Scan backwards and keep only the LAST occurrence of each unique _partial
# identity, then reverse back to original order.
_partial_seen = set()
_deduped_rev = []
for m in reversed(previous_display):
if isinstance(m, dict) and m.get('_partial'):
key = _message_identity(m)
if key is not None:
if key in _partial_seen:
continue
_partial_seen.add(key)
_deduped_rev.append(m)
_deduped = list(reversed(_deduped_rev))
if len(_deduped) < len(previous_display):
logger.debug(
"Deduplicated %d stale _partial messages from previous_display (was %d, now %d)",
len(previous_display) - len(_deduped), len(previous_display), len(_deduped),
)
previous_display = _deduped
previous_context = list(previous_context or [])
result_messages = list(result_messages or [])
if not result_messages:
@@ -2432,6 +2566,8 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
if _messages_have_prefix(result_messages, previous_context):
candidates = result_messages[len(previous_context):]
candidates = _strip_replayed_prefix(previous_display, candidates)
candidates = _strip_replayed_prefix(previous_context, candidates)
else:
current_user_idx = _find_current_user_turn(result_messages, msg_text)
marker_candidates = [
@@ -3500,10 +3636,20 @@ def _run_agent_streaming(
# closes over it) never captures an unbound name even if this
# block is reordered later (Issue #765).
_checkpoint_activity = [0]
_live_tool_event_start_ids = set()
_live_tool_event_complete_ids = set()
def _tool_args_snapshot(args):
args_snap = {}
if isinstance(args, dict):
for k, v in list(args.items())[:4]:
s2 = str(v)
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
return args_snap
def _record_live_tool_start(tool_call_id, name, args):
if not tool_call_id or tool_call_id in _live_prompt_estimate_seen_ids:
return
return False
_live_prompt_estimate_seen_ids.add(tool_call_id)
_tool_call = {
'id': tool_call_id,
@@ -3518,10 +3664,11 @@ def _run_agent_streaming(
'content': '',
'tool_calls': [_tool_call],
}])
return True
def _record_live_tool_complete(tool_call_id, name, function_result):
if not tool_call_id:
return
return False
_result_text = _tool_result_snippet(function_result)
_bump_live_prompt_estimate([{
'role': 'tool',
@@ -3529,6 +3676,7 @@ def _run_agent_streaming(
'tool_call_id': tool_call_id,
'content': _result_text,
}])
return True
def on_tool(*cb_args, **cb_kwargs):
nonlocal _reasoning_text
@@ -3561,11 +3709,14 @@ def _run_agent_streaming(
_emit_metering()
return
args_snap = {}
if isinstance(args, dict):
for k, v in list(args.items())[:4]:
s2 = str(v)
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
args_snap = _tool_args_snapshot(args)
# Modern Hermes Agent builds can call both tool_progress_callback
# and the structured tool_start/tool_complete callbacks for the
# same tool. Prefer the structured path when it is supported so
# the browser receives one tid-tagged tool card per real call.
if event_type in (None, 'tool.started') and 'tool_start_callback' in _agent_params:
return
if event_type in (None, 'tool.started'):
_live_tool_calls.append({
@@ -3602,6 +3753,9 @@ def _run_agent_streaming(
pass
return
if event_type == 'tool.completed' and 'tool_complete_callback' in _agent_params:
return
if event_type == 'tool.completed':
for live_tc in reversed(_live_tool_calls):
if live_tc.get('done'):
@@ -3641,6 +3795,28 @@ def _run_agent_streaming(
def on_tool_start(tool_call_id, name, args):
try:
_record_live_tool_start(tool_call_id, name, args)
if tool_call_id and tool_call_id not in _live_tool_event_start_ids:
_live_tool_event_start_ids.add(tool_call_id)
_live_tool_calls.append({
'name': name,
'args': args if isinstance(args, dict) else {},
'tid': tool_call_id,
})
# Mirror to shared dict so cancel_stream() can persist it (#1361 §B)
if stream_id in STREAM_LIVE_TOOL_CALLS:
STREAM_LIVE_TOOL_CALLS[stream_id].append({
'name': name,
'args': args if isinstance(args, dict) else {},
'done': False,
'tid': tool_call_id,
})
put('tool', {
'event_type': 'tool.started',
'name': name,
'preview': None,
'args': _tool_args_snapshot(args),
'tid': tool_call_id,
})
_tool_stats = meter().get_stats()
_tool_stats['session_id'] = session_id
_tool_stats['usage'] = _live_usage_snapshot()
@@ -3651,6 +3827,33 @@ def _run_agent_streaming(
def on_tool_complete(tool_call_id, name, args, function_result):
try:
_record_live_tool_complete(tool_call_id, name, function_result)
if tool_call_id and tool_call_id not in _live_tool_event_complete_ids:
_live_tool_event_complete_ids.add(tool_call_id)
result_snippet = _tool_result_snippet(function_result)
for live_tc in reversed(_live_tool_calls):
if live_tc.get('done'):
continue
if live_tc.get('tid') == tool_call_id or (not live_tc.get('tid') and live_tc.get('name') == name):
live_tc['done'] = True
live_tc['snippet'] = result_snippet
break
if stream_id in STREAM_LIVE_TOOL_CALLS:
for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]):
if shared_tc.get('done'):
continue
if shared_tc.get('tid') == tool_call_id or (not shared_tc.get('tid') and shared_tc.get('name') == name):
shared_tc['done'] = True
shared_tc['snippet'] = result_snippet
break
_checkpoint_activity[0] += 1
put('tool_complete', {
'event_type': 'tool.completed',
'name': name,
'preview': result_snippet,
'args': _tool_args_snapshot(args),
'tid': tool_call_id,
'is_error': False,
})
_tool_stats = meter().get_stats()
_tool_stats['session_id'] = session_id
_tool_stats['usage'] = _live_usage_snapshot()
@@ -4085,6 +4288,10 @@ def _run_agent_streaming(
),
msg_text,
)
# Dedup before feeding to agent — merge_session_messages_append_only
# can produce duplicates when context_messages and state.db share
# messages with different timestamps.
_previous_context_messages = _deduplicate_context_messages(_previous_context_messages)
_pre_compression_count = getattr(
getattr(agent, 'context_compressor', None),
'compression_count', 0,
@@ -4244,7 +4451,11 @@ def _run_agent_streaming(
_previous_context_messages,
_result_messages,
)
s.context_messages = _next_context_messages
_next_context_messages = _dedupe_replayed_active_context(
_previous_context_messages,
_next_context_messages,
)
s.context_messages = _deduplicate_context_messages(_next_context_messages)
s.messages = _merge_display_messages_after_agent_result(
_previous_messages,
_previous_context_messages,
@@ -4387,7 +4598,11 @@ def _run_agent_streaming(
_previous_context_messages,
_result_messages,
)
s.context_messages = _next_context_messages
_next_context_messages = _dedupe_replayed_active_context(
_previous_context_messages,
_next_context_messages,
)
s.context_messages = _deduplicate_context_messages(_next_context_messages)
s.messages = _merge_display_messages_after_agent_result(
_previous_messages,
_previous_context_messages,
@@ -5203,7 +5418,11 @@ def _run_agent_streaming(
_next_context_messages = _restore_reasoning_metadata(
_previous_context_messages, _result_messages,
)
s.context_messages = _next_context_messages
_next_context_messages = _dedupe_replayed_active_context(
_previous_context_messages,
_next_context_messages,
)
s.context_messages = _deduplicate_context_messages(_next_context_messages)
s.messages = _merge_display_messages_after_agent_result(
_previous_messages,
_previous_context_messages,
+172 -40
View File
@@ -10,10 +10,13 @@ Skips repos that are not git checkouts (e.g. Docker baked images where
"""
import hashlib
import json
import os
import re
import subprocess
import threading
import time
import urllib.error
import urllib.request
from collections import OrderedDict
from pathlib import Path
from urllib.parse import urlparse
@@ -33,6 +36,28 @@ _cache_lock = threading.Lock()
_check_in_progress = False
_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo
CACHE_TTL = 1800 # 30 minutes
_GIT_DIAGNOSTIC_MAX_CHARS = 300
_CREDENTIAL_IN_URL_RE = re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://)([^/@\s'\"]+)@")
_GITHUB_TOKEN_RE = re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b")
_QUERY_SECRET_RE = re.compile(r"([?&](?:access_token|token|password|auth|key)=)[^&\s'\"]+", re.IGNORECASE)
def _sanitize_git_diagnostic(output: str, *, limit: int = _GIT_DIAGNOSTIC_MAX_CHARS) -> str:
"""Return a user-facing git diagnostic with credentials removed.
Git can echo remote URLs in failure output. Keep the actionable error text,
but strip URL userinfo, common GitHub token shapes, and secret-looking query
parameter values before any message reaches the update-check API/UI.
"""
if not output:
return ""
sanitized = _CREDENTIAL_IN_URL_RE.sub(r"\1<redacted>@", str(output))
sanitized = _GITHUB_TOKEN_RE.sub("<redacted>", sanitized)
sanitized = _QUERY_SECRET_RE.sub(r"\1<redacted>", sanitized)
sanitized = sanitized.strip()
if len(sanitized) > limit:
sanitized = sanitized[:limit].rstrip() + "…"
return sanitized
def _active_stream_count() -> int:
@@ -142,30 +167,104 @@ def _detect_webui_version() -> str:
return 'unknown'
def _read_agent_source_version(agent_dir: Path) -> str | None:
"""Read Hermes Agent's package version from a copied source tree."""
init_file = agent_dir / 'hermes_cli' / '__init__.py'
try:
text = init_file.read_text(encoding='utf-8')
except (OSError, UnicodeDecodeError):
return None
m = re.search(r"""__version__\s*=\s*['"]([^'"]+)['"]""", text)
if m and m.group(1).strip():
return m.group(1).strip()
return None
def _gateway_health_base_url() -> str:
"""Return the configured/default Hermes Agent gateway base URL."""
raw = (
os.environ.get('GATEWAY_HEALTH_URL')
or os.environ.get('HERMES_GATEWAY_HEALTH_URL')
or 'http://hermes-agent:8642'
).strip()
if raw.endswith('/health/detailed'):
raw = raw[: -len('/health/detailed')]
elif raw.endswith('/health'):
raw = raw[: -len('/health')]
return raw.rstrip('/')
def _version_from_gateway_health_payload(payload: object) -> str | None:
"""Extract a version string from a Hermes Agent gateway health payload."""
if not isinstance(payload, dict):
return None
for key in ('version', 'agent_version', 'hermes_version'):
value = payload.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
nested = payload.get('agent')
if isinstance(nested, dict):
value = nested.get('version')
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _detect_agent_version_from_gateway_health(timeout: float = 0.75) -> str | None:
"""Best-effort cross-container gateway API fallback for Agent version."""
base = _gateway_health_base_url()
if not base:
return None
parsed = urlparse(base)
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
return None
for path in ('/health', '/health/detailed'):
try:
with urllib.request.urlopen(f'{base}{path}', timeout=timeout) as resp:
payload = json.loads(resp.read().decode('utf-8'))
except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError, UnicodeDecodeError):
continue
version = _version_from_gateway_health_payload(payload)
if version:
return version
return None
def _detect_agent_version() -> str:
"""Detect the running Hermes Agent version for UI display."""
if _AGENT_DIR is None:
return 'not detected'
agent_dir = Path(_AGENT_DIR) if _AGENT_DIR is not None else None
version_file = Path(_AGENT_DIR) / "VERSION"
try:
if version_file.exists():
text = version_file.read_text(encoding='utf-8').strip()
if text:
return text
except Exception:
pass
if agent_dir is not None:
version_file = agent_dir / "VERSION"
try:
if version_file.exists():
text = version_file.read_text(encoding='utf-8').strip()
if text:
return text
except Exception:
pass
# Fallback: infer from git describe when the checkout exists but no VERSION
# file is available (common in source checkouts and developer environments).
if not Path(_AGENT_DIR).exists():
return 'not detected'
# Symmetric with _detect_webui_version() above — `--dirty` flags a
# locally-modified checkout so operators can see when their agent has
# uncommitted changes vs a clean tag. Per Opus advisor on stage-293.
out = _describe_git_version(Path(_AGENT_DIR))
if out:
return out
# Fallback: infer from git describe when the checkout exists but no VERSION
# file is available (common in source checkouts and developer environments).
if agent_dir.exists():
# Symmetric with _detect_webui_version() above — `--dirty` flags a
# locally-modified checkout so operators can see when their agent has
# uncommitted changes vs a clean tag. Per Opus advisor on stage-293.
out = _describe_git_version(agent_dir)
if out:
return out
# Docker two-container deployments often mount a copied agent source
# tree without .git metadata or a VERSION file. The package version
# still lives in hermes_cli/__init__.py, so prefer that before giving
# up or relying on a live gateway probe.
source_version = _read_agent_source_version(agent_dir)
if source_version:
return source_version
gateway_version = _detect_agent_version_from_gateway_health()
if gateway_version:
return gateway_version
return 'not detected'
@@ -252,6 +351,26 @@ def _release_gap(tags, current, latest):
return 1
def _select_apply_compare_ref(path):
"""Return the same remote ref family that the update check reports.
The update banner prefers published release tags when they exist. Applying
an update must therefore advance to the latest release tag too; otherwise a
checkout on a local/fork tracking branch can report release updates, pull a
different branch that is already current, restart, and still remain behind.
"""
tags = _release_tags(path)
if tags:
return tags[0]
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
return upstream
branch = _detect_default_branch(path)
return f'origin/{branch}'
def _check_repo_release(path, name):
"""Check if a git repo is behind its latest published release tag."""
tags = _release_tags(path)
@@ -358,9 +477,30 @@ def _check_repo(path, name):
# Fetch tags first so update prompts track published releases, not every
# development commit that lands on master/main after the latest release.
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags'], path, timeout=15)
#
# --force is required because the WebUI is a release-tracking consumer:
# it never pushes tags, so it should always defer to whatever the remote
# says a release tag points to. Without --force, a remote re-tag (e.g.
# after a squash-merge that re-points a release tag at a new SHA) jams
# the update path indefinitely with "would clobber existing tag" errors.
# See #2756.
fetch_out, fetch_ok = _run_git(['fetch', 'origin', '--tags', '--force'], path, timeout=15)
if not fetch_ok:
return {'name': name, 'behind': 0, 'error': 'fetch failed'}
release_info = _check_repo_release(path, name)
message = 'fetch failed'
if fetch_out:
message = f'{message}: {_sanitize_git_diagnostic(fetch_out)}'
if release_info is not None:
release_info = dict(release_info)
release_info['error'] = message
release_info['stale_check'] = True
return release_info
return {
'name': name,
'behind': None,
'error': message,
'stale_check': True,
}
release_info = _check_repo_release(path, name)
if release_info is not None:
@@ -763,19 +903,17 @@ def apply_force_update(target: str) -> dict:
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
# --force so a remote re-tag (e.g. squash-merge that re-points an
# existing release tag) doesn't jam the apply path with "would clobber
# existing tag". See #2756.
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags', '--force'], path, timeout=15)
if not fetch_ok:
return {
'ok': False,
'message': 'Could not reach the remote repository. Check your connection.',
}
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
compare_ref = _select_apply_compare_ref(path)
# Discard local modifications then reset to remote HEAD
_run_git(['checkout', '.'], path)
@@ -824,17 +962,9 @@ def _apply_update_inner(target):
if path is None or not (path / '.git').exists():
return {'ok': False, 'message': 'Not a git repository'}
# Use the current branch's upstream for pull, matching the behaviour
# of _check_repo. Falls back to default branch if no upstream is set.
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
compare_ref = upstream
else:
branch = _detect_default_branch(path)
compare_ref = f'origin/{branch}'
# Fetch before attempting pull, so the remote ref is current.
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
# --force so a remote re-tag doesn't block the update path (see #2756).
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags', '--force'], path, timeout=15)
if not fetch_ok:
return {
'ok': False,
@@ -844,6 +974,8 @@ def _apply_update_inner(target):
),
}
compare_ref = _select_apply_compare_ref(path)
# Check for dirty working tree (ignore untracked files — git stash
# doesn't include them, so stashing on '??' alone leaves nothing to pop)
status_out, status_ok = _run_git(
@@ -879,7 +1011,7 @@ def _apply_update_inner(target):
if remote:
pull_args.extend([remote, branch])
else:
pull_args.append(compare_ref)
pull_args.extend(['origin', compare_ref])
pull_out, pull_ok = _run_git(pull_args, path, timeout=30)
if not pull_ok:
if stashed:
+40 -1
View File
@@ -7,6 +7,7 @@ profile has its own workspace configuration. State files live at
``{profile_home}/webui_state/last_workspace.txt``. The global STATE_DIR
paths are used as fallback when no profile module is available.
"""
import hashlib
import json
import logging
import os
@@ -714,12 +715,18 @@ def list_dir(workspace: Path, rel: str='.'):
display_path = str(Path(item.name))
if rel and rel != '.':
display_path = rel + '/' + display_path
try:
item_stat = item.lstat()
mtime_ns = item_stat.st_mtime_ns
except OSError:
mtime_ns = None
entry = {
'name': item.name,
'path': display_path,
'type': 'symlink',
'target': str(link_target),
'is_dir': is_dir,
'mtime_ns': mtime_ns,
}
if not is_dir:
try:
@@ -733,17 +740,49 @@ def list_dir(workspace: Path, rel: str='.'):
entry_path = item.name
if rel and rel != '.':
entry_path = rel + '/' + item.name
try:
item_stat = item.stat()
size = item_stat.st_size if item.is_file() else None
mtime_ns = item_stat.st_mtime_ns
except OSError:
size = None
mtime_ns = None
entries.append({
'name': item.name,
'path': entry_path,
'type': 'dir' if item.is_dir() else 'file',
'size': item.stat().st_size if item.is_file() else None,
'size': size,
'mtime_ns': mtime_ns,
})
if len(entries) >= 200:
break
return entries
def dir_signature(workspace: Path, rel: str = '.', entries: list[dict] | None = None) -> str:
"""Return a cheap, stable signature for a listed workspace directory.
The signature is based only on bounded directory-entry metadata already used
by the workspace tree: names, displayed paths, entry type, file sizes,
mtimes, and symlink targets. It intentionally does not read file contents.
"""
if entries is None:
entries = list_dir(workspace, rel)
payload = []
for entry in entries:
payload.append({
'name': entry.get('name'),
'path': entry.get('path'),
'type': entry.get('type'),
'is_dir': entry.get('is_dir'),
'size': entry.get('size'),
'mtime_ns': entry.get('mtime_ns'),
'target': entry.get('target'),
})
raw = json.dumps(payload, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def read_file_content(workspace: Path, rel: str) -> dict:
target = safe_resolve_ws(workspace, rel)
if not target.is_file():
+1261
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -64,8 +64,8 @@ that slice.
Current appearance has a theme axis (`light`, `dark`, `system`) and a separate
skin axis (`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`,
`charizard`, `sienna`, `catppuccin`, `nous`) in `static/boot.js` and
`static/style.css`. Do not follow stale `data-theme`-only theme guidance unless
`charizard`, `sienna`, `catppuccin`, `nous`, `geist-contrast`) in
`static/boot.js` and `static/style.css`. Do not follow stale `data-theme`-only theme guidance unless
the current code and tests prove that model still applies.
For UI or UX work, include before/after evidence, verify relevant responsive
+1 -1
View File
@@ -150,7 +150,7 @@ Current implementation has two appearance axes, sourced from `static/boot.js`:
`theme` is only `light`, `dark`, or `system` and resolves to the `.dark` class
for dark mode; `skin` is a separate axis applied with `data-skin` and currently
includes `default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`,
`charizard`, `sienna`, `catppuccin`, and `nous`. `slate` is both an active skin
`charizard`, `sienna`, `catppuccin`, `nous`, and `geist-contrast` / Geist Contrast. `slate` is both an active skin
and a legacy theme-name migration target; `solarized`, `monokai`, `nord`, and
`oled` are legacy theme names mapped to current theme/skin pairs. Do not follow
stale `data-theme`-only guidance without first proving the current
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

+132 -7
View File
@@ -4,7 +4,7 @@
- **Author:** @Michaelyklam
- **Updated by:** @franksong2702
- **Created:** 2026-05-11
- **Revised:** 2026-05-17
- **Revised:** 2026-05-21
- **Tracking issue:** [#1925](https://github.com/nesquena/hermes-webui/issues/1925)
## Credit and Scope
@@ -52,7 +52,7 @@ The immediate goal is not to build a sidecar. The immediate goal is to define th
browser contract, classify current runtime state, and gate the first reversible
journal slice.
## Current Gate State — 2026-05-19
## Current Gate State — 2026-05-21
Slice 1 is now past the first active validation gate:
@@ -94,11 +94,21 @@ adapter-seam work:
`queue_message(...)` as a staged protocol method only; `/queue` remains
browser-side queue/drain behavior, and no server-side queue endpoint or queue
scheduler should be added merely for adapter symmetry.
- #2575 shipped the Slice 4a runner/sidecar contract gate in v0.51.93. The next
implementation step can add runner-backend adapter plumbing, but it must stay
default-off, keep legacy fallback intact, pass explicit profile/workspace/model
payloads instead of mutating WebUI process globals, and avoid recreating
`STREAMS` / `CANCEL_FLAGS` / approval queues / clarify queues under new names.
- #2575 shipped the Slice 4a runner/sidecar contract gate in v0.51.93.
- #2599 shipped the Slice 4b `RunnerRuntimeAdapter` facade in v0.51.94. The
facade normalizes an injected runner client's start / observe / status /
control responses without owning `AIAgent`, streams, cancellation flags,
approval queues, clarify queues, goal state, or cached-agent tables.
- #2627 shipped the Slice 4c runner-backend harness gate in v0.51.96.
- #2696 shipped the first Slice 4c implementation in v0.51.105: the default-off
`runner-local` adapter selection point and `build_runtime_adapter(...)`
factory wiring around an injected runner client. Live browser chat routes still
stay on the legacy backend, and no supervised runner process exists yet.
- The next implementation gate is a supervised/local runner backend proposal and
route-selection harness. It must stay default-off, keep legacy fallback intact,
pass explicit profile/workspace/model payloads instead of mutating WebUI
process globals, and avoid recreating `STREAMS` / `CANCEL_FLAGS` / approval
queues / clarify queues under new names.
The next gate is runner-backend plumbing, not queue implementation
by default. Queue / continue routing should only move before Slice 4 if a future
@@ -753,6 +763,8 @@ Non-goals for Slice 4a:
#### Slice 4b: Runner adapter client facade
Status as of 2026-05-20: shipped in v0.51.94 via #2599.
The first code slice after the Slice 4a contract should be a small
`RunnerRuntimeAdapter` facade that delegates to an injected runner client. This
is still not the runner process itself. Its job is to pin the adapter-facing
@@ -771,6 +783,119 @@ normalization rules before route wiring or process supervision lands:
The implementation remains default-off until a later slice adds an actual runner
client/backend and explicit route selection.
#### Slice 4c: Feature-flagged runner backend and restart/reattach harness
Status as of 2026-05-21: shipped in v0.51.105 via #2696. The code adds a
default-off `runner-local` adapter selection point plus factory wiring for an
injected runner client, while keeping live browser chat routes on the legacy
backend. The restart/reattach harness remains synthetic/fake-runner based until a
later slice introduces a supervised runner process.
After the facade exists, the next narrow implementation slice should add a real
runner-client/backend selection point and a synthetic restart/reattach harness,
without routing normal browser chat to that backend yet.
Scope:
- add a concrete runner-client factory behind an explicit mode such as
`HERMES_WEBUI_RUNTIME_ADAPTER=runner-local`, while preserving `legacy-direct`
and `legacy-journal` as the default/revert paths;
- validate that `StartRunRequest` carries explicit session, profile, workspace,
attachments, provider/model, toolset, source, and metadata fields into the
runner boundary without relying on WebUI process-global environment mutation;
- prove a recreated WebUI adapter can rediscover runner-owned status and replay
ordered events from the runner/journal surface after process-local state is
discarded;
- keep controls bounded through `ControlResult` values, with unsupported controls
returning `unsupported` / `not-active` rather than falling back to stale legacy
`STREAMS` or callback queues;
- keep the live `/api/chat/start` path on the legacy backend until the runner
backend has a passing restart/reattach harness and maintainer approval to wire
route selection.
Acceptance tests for Slice 4c:
1. **Default-off selection.** `legacy-direct` remains the default; `runner-local`
or any later runner mode is selected only by an explicit feature flag.
2. **No route-shape drift.** Adding the runner backend does not expand public
`/api/chat/start`, cancel, approval, clarify, goal, or status response shapes
while the route remains legacy-backed.
3. **Restart/reattach harness.** A fake or local runner fixture can start a run,
discard the first WebUI adapter instance, recreate the adapter, and still
observe ordered events plus terminal/live status from durable runner-owned
state.
4. **Control bounds.** Cancel / approval / clarify / queue / goal controls route
through the runner client only when the runner backend is selected, and
unsupported controls return bounded `ControlResult` values without consulting
legacy process-local state.
5. **No runtime-surrogate globals.** The main WebUI server must not gain new
module-level maps for runner-owned streams, cancellation flags, pending
approval/clarify callbacks, cached agents, or goal/queue schedulers.
Non-goals for Slice 4c:
- no default-on runner backend;
- no removal of the legacy in-process backend;
- no public response-shape expansion;
- no live chat route switch to the runner backend before the restart/reattach
harness is reviewed;
- no server-side queue endpoint or queue scheduler just for adapter symmetry.
#### Slice 4d: Supervised runner backend route gate
After `runner-local` selection exists, the next reviewable gate should define the
first supervised/local runner backend and the route-selection harness before live
browser chat can use it. This is still a contract/test slice first: no default-on
runner mode, no removal of `legacy-direct` or `legacy-journal`, and no claim that
production WebUI turns survive restart until the harness demonstrates it.
Scope:
- define the runner process/client lifecycle: how a run is spawned, supervised,
observed, and terminated without placing new active-run maps in the main WebUI
request process;
- define the route-selection point for `/api/chat/start` without changing the
public response shape while the default remains legacy-backed;
- specify how runner-owned events become WebUI journal events with stable
cursors, terminal state, and replay ordering;
- specify cancellation, approval, clarify, goal, and staged queue behavior as
runner-client `ControlResult` responses, with unsupported controls bounded and
visible rather than silently falling back to legacy process-local callbacks;
- carry the runtime API gap matrix forward: missing Hermes-owned capabilities
such as active-run discovery, session-to-run lookup, command capability
metadata, artifact events, and provider/tool routing should remain explicit
gaps or temporary adapter state, not private WebUI runtime replicas.
Acceptance tests for Slice 4d:
1. **Route remains default-off.** Unset `HERMES_WEBUI_RUNTIME_ADAPTER` and
`legacy-direct` keep `/api/chat/start` on the existing path; `runner-local`
is the only mode allowed to select the runner route.
2. **Restart/reattach harness proves ownership moved.** A fake or local runner
starts a run, the first WebUI server/adapter instance is discarded, a new
adapter instance discovers the same active or terminal run, replay catches up
from cursor, and cancel remains available if the run is still active.
3. **No public response-shape drift.** Chat start and control responses remain
stable while adapter-only fields stay internal or explicitly documented as a
new contract revision.
4. **No runtime-surrogate globals.** The main WebUI server does not gain new
module-level maps for runner-owned streams, cancel flags, approval/clarify
callbacks, cached agents, goal state, or queue schedulers.
5. **Explicit context payloads.** Runner startup carries session, profile,
workspace, attachments, provider/model, toolsets, source, and metadata as
payload fields rather than depending on process-global environment mutation in
the WebUI server.
Non-goals for Slice 4d:
- no default-on runner backend;
- no removal of the legacy in-process backends;
- no server-side queue endpoint or scheduler just for adapter symmetry;
- no permanent WebUI-owned active-run discovery cache when the missing capability
belongs in a runner or future Hermes Runtime API;
- no broad UI/product surface migration; WebUI remains the rich workbench while
only execution ownership moves.
## First Meaningful Success Criteria
The first meaningful milestones are deliberately split.
+28
View File
@@ -85,6 +85,34 @@ If after running steps 1-4 the import still fails *and* `pip install -e .` succe
---
## "Response interrupted." marker keeps saying "no agent output was recovered"
**Symptom.** After the WebUI process restarts mid-turn (manual restart, OOM, crash, …), the affected chat shows an `**Response interrupted.**` marker with the wording *"The user message above was preserved, but no agent output was recovered."*, even though the run-journal for that turn is present on disk and contains the partial tokens the agent had already streamed.
**Why.** Sidecar repair re-checks the run-journal at restart and uses the result as a one-shot signal. On WSL2 (9p / DrvFs) and on some network-backed setups, the run-journal `.jsonl` is written by the dead worker but the WebUI process reads it through a page-cache state that has not yet seen those writes — recovery returns "empty" and the marker is baked permanently. The fix introduces a *lazy* retry path: when sidecar repair cannot read visible output but knows the stream id, it stores a `_pending_journal_recovery` flag on the marker and re-attempts recovery from `get_session()` until the journal becomes readable (or the retry budget is exhausted).
**Diagnostic.**
The on-disk locations below assume the default `~/.hermes/webui` state directory. If you override it via `HERMES_WEBUI_STATE_DIR`, substitute that path for `~/.hermes/webui` in every step.
1. Identify the affected session id and stream id from the marker. The marker JSON lives at `~/.hermes/webui/sessions/<sid>.json`; after the fix it shows them on the `_journal_retry_stream_id` key. Pre-fix sessions only carry the legacy wording, with no retry meta.
2. Check whether the run-journal contains real events:
```bash
ls -la ~/.hermes/webui/sessions/_run_journal/<sid>/<stream_id>.jsonl
head -2 ~/.hermes/webui/sessions/_run_journal/<sid>/<stream_id>.jsonl
```
If the file exists and contains `token` / `tool` events, the lazy-retry path will pick them up the next time the session is opened.
**Fix.** Reload the session in the browser. On the next `get_session()` call the marker is re-evaluated; if the journaled events are visible on disk the marker promotes to *"The partial output above was recovered from the run journal …"* wording and the journaled assistant text + tool cards land above the marker in chronological order. No manual sidecar editing is required.
**Trigger.** Sidebar metadata polling is intentionally not enough to run this self-heal. Requests such as `/api/session?messages=0&resolve_model=0` load the session with `metadata_only=True`, skip the full messages array, and therefore skip the lazy journal retry helper. Click/open the affected conversation so the message panel performs a full `messages=1` load; that full render is what re-checks the journal and can promote the marker.
**Caps.** The lazy retry path gives up after 12 failed attempts or 24h of wall-clock age, at which point the marker is demoted to a neutral *"Partial output may have been lost."* wording so the "reload to retry" prompt doesn't linger forever for genuinely lost journals.
**When to file a bug.** If, after the fix, you see the lazy-retry wording (*"Recovering the partial output from the run journal — reload this session to retry."*) but reloading the session never promotes it to the recovered wording even though the `.jsonl` clearly contains `token` events, capture the marker JSON and the run-journal file and file a bug.
---
## Other troubleshooting
This document grows over time. If a recurring failure mode isn't covered here yet, add it via PR. The format for each entry: **Symptom → Why → Diagnostic commands → Fix → When to file a bug**.
View File
File renamed without changes.
+92
View File
@@ -0,0 +1,92 @@
# Workspace Git controls
Workspace Git controls let the browser inspect Git state for the active session workspace. By default,
WebUI can read status, list branches, show diffs, fetch remote refs, and generate commit-message
suggestions. Actions that modify the repository, index, or worktree are disabled unless the WebUI
process is started with `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1`.
> **Trust model - read this first.** Once mutating Git actions are enabled, a browser action can run
> Git commands inside a mounted workspace. Some Git commands can also run repository hook code from
> `.git/hooks/` or a configured `core.hooksPath`. That hook code runs as the WebUI process user, with
> the WebUI process permissions and environment. Treat hooks that download or execute code as code
> execution by the WebUI process user.
## What works by default
Without `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1`, WebUI can:
- show repository status
- list branches
- show file diffs
- fetch from the configured remote
- generate commit-message suggestions
Fetch may update remote-tracking refs, but it does not change the worktree, merge branches, create
commits, or push changes.
Commit-message generation may send staged or selected diff context to the configured model provider.
The UI labels this before generation.
Diffs for untracked files are size checked before WebUI reads file contents. Large or binary files
return metadata instead of inline diff text.
## What requires explicit enablement
These actions are blocked unless `HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE=1` is set:
- stage and unstage
- discard changes
- commit and selected-file commit
- pull and push
- checkout
- branch switching that parks and restores local changes
Leave the flag unset for deployments where WebUI should only inspect mounted workspaces. Set it only
when browser users are trusted to modify those repositories from WebUI.
When the flag is enabled, branch switching parks and restores local changes automatically. If the
branch being left has local changes, WebUI parks those changes in a WebUI-owned Git stash, switches
branches, and then restores any WebUI-owned stash for the branch being entered. If Git cannot restore
the stash cleanly, WebUI leaves the stash in place and reports the restore failure instead of dropping
it.
## Workspace and path scope
The browser does not send an arbitrary repository path. Git requests carry a session id and, when
needed, workspace-relative file paths. The server resolves the session workspace, checks each path
against that workspace, and then builds Git pathspecs from the checked paths.
Git commands run through `subprocess.run` with `shell=False`. Local status and diff commands use a 5
second timeout. Remote operations such as fetch, pull, and push use a 60 second timeout.
Before any Git subprocess starts, WebUI removes inherited `GIT_DIR`, `GIT_WORK_TREE`,
`GIT_CONFIG_GLOBAL`, `GIT_CONFIG_SYSTEM`, `GIT_CONFIG_COUNT`, `GIT_CONFIG_PARAMETERS`, and injected
`GIT_CONFIG_KEY_*` / `GIT_CONFIG_VALUE_*` values from the environment. Those variables can redirect
Git to a different repository or inject config, so WebUI does not trust them from the parent process.
`GIT_INDEX_FILE` is the intentional exception. Selected-file commits use a temporary index so WebUI
can commit only the requested files, then remove the temporary index afterward.
## Coordination with active runs
Mutating Git actions are rejected while the same session has a live stream. The API returns
`active_stream` instead of racing a running agent that may be writing files in the same workspace.
Mutating Git actions also take a per-repository lock. If another Git mutation is already running for
that repository, the API returns `operation_in_progress`.
## Hook and remote behavior
WebUI does not bypass Git hooks. Hook code may come from `.git/hooks/` or from a configured
`core.hooksPath`.
Commit actions run normal commit hooks such as `pre-commit`, `commit-msg`, and `post-commit`. Push can
run `pre-push`. Pull uses `--ff-only`, so it does not create a merge commit, but it is still a Git
operation running under the WebUI process.
Push keeps Git's normal non-fast-forward protection and reports non-fast-forward rejection separately
from general Git failures.
If a hook fails, the API returns a structured Git error instead of hiding the failure. Other classified
failures include authentication errors, missing upstream branches, conflicts, dirty worktrees, invalid
refs, missing Git binaries, and timeouts.
+131 -23
View File
@@ -283,6 +283,26 @@ function expandSidebar(){
}catch(_){}
_syncSidebarAria();
})();
// ── Boot-time tab visibility ────────────────────────────────────────────────
// Apply hidden tabs from localStorage. The primary flash-prevention is an
// inline <script> in index.html (after sidebar-nav) that runs synchronously
// before first paint. This IIFE is a secondary fallback: it ensures consistency
// after panels.js is loaded and handles the active-tab switch. No-op if
// panels.js hasn't loaded yet (typeof guard).
(function _restoreTabVisibility(){
try{
if(typeof _applyTabVisibility==='function'&&typeof _getHiddenTabs==='function'){
_applyTabVisibility(_getHiddenTabs());
}
var active=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]')
||document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');
if(active&&active.classList.contains('nav-tab-hidden')){
var chatBtn=document.querySelector('.rail .rail-btn.nav-tab[data-panel="chat"]');
if(chatBtn)chatBtn.classList.add('active');
if(active)active.classList.remove('active');
}
}catch(_){}
})();
function toggleMobileFiles(){
toggleWorkspacePanel();
}
@@ -935,6 +955,11 @@ $('modelSelect').onchange=async()=>{
if(typeof closeModelDropdown==='function') closeModelDropdown();
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
if(typeof _rememberPendingSessionModel==='function') _rememberPendingSessionModel(S.session.session_id,modelState.model,modelState.model_provider);
S.session.model=modelState.model;
S.session.model_provider=modelState.model_provider||null;
if(typeof syncModelChip==='function') syncModelChip();
syncTopbar();
// Clarify scope: composer model changes are session-local, not the global default.
if(typeof showToast==='function'){
showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000);
@@ -945,10 +970,12 @@ $('modelSelect').onchange=async()=>{
model:modelState.model,
model_provider:modelState.model_provider||null,
})});
S.session.model=modelState.model;
S.session.model_provider=modelState.model_provider||null;
if(typeof syncModelChip==='function') syncModelChip();
syncTopbar();
if(typeof _readPendingSessionModel==='function'&&typeof _clearPendingSessionModel==='function'){
const pending=_readPendingSessionModel(S.session.session_id);
if(!pending||(pending.model===modelState.model&&String(pending.model_provider||'')===String(modelState.model_provider||''))){
_clearPendingSessionModel(S.session.session_id);
}
}
_applySessionContextMetadataUpdate(data);
// Warn if selected model belongs to a different provider than what Hermes is configured for
if(typeof _checkProviderMismatch==='function'){
@@ -1002,6 +1029,11 @@ let _imeComposing=false;
})();
function _isImeEnter(e){return e.isComposing||e.keyCode===229||_imeComposing;}
window._isImeEnter=_isImeEnter;
function _isVirtualKeyboardLikelyOpen(){
const vv=window.visualViewport;
if(!vv||!window.innerHeight)return true;
return window.innerHeight-vv.height>120;
}
$('msg').addEventListener('keydown',e=>{
// Autocomplete navigation when dropdown is open
const dd=$('cmdDropdown');
@@ -1019,13 +1051,14 @@ $('msg').addEventListener('keydown',e=>{
}
}
// Send key: respect user preference.
// On touch-primary devices (software keyboard), default to Enter = newline
// since there's no physical Shift key. Users send via the Send button.
// On touch-primary devices with the software keyboard open, default to
// Enter = newline since there's no physical Shift key. Hardware keyboards on
// tablets keep desktop behavior when the viewport is not keyboard-shrunk.
// The 'ctrl+enter' setting also uses this behavior (Enter = newline).
// Users can override in Settings by explicitly choosing 'enter' mode.
if(e.key==='Enter'){
if(_isImeEnter(e)){return;}
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter';
const _mobileDefault=matchMedia('(pointer:coarse)').matches&&window._sendKey==='enter'&&_isVirtualKeyboardLikelyOpen();
if(window._sendKey==='ctrl+enter'||_mobileDefault){
if(e.ctrlKey||e.metaKey){e.preventDefault();send();}
} else {
@@ -1126,6 +1159,11 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
btn.onclick=()=>{$('msg').value=btn.dataset.msg;send();};
});
function applyEmptyStateSuggestionPref(){
if(!$('emptyState')) return;
$('emptyState').classList.toggle('no-suggestions',window._hideEmptyStateSuggestions===true);
}
window.addEventListener('resize',()=>{
_syncWorkspacePanelInlineWidth();
syncWorkspacePanelState();
@@ -1201,9 +1239,10 @@ const _SKINS=[
{name:'Sienna', colors:['#D97757','#C06A49','#9A523A']},
{name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']},
{name:'Nous', colors:['#4682B4','#3A6E9A','#2C5F88']},
{name:'Geist Contrast', value:'geist-contrast', colors:['#000000','#ffffff','#FFF175']},
];
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>(s.value||s.name).toLowerCase()));
const _LEGACY_THEME_MAP={
slate:{theme:'dark',skin:'slate'},
solarized:{theme:'dark',skin:'poseidon'},
@@ -1365,15 +1404,16 @@ function _buildSkinPicker(activeSkin){
if(!grid) return;
grid.innerHTML='';
for(const skin of _SKINS){
const key=skin.name.toLowerCase();
const key=(skin.value||skin.name).toLowerCase();
const btn=document.createElement('button');
btn.type='button';
btn.className='skin-pick-btn';
btn.dataset.skinVal=key;
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
btn.onclick=()=>_pickSkin(skin.name);
btn.onclick=()=>_pickSkin(key);
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
const label=skin.label||skin.name;
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${label}</span>`;
grid.appendChild(btn);
}
_syncSkinPicker((activeSkin||'default').toLowerCase());
@@ -1403,6 +1443,8 @@ function applyBotName(){
window._sendKey=s.send_key||'enter';
window._showTokenUsage=!!s.show_token_usage;
window._showQuotaChip=s.show_quota_chip===true;
window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true;
applyEmptyStateSuggestionPref();
window._showTps=!!s.show_tps;
window._fadeTextEffect=!!s.fade_text_effect;
window._showCliSessions=!!s.show_cli_sessions;
@@ -1416,10 +1458,39 @@ function applyBotName(){
window._showThinking=s.show_thinking!==false;
window._simplifiedToolCalling=s.simplified_tool_calling!==false;
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3;
window._busyInputMode=(s.busy_input_mode||'queue');
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
window._botName=s.bot_name||'Hermes';
if(s.default_model) window._defaultModel=s.default_model;
if(s.default_model_provider) window._activeProvider=s.default_model_provider;
if(s.default_model){
window._defaultModel=s.default_model;
const sel=$('modelSelect');
if(sel&&typeof _applyModelToDropdown==='function'){
// Fresh page boot must prefer the profile/server default over stale
// browser-persisted model state. A restored session can still apply its
// own persisted model later through loadSession().
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
else {
localStorage.removeItem('hermes-webui-model');
localStorage.removeItem('hermes-webui-model-state');
}
const existingDefaultOpt=Array.from(sel.options).find(o=>o.value===s.default_model);
if(existingDefaultOpt&&window._activeProvider&&!existingDefaultOpt.dataset.provider){
existingDefaultOpt.dataset.provider=window._activeProvider;
}
if(!existingDefaultOpt){
const opt=document.createElement('option');
opt.value=s.default_model;
opt.textContent=typeof getModelLabel==='function'?getModelLabel(s.default_model):s.default_model;
opt.dataset.custom='1';
opt.dataset.provider=window._activeProvider||'';
sel.querySelectorAll('option[data-custom]').forEach(o=>o.remove());
sel.appendChild(opt);
}
_applyModelToDropdown(s.default_model,sel,window._activeProvider||null);
}
}
window._sessionJumpButtonsEnabled=!!s.session_jump_buttons;
// Reconcile appearance: prefer localStorage (what the user last saw) over
// the server. If they diverge (e.g. a previous autosave POST failed),
@@ -1469,6 +1540,8 @@ function applyBotName(){
window._sendKey='enter';
window._showTokenUsage=false;
window._showQuotaChip=false;
window._hideEmptyStateSuggestions=false;
applyEmptyStateSuggestionPref();
window._showTps=false;
window._fadeTextEffect=false;
window._showCliSessions=false;
@@ -1479,6 +1552,7 @@ function applyBotName(){
window._simplifiedToolCalling=true;
window._sessionJumpButtonsEnabled=false;
window._sidebarDensity='compact';
window._pinnedSessionsLimit=3;
window._busyInputMode='queue';
window._sessionEndlessScrollEnabled=false;
window._botName='Hermes';
@@ -1508,32 +1582,60 @@ function applyBotName(){
// Fetch available models without blocking session restore. The static HTML
// options are enough for first paint; the dynamic provider list can settle
// after the saved session is visible.
const _modelDropdownReady=populateModelDropdown().then(()=>{
const _hydrateBootModelDropdown=()=>populateModelDropdown().then(()=>{
const sessionModelState=S.session&&S.session.model
? {model:S.session.model,model_provider:S.session.model_provider||null}
: null;
const savedState=(typeof _readPersistedModelState==='function')
? _readPersistedModelState()
: (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null);
const savedModel=savedState&&savedState.model;
// Active sessions are authoritative. On fresh boot without a restored
// session, keep the profile/server default ahead of stale browser model
// state when a default exists.
const stateToApply=sessionModelState||(!window._defaultModel?savedState:null);
const savedModel=stateToApply&&stateToApply.model;
if(savedModel && $('modelSelect')){
const applied=(typeof _applyModelToDropdown==='function')
? _applyModelToDropdown(savedModel,$('modelSelect'),savedState.model_provider||null)
? (sessionModelState
? _applyModelToDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)
: _applyModelToDropdown(savedState.model,$('modelSelect'),savedState.model_provider||null))
: null;
if(!applied) $('modelSelect').value=savedModel;
// If the value didn't take (model not in list), clear the bad pref
if(!applied&&$('modelSelect').value!==savedModel){
if(!applied) $('modelSelect').value=stateToApply.model;
// If the value didn't take (model not in list), clear the bad pref only
// for persisted browser preferences. Active sessions remain authoritative.
if(!applied&&sessionModelState&&typeof _ensureModelOptionInDropdown==='function'){
_ensureModelOptionInDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null);
}
else if(!applied&&!sessionModelState&&$('modelSelect').value!==stateToApply.model){
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
else localStorage.removeItem('hermes-webui-model');
else {
localStorage.removeItem('hermes-webui-model');
localStorage.removeItem('hermes-webui-model-state');
}
}
else if(typeof syncModelChip==='function') syncModelChip();
}
if(S.session) syncTopbar();
}).catch(()=>{});
window._modelDropdownReady=_modelDropdownReady;
// Pre-load workspace list so sidebar name is correct from first render.
const _startBootModelDropdown=()=>{
const ready=window._modelDropdownReady;
if(ready&&typeof ready.then==='function') return ready;
const next=_hydrateBootModelDropdown();
window._modelDropdownReady=next;
return next;
};
window._modelDropdownReady=null;
window._ensureModelDropdownReady=_startBootModelDropdown;
// Start independent boot fetches without holding the conversation list behind
// them. The sidebar can render from /api/sessions while workspace/onboarding
// metadata settles in parallel.
const _workspaceListReady=loadWorkspaceList();
const _onboardingReady=_bootSettings.onboarding_completed?Promise.resolve(false):loadOnboardingWizard();
// Render the session list before restoring the saved conversation so a stale
// saved-session/client-side boot error cannot leave the sidebar empty forever.
await loadWorkspaceList();
await loadOnboardingWizard();
await renderSessionList();
await _workspaceListReady;
await _onboardingReady;
_initResizePanels();
// Workspace panel restore happens AFTER loadSession so we know if
// the session has a workspace — prevents the snap-open-then-closed flash (#576).
@@ -1559,6 +1661,12 @@ function applyBotName(){
return;
}
await loadSession(saved);
// Hard refresh starts from the static HTML model list. Hydrate the live
// catalog after the saved session is known, then re-apply that session's
// model before S._bootReady lets syncModelChip reveal the composer label.
// Otherwise the chip can display the static default (e.g. GPT-5.4 Mini)
// even though S.session already points at the Codex/current model.
if(S.session) await _startBootModelDropdown();
// If the restored session has no messages it is an ephemeral scratch pad —
// treat the page as a fresh start rather than resuming a blank conversation.
// loadSession() already ran, so loadDir() has populated the workspace file tree.
+1 -1
View File
@@ -664,7 +664,7 @@ async function cmdUsage(){
async function cmdTheme(args){
const themes=['system','dark','light'];
const skins=(_SKINS||[]).map(s=>s.name.toLowerCase());
const skins=(_SKINS||[]).map(s=>(s.value||s.name).toLowerCase());
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
const val=(args||'').toLowerCase().trim();
// Check if it's a theme
+185 -13
View File
@@ -188,7 +188,7 @@ const LOCALES = {
cmd_terminal: 'Open the workspace terminal',
cmd_new: 'Start a new chat session',
cmd_usage: 'Toggle token usage display on/off',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills',
available_commands: 'Available commands:',
@@ -511,6 +511,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Load older messages while scrolling up',
settings_desc_session_endless_scroll: 'When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.',
settings_label_tab_visibility: 'Sidebar tabs',
settings_desc_tab_visibility: 'Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.',
open_in_browser: 'Open in browser',
settings_dropdown_conversation: 'Conversation',
settings_dropdown_appearance: 'Appearance',
@@ -530,6 +533,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks',
plugins_enabled: 'Enabled',
plugins_disabled: 'Disabled',
plugins_active_provider: 'Active (provider)',
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks',
plugins_load_failed: 'Failed to load plugins: ',
settings_tab_system: 'System',
settings_title: 'Settings',
@@ -551,6 +556,8 @@ const LOCALES = {
settings_label_language: 'Language',
settings_label_quota_chip: 'Show provider quota chip in composer',
settings_desc_quota_chip: 'Displays an ambient remaining-quota indicator (e.g. OpenRouter credit balance) in the composer footer. Default off. Only visible on wide displays (≥1400px) when enabled, to keep the composer uncluttered on laptop and standard desktop widths.',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: 'Show token usage',
settings_label_sidebar_density: 'Sidebar density',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
@@ -1425,7 +1432,7 @@ const LOCALES = {
cmd_terminal: 'Apri il terminale del workspace',
cmd_new: 'Avvia una nuova sessione di chat',
cmd_usage: 'Attiva/disattiva visualizzazione uso token',
cmd_theme: 'Cambia aspetto (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Cambia aspetto (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: "Cambia personalità dell'agente",
cmd_skills: 'Elenca le skill Hermes disponibili',
available_commands: 'Comandi disponibili:',
@@ -1748,6 +1755,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Carica messaggi precedenti scorrendo in alto',
settings_desc_session_endless_scroll: 'Se abilitato, i messaggi precedenti si caricano automaticamente scorrendo in alto. Se disabilitato, usa il pulsante messaggi precedenti.',
settings_label_tab_visibility: 'Schede della barra laterale',
settings_desc_tab_visibility: 'Scegli quali schede mostrare nella barra laterale e nel rail. Chat e Impostazioni sono sempre visibili.',
open_in_browser: 'Apri nel browser',
settings_dropdown_conversation: 'Conversazione',
settings_dropdown_appearance: 'Aspetto',
@@ -1767,6 +1777,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks', // TODO: translate
plugins_enabled: 'Enabled', // TODO: translate
plugins_disabled: 'Disabled', // TODO: translate
plugins_active_provider: 'Active (provider)', // TODO: translate
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate
plugins_load_failed: 'Failed to load plugins: ', // TODO: translate
settings_tab_system: 'Sistema',
settings_title: 'Impostazioni',
@@ -1788,6 +1800,8 @@ const LOCALES = {
settings_label_language: 'Lingua',
settings_label_quota_chip: 'Mostra il chip della quota del provider nel compositore',
settings_desc_quota_chip: "Mostra un indicatore di quota residua (es. saldo crediti OpenRouter) nel piè di pagina del compositore. Predefinito disattivato. Visibile solo su schermi larghi (≥1400px) quando attivato, per mantenere il compositore non affollato su laptop e desktop standard.",
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: 'Mostra uso token',
settings_label_sidebar_density: 'Densità sidebar',
cmd_reasoning: 'Mostra/nascondi ragionamento, imposta livello sforzo o controlla stato attuale',
@@ -2654,7 +2668,7 @@ const LOCALES = {
cmd_terminal: 'ワークスペースのターミナルを開く',
cmd_new: '新しいチャットセッションを開始',
cmd_usage: 'トークン使用量表示の ON/OFF を切り替え',
cmd_theme: '外観を切り替え (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: '外観を切り替え (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'エージェントのパーソナリティを切り替え',
cmd_skills: '利用可能な Hermes スキルを一覧表示',
available_commands: '利用可能なコマンド:',
@@ -2977,6 +2991,9 @@ const LOCALES = {
settings_label_session_endless_scroll: '上スクロールで古いメッセージを読み込む',
settings_desc_session_endless_scroll: '有効にすると、上にスクロールしたとき古いメッセージを自動で読み込みます。無効の場合は古いメッセージボタンを使います。',
settings_label_tab_visibility: 'サイドバータブ',
settings_desc_tab_visibility: 'サイドバーとレールに表示するタブを選択します。チャットと設定は常に表示されます。',
open_in_browser: 'ブラウザで開く',
settings_dropdown_conversation: '会話',
settings_dropdown_appearance: '外観',
@@ -2996,6 +3013,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks', // TODO: translate
plugins_enabled: 'Enabled', // TODO: translate
plugins_disabled: 'Disabled', // TODO: translate
plugins_active_provider: 'Active (provider)', // TODO: translate
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate
plugins_load_failed: 'Failed to load plugins: ', // TODO: translate
settings_tab_system: 'システム',
settings_title: '設定',
@@ -3017,6 +3036,8 @@ const LOCALES = {
settings_label_language: '言語',
settings_label_quota_chip: 'コンポーザーにプロバイダーのクォータチップを表示',
settings_desc_quota_chip: 'コンポーザーのフッターに残りクォータインジケーター(例: OpenRouter のクレジット残高)を表示します。デフォルトはオフ。有効にした場合、ラップトップや標準デスクトップの幅でコンポーザーが混雑しないよう、ワイドディスプレイ(≥1400px)でのみ表示されます。',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: 'トークン使用量を表示',
settings_label_sidebar_density: 'サイドバー密度',
cmd_reasoning: '思考表示の切り替え (表示/非表示)、努力レベル設定、現在状態の確認',
@@ -3852,7 +3873,7 @@ const LOCALES = {
cmd_terminal: 'Открыть терминал рабочей области',
cmd_new: 'Начать новую сессию чата',
cmd_usage: 'Показать или скрыть использование токенов',
cmd_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Переключить личность агента',
cmd_skills: 'Показать доступные навыки Hermes',
available_commands: 'Доступные команды:',
@@ -4057,6 +4078,8 @@ const LOCALES = {
settings_label_language: 'Язык',
settings_label_quota_chip: 'Показывать чип квоты провайдера в композиторе',
settings_desc_quota_chip: 'Отображает фоновый индикатор остатка квоты (например, баланс кредитов OpenRouter) в подвале композитора. По умолчанию отключено. Виден только на широких экранах (≥1400px) при включении, чтобы не загромождать композитор на ноутбуках и стандартных мониторах.',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: 'Показывать использование токенов',
settings_label_sidebar_density: 'Плотность боковой панели',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
@@ -4740,6 +4763,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Загружать старые сообщения при прокрутке вверх',
settings_desc_session_endless_scroll: 'Если включено, старые сообщения загружаются автоматически при прокрутке вверх. Если выключено, используйте кнопку загрузки старых сообщений.',
settings_label_tab_visibility: 'Вкладки боковой панели',
settings_desc_tab_visibility: 'Выберите, какие вкладки отображаются на боковой панели и в рейле. Чат и настройки всегда видны.',
open_in_browser: 'Открыть в браузере',
settings_section_system_title: 'System',
settings_tab_appearance: 'Appearance',
@@ -4755,6 +4781,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks', // TODO: translate
plugins_enabled: 'Enabled', // TODO: translate
plugins_disabled: 'Disabled', // TODO: translate
plugins_active_provider: 'Active (provider)', // TODO: translate
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate
plugins_load_failed: 'Failed to load plugins: ', // TODO: translate
settings_tab_system: 'System',
status_updated: 'Updated',
@@ -5048,7 +5076,7 @@ const LOCALES = {
cmd_terminal: 'Abrir terminal del espacio de trabajo',
cmd_new: 'Iniciar una nueva sesión de chat',
cmd_usage: 'Activar o desactivar el uso de tokens',
cmd_theme: 'Cambiar apariencia (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Cambiar apariencia (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Cambiar la personalidad del agente',
cmd_skills: 'Listar las skills de Hermes disponibles',
available_commands: 'Comandos disponibles:',
@@ -5215,6 +5243,8 @@ const LOCALES = {
settings_label_language: 'Idioma',
settings_label_quota_chip: 'Mostrar el chip de cuota del proveedor en el compositor',
settings_desc_quota_chip: 'Muestra un indicador ambiental de cuota restante (por ejemplo, saldo de crédito de OpenRouter) en el pie del compositor. Predeterminado: desactivado. Solo visible en pantallas anchas (≥1400px) cuando se activa, para mantener el compositor despejado en portátiles y monitores estándar.',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: 'Mostrar uso de tokens',
settings_label_sidebar_density: 'Densidad de la barra lateral',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
@@ -5896,6 +5926,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Cargar mensajes antiguos al desplazarse hacia arriba',
settings_desc_session_endless_scroll: 'Si está activado, los mensajes antiguos se cargan automáticamente al desplazarte hacia arriba. Si está desactivado, usa el botón de mensajes antiguos.',
settings_label_tab_visibility: 'Pestañas de la barra lateral',
settings_desc_tab_visibility: 'Elige qué pestañas aparecen en la barra lateral y el rail. Chat y Configuración siempre están visibles.',
open_in_browser: 'Abrir en el navegador',
settings_section_system_title: 'System',
settings_tab_appearance: 'Appearance',
@@ -5911,6 +5944,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks', // TODO: translate
plugins_enabled: 'Enabled', // TODO: translate
plugins_disabled: 'Disabled', // TODO: translate
plugins_active_provider: 'Active (provider)', // TODO: translate
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate
plugins_load_failed: 'Failed to load plugins: ', // TODO: translate
settings_tab_system: 'System',
status_updated: 'Updated',
@@ -6190,7 +6225,7 @@ const LOCALES = {
cmd_terminal: 'Workspace-Terminal öffnen',
cmd_new: 'Neue Chat-Sitzung starten',
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Agenten-Persönlichkeit wechseln',
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
available_commands: 'Verfügbare Befehle:',
@@ -6366,6 +6401,8 @@ const LOCALES = {
settings_label_language: 'Sprache',
settings_label_quota_chip: 'Anbieter-Kontingent-Chip im Editor anzeigen',
settings_desc_quota_chip: 'Zeigt einen Hintergrund-Indikator des verbleibenden Kontingents (z. B. OpenRouter-Guthaben) in der Editor-Fußzeile an. Standardmäßig deaktiviert. Bei Aktivierung nur auf breiten Bildschirmen (≥1400px) sichtbar, damit der Editor auf Laptops und Standard-Desktops übersichtlich bleibt.',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: 'Token-Verbrauch anzeigen',
settings_label_sidebar_density: 'Seitenleistendichte',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
@@ -6771,6 +6808,9 @@ const LOCALES = {
settings_desc_session_endless_scroll: 'Wenn aktiviert, werden ältere Nachrichten beim Hochscrollen automatisch geladen. Wenn deaktiviert, nutzt du den Button für ältere Nachrichten.',
settings_label_tab_visibility: 'Seitenleiste-Tabs',
settings_desc_tab_visibility: 'Wähle, welche Tabs in der Seitenleiste und im Rail angezeigt werden. Chat und Einstellungen sind immer sichtbar.',
workspace_drag_hint: 'Ziehen zum Neuordnen',
workspace_reorder_failed: 'Neuordnen fehlgeschlagen',
open_in_browser: 'Im Browser öffnen',
@@ -6788,6 +6828,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks', // TODO: translate
plugins_enabled: 'Enabled', // TODO: translate
plugins_disabled: 'Disabled', // TODO: translate
plugins_active_provider: 'Active (provider)', // TODO: translate
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate
plugins_load_failed: 'Failed to load plugins: ', // TODO: translate
settings_tab_system: 'System',
status_updated: 'Updated',
@@ -7383,7 +7425,7 @@ const LOCALES = {
cmd_terminal: '打开工作区 Terminal',
cmd_new: '新建聊天会话',
cmd_usage: '切换 token 用量显示',
cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: '切换 Agent 人设',
cmd_skills: '列出可用的 Hermes 技能',
available_commands: '可用命令:',
@@ -7464,6 +7506,8 @@ const LOCALES = {
plugins_registered_hooks: '已注册的挂钩',
plugins_enabled: '已启用',
plugins_disabled: '已禁用',
plugins_active_provider: '激活(提供方)',
plugins_provider_no_hooks: '提供方插件 — 无代理可见挂钩',
plugins_load_failed: '加载插件失败:',
workspace_empty_no_path: '未选择工作区。请在 设置 → 工作区 中设置工作区以浏览文件。',
workspace_empty_dir: '此工作区为空。',
@@ -7567,6 +7611,8 @@ const LOCALES = {
settings_label_language: '语言',
settings_label_quota_chip: '在编辑器中显示供应商配额标签',
settings_desc_quota_chip: '在编辑器底部显示剩余配额指示器(如 OpenRouter 信用余额)。默认关闭。启用时仅在宽屏(≥1400px)显示,以保持笔记本和标准桌面屏幕上编辑器的整洁。',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: '显示 token 用量',
settings_label_sidebar_density: '侧边栏密度',
cmd_reasoning: '切换思维可见性(显示/隐藏)、设置工作强度或查看当前状态',
@@ -7960,7 +8006,7 @@ const LOCALES = {
cron_status_off: '关闭',
cron_status_paused: '暂停',
cron_status_error: '错误',
cron_status_active: '运行中',
cron_status_active: '已启用',
cron_status_running: '执行中\u2026',
cron_status_needs_attention: '需要处理',
cron_attention_desc: '这个重复定时任务没有下次运行时间。调度器可能没能计算出下一次运行。',
@@ -8238,6 +8284,9 @@ const LOCALES = {
settings_label_session_endless_scroll: '向上滚动时加载更早的消息',
settings_desc_session_endless_scroll: '启用后,向上滚动时会自动加载更早的消息。禁用时请使用加载更早消息按钮。',
settings_label_tab_visibility: '侧边栏标签',
settings_desc_tab_visibility: '选择在侧边栏和导航栏中显示哪些标签。聊天和设置始终可见。',
open_in_browser: '在浏览器中打开',
settings_section_system_title: '系统',
settings_tab_appearance: '外观',
@@ -8513,7 +8562,7 @@ const LOCALES = {
cmd_terminal: '\u6253\u958b\u5de5\u4f5c\u5340 Terminal',
cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
cmd_theme: '\u5207\u63db\u5916\u89c0\uff08\u4e3b\u984c\uff1asystem/dark/light\uff0c\u76ae\u819a\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous\uff09',
cmd_theme: '\u5207\u63db\u5916\u89c0\uff08\u4e3b\u984c\uff1asystem/dark/light\uff0c\u76ae\u819a\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast\uff09',
cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
@@ -8704,6 +8753,9 @@ const LOCALES = {
settings_label_session_endless_scroll: '向上捲動時載入較早訊息',
settings_desc_session_endless_scroll: '啟用後,向上捲動時會自動載入較早訊息。停用時請使用載入較早訊息按鈕。',
settings_label_tab_visibility: '側邊欄標籤',
settings_desc_tab_visibility: '選擇在側邊欄和導航列中顯示哪些標籤。聊天和設定始終可見。',
open_in_browser: '在瀏覽器中開啓',
settings_dropdown_conversation: '對話',
settings_dropdown_appearance: '外觀',
@@ -8723,6 +8775,8 @@ const LOCALES = {
plugins_registered_hooks: '已註冊的鉤子',
plugins_enabled: '已啟用',
plugins_disabled: '已停用',
plugins_active_provider: '啟用中(提供者)',
plugins_provider_no_hooks: '提供者外掛 — 無代理可見鉤子',
plugins_load_failed: '載入外掛失敗:',
settings_tab_system: '系統',
settings_title: '\u8a2d\u5b9a',
@@ -8744,6 +8798,8 @@ const LOCALES = {
settings_label_language: '\u8a9e\u8a00',
settings_label_quota_chip: '在編輯器中顯示供應商配額標籤',
settings_desc_quota_chip: '在編輯器底部顯示剩餘配額指示器(如 OpenRouter 點數餘額)。預設關閉。啟用時僅在寬螢幕(≥1400px)顯示,以保持筆記型電腦和標準桌面螢幕上編輯器的整潔。',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: '\u986f\u793a token \u7528\u91cf',
settings_label_sidebar_density: '側邊欄密度',
cmd_reasoning: '切換思考區塊可見性(顯示/隱藏)或設定努力等級',
@@ -9325,7 +9381,7 @@ const LOCALES = {
cron_schedule_placeholder: '\u6392\u7a0b',
cron_schedule_required: '\u9700\u8981\u6392\u7a0b',
cron_schedule_required_example: '\u9700\u8981\u6392\u7a0b\uff08\u4f8b\u5982 "0 9 * * *" \u6216 "every 1h"\uff09',
cron_status_active: '\u6d3b\u8e8d\u4e2d',
cron_status_active: '已啟用',
cron_status_running: '\u57f7\u884c\u4e2d\u2026',
cron_status_error: '\u932f\u8aa4',
cron_status_off: '\u672a\u555f\u7528',
@@ -9719,7 +9775,7 @@ const LOCALES = {
cmd_workspace: 'Trocar workspace por nome',
cmd_new: 'Iniciar nova sessão de chat',
cmd_usage: 'Alternar exibição de uso de tokens',
cmd_theme: 'Trocar aparência (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Trocar aparência (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Trocar personalidade do agente',
cmd_skills: 'Listar skills disponíveis do Hermes',
available_commands: 'Comandos disponíveis:',
@@ -10006,6 +10062,9 @@ const LOCALES = {
settings_label_session_endless_scroll: 'Carregar mensagens antigas ao rolar para cima',
settings_desc_session_endless_scroll: 'Quando ativado, mensagens antigas carregam automaticamente ao rolar para cima. Quando desativado, use o botão de mensagens antigas.',
settings_label_tab_visibility: 'Abas da barra lateral',
settings_desc_tab_visibility: 'Escolha quais abas aparecem na barra lateral e no rail. Chat e Configurações estão sempre visíveis.',
open_in_browser: 'Abrir no navegador',
settings_dropdown_conversation: 'Conversa',
settings_dropdown_appearance: 'Aparência',
@@ -10025,6 +10084,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks', // TODO: translate
plugins_enabled: 'Enabled', // TODO: translate
plugins_disabled: 'Disabled', // TODO: translate
plugins_active_provider: 'Active (provider)', // TODO: translate
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate
plugins_load_failed: 'Failed to load plugins: ', // TODO: translate
settings_tab_system: 'Sistema',
settings_title: 'Configurações',
@@ -10046,6 +10107,8 @@ const LOCALES = {
settings_label_language: 'Idioma',
settings_label_quota_chip: 'Mostrar o chip de cota do provedor no compositor',
settings_desc_quota_chip: 'Exibe um indicador ambiente de cota restante (por exemplo, saldo de crédito do OpenRouter) no rodapé do compositor. Desativado por padrão. Visível apenas em telas largas (≥1400px) quando ativado, para manter o compositor livre em laptops e monitores padrão.',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: 'Mostrar uso de tokens',
settings_label_sidebar_density: 'Densidade da sidebar',
cmd_reasoning: 'Alternar visibilidade do pensamento (mostrar/ocultar)',
@@ -10834,7 +10897,7 @@ const LOCALES = {
cmd_terminal: '워크스페이스 터미널 열기',
cmd_new: '새 채팅 세션 시작',
cmd_usage: '토큰 사용량 표시 켜기/끄기',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills',
available_commands: '사용 가능한 명령:',
@@ -11138,6 +11201,9 @@ const LOCALES = {
settings_label_session_endless_scroll: '위로 스크롤할 때 이전 메시지 불러오기',
settings_desc_session_endless_scroll: '활성화하면 위로 스크롤할 때 이전 메시지를 자동으로 불러옵니다. 비활성화하면 이전 메시지 버튼을 사용합니다.',
settings_label_tab_visibility: '사이드바 탭',
settings_desc_tab_visibility: '사이드바와 레일에 표시할 탭을 선택하세요. 채팅과 설정은 항상 표시됩니다.',
open_in_browser: '브라우저에서 열기',
settings_dropdown_conversation: '대화',
settings_dropdown_appearance: '외형',
@@ -11157,6 +11223,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks', // TODO: translate
plugins_enabled: 'Enabled', // TODO: translate
plugins_disabled: 'Disabled', // TODO: translate
plugins_active_provider: 'Active (provider)', // TODO: translate
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate
plugins_load_failed: 'Failed to load plugins: ', // TODO: translate
settings_tab_system: '시스템',
settings_title: '설정',
@@ -11178,6 +11246,8 @@ const LOCALES = {
settings_label_language: '언어',
settings_label_quota_chip: '작성기에 공급자 할당량 칩 표시',
settings_desc_quota_chip: '작성기 푸터에 남은 할당량 표시기(예: OpenRouter 크레딧 잔액)를 표시합니다. 기본값은 끔. 활성화 시 노트북과 표준 데스크톱에서 작성기가 복잡해지지 않도록 와이드 디스플레이(≥1400px)에서만 표시됩니다.',
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: '토큰 사용량 표시',
settings_label_sidebar_density: '사이드바 밀도',
cmd_reasoning: 'Toggle thinking visibility (show/hide), set effort level, or check current status',
@@ -12055,7 +12125,7 @@ const LOCALES = {
cmd_terminal: 'Ouvrez le terminal de l\'espace de travail',
cmd_new: 'Démarrer une nouvelle session de discussion',
cmd_usage: 'Activer/désactiver l\'affichage de l\'utilisation du jeton',
cmd_theme: 'Changer d\'apparence (thème : system/dark/light, skin : default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Changer d\'apparence (thème : system/dark/light, skin : default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Personnalité de l\'agent de commutation',
cmd_skills: 'Lister les compétences Hermès disponibles',
available_commands: 'Commandes disponibles :',
@@ -12285,6 +12355,9 @@ const LOCALES = {
settings_desc_session_jump_buttons: 'Affichez les boutons flottants de début et de fin lors de la lecture de longs historiques de session.',
settings_label_session_endless_scroll: 'Charger les anciens messages en faisant défiler vers le haut',
settings_desc_session_endless_scroll: 'Lorsqu\'ils sont activés, les anciens messages se chargent automatiquement lorsque vous faites défiler vers le haut. Lorsqu\'il est désactivé, utilisez le bouton des messages plus anciens.',
settings_label_tab_visibility: 'Onglets de la barre latérale',
settings_desc_tab_visibility: 'Choisissez quels onglets apparaissent dans la barre latérale et le rail. Chat et Paramètres sont toujours visibles.',
open_in_browser: 'Ouvrir dans le navigateur',
settings_dropdown_conversation: 'Conversation',
settings_dropdown_appearance: 'Apparence',
@@ -12304,6 +12377,8 @@ const LOCALES = {
plugins_registered_hooks: 'Registered hooks', // TODO: translate
plugins_enabled: 'Enabled', // TODO: translate
plugins_disabled: 'Disabled', // TODO: translate
plugins_active_provider: 'Active (provider)', // TODO: translate
plugins_provider_no_hooks: 'Provider plugin — no agent-visibility hooks', // TODO: translate
plugins_load_failed: 'Failed to load plugins: ', // TODO: translate
settings_tab_system: 'Système',
settings_title: 'Paramètres',
@@ -12325,6 +12400,8 @@ const LOCALES = {
settings_label_language: 'Langue',
settings_label_quota_chip: 'Afficher la pastille de quota du fournisseur dans le compositeur',
settings_desc_quota_chip: "Affiche un indicateur ambiant de quota restant (par ex. solde de crédit OpenRouter) dans le pied du compositeur. Désactivé par défaut. Visible uniquement sur les écrans larges (≥1400px) lorsqu'activé, pour garder le compositeur dégagé sur les ordinateurs portables et les bureaux standard.",
settings_label_hide_suggestions: 'Hide new-chat suggestions',
settings_desc_hide_suggestions: 'Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.',
settings_label_token_usage: 'Afficher l\'utilisation du jeton',
settings_label_sidebar_density: 'Densité de la barre latérale',
cmd_reasoning: 'Basculez la visibilité de la réflexion (afficher/masquer), définir le niveau d\'effort ou vérifier l\'état actuel',
@@ -12946,6 +13023,101 @@ const LOCALES = {
checkpoint_restored: 'Point de contrôle restauré',
checkpoint_diff_title: 'Changements au point de contrôle',
checkpoint_diff_no_changes: 'Aucune différence trouvée entre ce point de contrôle et l\'espace de travail actuel.',
// === Missing keys (FR translation completion) ===
bg_complete: 'Tâche en arrière-plan terminée',
bg_failed: 'Échec de la tâche en arrière-plan : ',
bg_label: 'Résultat en arrière-plan :',
bg_no_answer: '(aucune réponse)',
bg_running: 'Exécution en arrière-plan…',
branch_failed: 'Échec du fork : ',
branch_forked: 'Conversation forkée dans une nouvelle session',
btw_asking: 'Question annexe en cours…',
btw_done: 'Question annexe répondue',
btw_failed: 'Échec de la question annexe : ',
btw_label: 'Question annexe — hors historique',
btw_no_answer: 'Aucune réponse reçue.',
cancel_unavailable: 'Annulation indisponible.',
cmd_background: 'Exécuter une commande en arrière-plan',
cmd_background_usage: '/background <prompt> — exécute en parallèle sans bloquer',
cmd_branch: 'Forker cette conversation dans une nouvelle session',
cmd_branch_usage: '/branch [nom] — forke la conversation (avec un nom optionnel)',
cmd_btw: 'Poser une question annexe (éphémère)',
cmd_btw_usage: '/btw <question> — pose une question annexe en utilisant le contexte de la session',
cmd_retry: 'Renvoyer le dernier message',
cmd_status: 'Afficher les infos de la session',
cmd_stop: 'Arrêter la réponse en cours',
cmd_title: 'Obtenir ou définir le titre de la session',
cmd_undo: 'Supprimer le dernier échange',
cmd_voice: 'Activer/désactiver le micro',
cmd_voice_use_mic: 'Cliquez sur le bouton micro dans le composeur.',
cmd_webui_only_session: 'Cette commande n\'est pas disponible pour les sessions importées en CLI.',
folder_add_as_space_btn: 'Ajouter comme espace',
folder_add_as_space_msg: 'Ajouter ce dossier comme nouvel espace dans votre liste ?',
folder_add_as_space_title: 'Ajouter comme espace ?',
fork_from_here: 'Forker à partir d\'ici',
forked_from: 'Forké depuis',
kanban_archive_board: 'Archiver le tableau courant…',
kanban_archive_board_confirm: 'Archiver le tableau « {name} » ? Les tâches restent sur disque et le tableau peut être restauré depuis kanban/boards/_archived/.',
kanban_back_to_board: 'Retour au tableau',
kanban_block: 'Bloquer',
kanban_board_archived: 'Tableau archivé',
kanban_board_color: 'Couleur (optionnel)',
kanban_board_description: 'Description (optionnel)',
kanban_board_icon: 'Icône (emoji, optionnel)',
kanban_board_name: 'Nom',
kanban_board_name_required: 'Le nom est requis',
kanban_board_slug: 'Slug (minuscules, tirets)',
kanban_board_slug_required: 'Le slug est requis',
kanban_bulk_action: 'Action groupée',
kanban_card_archive: 'archiver',
kanban_card_complete: 'terminer',
kanban_lanes_by_profile: 'Colonnes par profil',
kanban_new_board: 'Nouveau tableau…',
kanban_nudge_dispatcher: 'Aperçu du dispatcher',
kanban_only_mine: 'Seulement les miennes',
kanban_rename_board: 'Renommer le tableau courant…',
kanban_stats: 'Statistiques',
kanban_unassigned: 'non assigné',
kanban_unblock: 'Débloquer',
kanban_worker_log: 'Journal du worker',
no_active_task: 'Aucune tâche active à arrêter.',
retrieval_context_label: 'Contexte indexé',
retrieval_context_preview: 'Les messages plus anciens sont stockés et récupérables via les outils de contexte',
retry_failed: 'Échec de la nouvelle tentative : ',
slash_skill_badge: 'Skill',
slash_skill_desc: 'Invoquer cette skill',
status_agent_running: 'Agent en cours d\'exécution',
status_heading: 'État de la session',
status_load_failed: 'Échec du chargement de l\'état : ',
status_messages: 'Messages',
status_model: 'Modèle',
status_no: 'Non',
status_personality: 'Personnalité',
status_provider: 'Fournisseur',
status_session_id: 'ID de session',
status_title: 'Titre',
status_workspace: 'Espace de travail',
status_yes: 'Oui',
stream_stopped: 'Réponse arrêtée.',
subagent_children: 'Sous-sessions de sous-agents',
title_change_hint: 'Utilisez `/title <nouveau nom>` pour renommer.',
title_current: 'Titre actuel',
title_set: 'Titre défini sur',
undid_messages_suffix: 'message(s).',
undid_n_messages: 'Supprimé',
undo_exchange: 'Annuler le dernier échange',
undo_failed: 'Échec de l\'annulation : ',
usage_default_model: 'par défaut',
usage_estimated_cost: 'Coût estimé',
usage_heading: 'Consommation de tokens',
usage_input_tokens: 'Tokens d\'entrée',
usage_load_failed: 'Échec du chargement de la consommation : ',
usage_output_tokens: 'Tokens de sortie',
usage_settings_tip: 'Note : les estimations de coût sont approximatives.',
usage_total: 'Total de tokens',
usage_unknown: 'inconnu',
workspace_auto_create_folder: 'Créer le dossier s\'il n\'existe pas',
}
};
+24 -2
View File
@@ -17,7 +17,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Hermes">
<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,nous:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,nous:1,'geist-contrast':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<!-- theme-color: surfaces the active app chrome color to native status bars (Safari status bar, PWA, native WKWebView wrappers). Updated dynamically by boot.js when theme/skin changes. The light/dark default values match style.css :root --sidebar / :root.dark --sidebar. -->
<meta name="theme-color" content="#FAF7F0" media="(prefers-color-scheme: light)">
@@ -178,6 +178,11 @@
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
<button class="nav-tab has-tooltip has-tooltip--bottom" data-panel="settings" onclick="switchPanel('settings',{fromRailClick:true})" data-tooltip="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
</div>
<!-- Flash-prevention: apply hidden tab visibility from localStorage synchronously
before any paint. Mirrors the theme/skin/font-size inline scripts in <head>.
The boot.js _restoreTabVisibility IIFE is a secondary fallback that also
handles the active-tab switch. -->
<script>(function(){try{var h=localStorage.getItem('hermes-webui-hidden-tabs');if(!h)return;var p=JSON.parse(h);if(!Array.isArray(p))return;var a=new Set(['chat','settings']);p.forEach(function(id){if(a.has(id))return;document.querySelectorAll('[data-panel="'+id+'"]').forEach(function(el){el.classList.add('nav-tab-hidden');});});try{var ac=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]')||document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');if(ac&&ac.classList.contains('nav-tab-hidden')){ac.classList.remove('active');var c=document.querySelector('.rail .rail-btn.nav-tab[data-panel="chat"]')||document.querySelector('.sidebar-nav .nav-tab[data-panel="chat"]');if(c)c.classList.add('active');}}catch(_){}}catch(e){}})();</script>
<!-- Chat panel -->
<div class="panel-view active" id="panelChat">
<div class="panel-head">
@@ -971,6 +976,11 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_session_endless_scroll">When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.</div>
</div>
<div class="settings-field">
<label id="tabVisibilityLabel" data-i18n="settings_label_tab_visibility">Sidebar tabs</label>
<div id="tabVisibilityChips" class="tab-visibility-chips" role="group" aria-labelledby="tabVisibilityLabel"></div>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_tab_visibility">Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.</div>
</div>
<div id="settingsAppearanceAutosaveStatus" class="settings-autosave-status" aria-live="polite"></div>
</div>
<div class="settings-pane" id="settingsPanePreferences">
@@ -985,6 +995,13 @@
<select id="settingsModel" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px"></select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_model">Used for new conversations. Existing conversations keep their selected model.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsHideSuggestions" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_hide_suggestions">Hide new-chat suggestions</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_hide_suggestions">Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.</div>
</div>
<div class="settings-field">
<label for="settingsSendKey" data-i18n="settings_label_send_key">Send Key</label>
<select id="settingsSendKey" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
@@ -1109,6 +1126,11 @@
</select>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_sidebar_density">Controls how much metadata the session list shows in the left sidebar.</div>
</div>
<div class="settings-field">
<label for="settingsPinnedSessionsLimit">Pinned conversations limit</label>
<input type="number" id="settingsPinnedSessionsLimit" min="1" max="99" step="1" inputmode="numeric" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
<div style="font-size:11px;color:var(--muted);margin-top:4px">Maximum number of active conversations that can be pinned in the sidebar. Default is 3.</div>
</div>
<div class="settings-field">
<label for="settingsAutoTitleRefresh" data-i18n="settings_label_auto_title_refresh">Adaptive title refresh</label>
<select id="settingsAutoTitleRefresh" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
@@ -1222,7 +1244,7 @@
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
<label for="settingsDashboardMode">Official Hermes Dashboard</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Show a nav-rail link when the official <code>hermes dashboard</code> is reachable. Overrides are restricted to loopback URLs.</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Show a nav-rail link when the official <code>hermes dashboard</code> is reachable. Public reverse-proxy URLs are stored as browser-only links and are never server-probed.</div>
<select id="settingsDashboardMode" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="auto">Auto-detect</option>
<option value="always">Always show</option>
+62 -21
View File
@@ -415,7 +415,8 @@ async function send(){
if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
INFLIGHT[activeSid]={messages:[...S.messages],uploaded:uploadedNames,toolCalls:[]};
const optimisticMessages=[...S.messages];
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId:null,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:[]});
}
@@ -486,9 +487,13 @@ async function send(){
// against real active-stream metadata before the background refresh lands.
upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
if(!INFLIGHT[activeSid]){
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
}
const currentInflight=INFLIGHT[activeSid];
markInflight(activeSid, streamId);
if(typeof saveInflightState==='function'){
saveInflightState(activeSid,{streamId,messages:INFLIGHT[activeSid].messages,uploaded:uploadedNames,toolCalls:INFLIGHT[activeSid].toolCalls||[]});
saveInflightState(activeSid,{streamId,messages:currentInflight.messages||optimisticMessages,uploaded:uploadedNames,toolCalls:currentInflight.toolCalls||[]});
}
// Refresh session list so background streaming indicators appear immediately for the
// session that was just started and any others that may already be running.
@@ -590,6 +595,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let _smdParser=null; // current smd parser instance (null until first content)
let _smdWrittenLen=0; // how many chars of displayText have been fed to smd parser
let _smdWrittenText=''; // exact displayText snapshot used for prefix-alignment checks
let _streamingKatexTimer=null; // throttles live KaTeX scans while smd writes deltas
// On reconnect, the assistantBody already has partial smd-rendered content.
// We clear it on first new token and restart the parser from the reconnect point.
let _smdReconnect=reconnecting;
@@ -935,6 +941,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
// Helper: end the current smd parser (flushes remaining state) and null it out.
function _smdEndParser(){
if(_streamingKatexTimer){clearTimeout(_streamingKatexTimer);_streamingKatexTimer=null;}
if(_smdParser&&window.smd){
try{window.smd.parser_end(_smdParser);}catch(_){}
// parser_end may flush remaining markdown that creates new links/images —
@@ -945,6 +952,13 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_smdWrittenLen=0;
_smdWrittenText='';
}
function _scheduleStreamingKatex(){
if(_streamingKatexTimer) return;
_streamingKatexTimer=setTimeout(()=>{
_streamingKatexTimer=null;
if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody);
},150);
}
// Helper: feed new displayText delta to the smd parser.
// Only feeds chars beyond what has already been written (_smdWrittenLen).
function _smdWrite(displayText, fade=false){
@@ -969,6 +983,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// streaming-markdown does NOT sanitize URL schemes. The default live path
// scans after writes; fade mode blocks unsafe href/src in its renderer.set_attr.
if(assistantBody&&!fade){_sanitizeSmdLinks(assistantBody);}
_scheduleStreamingKatex();
}
// Allowed URL schemes for anchors and images rendered from agent-streamed markdown.
// Raw file:// anchors are rewritten to /api/media before the user can click them.
@@ -1270,18 +1285,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
};
step();
}
function _closeCurrentLiveActivityGroup(){
const turn=$('liveAssistantTurn');
if(turn){
turn.querySelectorAll('.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]').forEach(group=>{
group.removeAttribute('data-live-activity-current');
});
}
}
function _resetAssistantSegment(){
const options=arguments[0]||{};
const closeActivity=!!(options&&options.closeActivity);
if(closeActivity) _closeCurrentLiveActivityGroup();
assistantRow=null;
assistantBody=null;
segmentStart=assistantText.length;
@@ -1408,7 +1412,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
if(alreadyStreamed){
if(!S.session||S.session.session_id!==activeSid) return;
_resetAssistantSegment({closeActivity:true});
_resetAssistantSegment();
return;
}
assistantText += assistantText ? `\n\n${visible}` : visible;
@@ -1420,7 +1424,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
else appendThinking(_liveThinkingText());
}
ensureAssistantRow(true);
_resetAssistantSegment({closeActivity:true});
_resetAssistantSegment();
_scheduleRender();
});
@@ -2434,6 +2438,7 @@ let _clarifyHideTimer = null;
let _clarifyVisibleSince = 0;
let _clarifySignature = '';
let _clarifySessionId = null;
let _clarifyId = null;
let _clarifyMissingEndpointWarned = false;
let _clarifyCountdownTimer = null;
let _clarifyExpiresAt = 0;
@@ -2598,6 +2603,7 @@ function _resetClarifyCardState() {
_clearClarifyCountdownTimer();
_clarifyVisibleSince = 0;
_clarifySignature = '';
_clarifyId = null;
}
function hideClarifyCard(force=false, reason="dismissed") {
@@ -2673,6 +2679,7 @@ function showClarifyCard(pending) {
const input = $("clarifyInput");
const sameClarify = card.classList.contains("visible") && _clarifySignature === sig;
_clarifySessionId = sid;
_clarifyId = pending.clarify_id || null;
_clarifySignature = sig;
_startClarifyCountdown(pending);
if (!sameClarify) {
@@ -2752,16 +2759,50 @@ async function respondClarify(response) {
if (input) input.focus();
return;
}
_clarifySessionId = null;
_clearClarifyPendingForSession(sid);
const clarifyId = _clarifyId;
// Keep a draft copy so we can restore the input on failure (issue #2639).
const draft = value;
_clarifySetControlsDisabled(true, true);
hideClarifyCard(true, 'sent');
try {
await api("/api/clarify/respond", {
const result = await api("/api/clarify/respond", {
method: "POST",
body: JSON.stringify({ session_id: sid, response: value })
body: JSON.stringify({ session_id: sid, response: value, clarify_id: clarifyId || "" })
});
} catch(e) { setStatus(t("clarify_responding") + " " + e.message); }
if (result && result.ok) {
// Only clear/hide if the visible prompt still matches what was just
// submitted. If a parallel SSE event already loaded the next queued
// prompt, erasing the session cache would leave the agent waiting
// until timeout (codex review P1, issue #2639).
if (_clarifyId === clarifyId) {
_clarifySessionId = null;
_clarifyId = null;
_clearClarifyPendingForSession(sid);
hideClarifyCard(true, 'sent');
}
} else {
// Stale / expired / wrong session — keep the card and draft visible.
_clarifySetControlsDisabled(false, false);
if (input) {
input.value = draft;
input.focus();
}
const errMsg = (result && result.error) || "Clarification response not accepted — the agent may have already proceeded.";
if (typeof showToast === "function") showToast(errMsg, 5000);
if (typeof setStatus === "function") setStatus(errMsg);
}
} catch(e) {
// Stale (409) or network error — keep the card and draft visible so the user can retry.
_clarifySetControlsDisabled(false, false);
if (input) {
input.value = draft;
input.focus();
}
const errMsg = (e && e.status === 409)
? (e.message || "Clarification prompt expired or not found.")
: ((e && e.message) || "Failed to deliver clarification response.");
if (typeof setStatus === "function") setStatus("Clarify: " + errMsg);
if (typeof showToast === "function") showToast(errMsg, 5000);
}
}
var _clarifyEventSource = null;
+314 -83
View File
@@ -182,6 +182,24 @@ function _consumeSettingsTargetPanel(fallback = 'chat') {
return target;
}
function _resyncChatSidebarAfterPanelSwitch() {
if (_currentPanel !== 'chat') return;
if (typeof renderSessionListFromCache !== 'function') return;
const run = () => {
if (_currentPanel !== 'chat') return;
if (typeof _renamingSid !== 'undefined' && _renamingSid) return;
// If the user opens the per-conversation action menu immediately after
// returning to Chat, do not let the deferred sidebar resync tear it down.
// renderSessionListFromCache() intentionally closes that menu before it
// rebuilds rows, which is correct for normal list refreshes but hostile to
// this one-shot panel-transition repair.
if (typeof _sessionActionMenu !== 'undefined' && _sessionActionMenu) return;
renderSessionListFromCache();
};
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
else run();
}
async function switchPanel(name, opts = {}) {
const nextPanel = name || 'chat';
const prevPanel = _currentPanel;
@@ -251,6 +269,7 @@ async function switchPanel(name, opts = {}) {
if (sidebar) sidebar.classList.add('mobile-open');
if (overlay) overlay.classList.add('visible');
}
_resyncChatSidebarAfterPanelSwitch();
syncAppTitlebar();
return true;
}
@@ -658,11 +677,14 @@ async function _loadRunContent(jobId, filename, runId){
body.textContent = data.error;
return;
}
const expanded = _cronExpansionGet(_cronRunExpandKey(jobId, filename));
const output = expanded ? (data.content || data.snippet || '') : (data.snippet || data.content || '');
body.classList.toggle('expanded', expanded);
// Render markdown content using the same renderer as chat messages
if (typeof renderMd === 'function') {
body.innerHTML = renderMd(data.snippet || data.content);
body.innerHTML = renderMd(output);
} else {
body.textContent = data.snippet || data.content;
body.textContent = output;
}
const usageStrip = _formatCronRunUsageStrip(data.usage);
if (usageStrip) {
@@ -671,13 +693,15 @@ async function _loadRunContent(jobId, filename, runId){
usage.textContent = usageStrip;
body.appendChild(usage);
}
// Show "View full output" button if content was truncated
if (data.content && data.snippet && data.content.length > data.snippet.length) {
// Show "View full output" button only for collapsed previews. Expanded rows render the full body inline.
if (!expanded && data.content && data.snippet && data.content.length > data.snippet.length) {
const btn = document.createElement('button');
btn.style.cssText = 'margin-top:8px;padding:4px 12px;border-radius:var(--radius-btn);border:1px solid var(--border-subtle);background:var(--surface-subtle);color:var(--text-secondary);cursor:pointer;font-size:12px';
btn.textContent = t('cron_view_full_output') || 'View full output';
btn.onclick = () => {
body.innerHTML = renderMd ? renderMd(data.content) : '';
_cronExpansionSet(_cronRunExpandKey(jobId, filename), true);
body.classList.add('expanded');
body.innerHTML = renderMd ? renderMd(data.content) : data.content;
btn.remove();
};
body.appendChild(btn);
@@ -4629,6 +4653,38 @@ async function switchToWorkspace(path,name){
// ── Profile panel + dropdown ──
let _profilesCache = null;
let _profileSwitchGeneration = 0;
async function _profileSwitchPanelLoad(){
if (_currentPanel === 'skills') await loadSkills();
if (_currentPanel === 'memory') await loadMemory();
if (_currentPanel === 'tasks') await loadCrons();
if (_currentPanel === 'kanban') await loadKanban();
if (_currentPanel === 'profiles') await loadProfilesPanel();
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
}
function _refreshProfileSwitchBackground(gen){
window._modelDropdownReady=null;
if (typeof window._ensureModelDropdownReady === 'function') {
Promise.resolve(window._ensureModelDropdownReady()).catch(()=>{});
}
Promise.resolve(loadWorkspaceList()).then(()=>{
if (gen !== _profileSwitchGeneration) return;
if (S.session && typeof syncTopbar === 'function') syncTopbar();
}).catch(()=>{});
// Reconcile per-profile sidebar tab visibility. hidden_tabs is a per-profile
// appearance setting; without this fetch, Profile A's hidden-tabs choice
// would remain in effect under Profile B until the user opens Settings.
// Stage-394 follow-up to #2636 deep review.
Promise.resolve(api('/api/settings')).then(function(s){
if (gen !== _profileSwitchGeneration) return;
var hidden = (s && Array.isArray(s.hidden_tabs)) ? s.hidden_tabs : [];
hidden = hidden.filter(function(x){ return typeof x === 'string' && x.trim(); });
if (typeof _setHiddenTabs === 'function') _setHiddenTabs(hidden);
if (typeof _applyTabVisibility === 'function') _applyTabVisibility(hidden);
}).catch(function(){});
}
async function loadProfilesPanel() {
const panel = $('profilesPanel');
@@ -4891,6 +4947,7 @@ async function switchToProfile(name) {
const _chip = $('profileChip');
const _chipLabel = $('profileChipLabel');
const _prevProfileName = S.activeProfile || 'default';
const _switchGen = ++_profileSwitchGeneration;
if (_chip) { _chip.classList.add('switching'); _chip.disabled = true; }
// Optimistic name update — shows the target name right away
if (_chipLabel) _chipLabel.textContent = name;
@@ -4906,35 +4963,52 @@ async function switchToProfile(name) {
try {
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }) });
if (_switchGen !== _profileSwitchGeneration) return;
S.activeProfile = data.active || name;
// Update composer placeholder and title bar while the core profile-switch
// state is still close to the profile API response.
if (typeof applyBotName === 'function') applyBotName();
// ── Model + Workspace (parallelized) ───────────────────────────────────
// populateModelDropdown hits /api/models; loadWorkspaceList hits /api/workspaces.
// They are fully independent — run both simultaneously to cut switch time ~50%.
// ── Model + Workspace ──────────────────────────────────────────────────
// Apply the profile defaults returned by /api/profile/switch immediately.
// Refreshing the full model/workspace catalogs is useful, but it should not
// hold the visible switch animation open.
if(typeof _clearPersistedModelState==='function') _clearPersistedModelState();
else localStorage.removeItem('hermes-webui-model');
_skillsData = null;
_workspaceList = null;
await Promise.all([populateModelDropdown(), loadWorkspaceList()]);
if (data.default_model) window._defaultModel = data.default_model;
if (data.default_model_provider) window._activeProvider = data.default_model_provider;
// ── Apply model ────────────────────────────────────────────────────────
if (data.default_model) {
const sel = $('modelSelect');
const resolved = _applyModelToDropdown(data.default_model, sel, window._activeProvider||null);
const providerId = data.default_model_provider || window._activeProvider || null;
const existingDefaultOpt = sel ? Array.from(sel.options).find(o => o.value === data.default_model) : null;
if (existingDefaultOpt && providerId && !existingDefaultOpt.dataset.provider) {
existingDefaultOpt.dataset.provider = providerId;
}
if (sel && !existingDefaultOpt) {
const opt = document.createElement('option');
opt.value = data.default_model;
opt.textContent = typeof getModelLabel === 'function' ? getModelLabel(data.default_model) : data.default_model;
opt.dataset.custom = '1';
if (providerId) opt.dataset.provider = providerId;
sel.querySelectorAll('option[data-custom]').forEach(o => o.remove());
sel.appendChild(opt);
}
const resolved = _applyModelToDropdown(data.default_model, sel, providerId);
const modelToUse = resolved || data.default_model;
const modelState = (typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel, modelToUse)
: {model:modelToUse,model_provider:null};
: {model:modelToUse,model_provider:providerId};
S._pendingProfileModel = modelToUse;
S._pendingProfileModelProvider = modelState.model_provider||null;
S._pendingProfileModelProvider = modelState.model_provider||providerId||null;
// Only patch the in-memory session model if we're NOT about to replace the session
if (S.session && !sessionInProgress) {
S.session.model = modelToUse;
S.session.model_provider = modelState.model_provider||null;
S.session.model_provider = modelState.model_provider||providerId||null;
}
}
@@ -4963,23 +5037,14 @@ async function switchToProfile(name) {
// ── Session ────────────────────────────────────────────────────────────
_showAllProfiles = false;
if (typeof animateNextSessionListRefresh === 'function') animateNextSessionListRefresh();
if (sessionInProgress) {
// The current session has messages and belongs to the previous profile.
// Start a new session for the new profile so nothing gets cross-tagged.
await newSession(false);
// Apply profile default workspace to the newly created session (fixes #424)
if (S._profileDefaultWorkspace && S.session) {
try {
await api('/api/session/update', { method: 'POST', body: JSON.stringify({
session_id: S.session.session_id,
workspace: S._profileDefaultWorkspace,
model: S.session.model,
model_provider: S.session.model_provider||null,
})});
S.session.workspace = S._profileDefaultWorkspace;
} catch (_) {}
}
const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed';
await newSession(false, {awaitWorkspaceLoad: workspaceVisible});
if (_switchGen !== _profileSwitchGeneration) return;
// Keep topbar chips (workspace/profile) in sync after creating the
// new profile-scoped session.
syncTopbar();
@@ -4988,28 +5053,27 @@ async function switchToProfile(name) {
} else {
// No messages yet — just refresh the list and topbar in place
await renderSessionList();
if (_switchGen !== _profileSwitchGeneration) return;
syncTopbar();
// Refresh workspace file tree so the right panel shows the new
// profile's workspace, not the previous one (#1214).
if (S.session && S.session.workspace) loadDir('.');
if (S.session && S.session.workspace) {
const dirLoad = loadDir('.');
if (typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed') await dirLoad;
}
showToast(t('profile_switched', name));
}
// ── Sidebar panels ─────────────────────────────────────────────────────
if (_currentPanel === 'skills') await loadSkills();
if (_currentPanel === 'memory') await loadMemory();
if (_currentPanel === 'tasks') await loadCrons();
if (_currentPanel === 'kanban') await loadKanban();
if (_currentPanel === 'profiles') await loadProfilesPanel();
if (_currentPanel === 'workspaces') await loadWorkspacesPanel();
await _profileSwitchPanelLoad();
_refreshProfileSwitchBackground(_switchGen);
} catch (e) {
// Revert the optimistic name update on error
if (_chipLabel) _chipLabel.textContent = _prevProfileName;
showToast(t('switch_failed') + e.message);
if (_switchGen === _profileSwitchGeneration && _chipLabel) _chipLabel.textContent = _prevProfileName;
if (_switchGen === _profileSwitchGeneration) showToast(t('switch_failed') + e.message);
} finally {
// Always remove loading indicator regardless of success or failure
if (_chip) { _chip.classList.remove('switching'); _chip.disabled = false; }
if (_switchGen === _profileSwitchGeneration && _chip) { _chip.classList.remove('switching'); _chip.disabled = false; }
}
}
@@ -5228,6 +5292,86 @@ let _settingsAppearanceAutosaveRetryPayload = null;
let _settingsPreferencesAutosaveTimer = null;
let _settingsPreferencesAutosaveRetryPayload = null;
// ── Sidebar tab visibility ─────────────────────────────────────────────────
const _ALWAYS_VISIBLE_TABS = new Set(['chat','settings']);
const _HIDDEN_TABS_LS_KEY = 'hermes-webui-hidden-tabs';
function _getHiddenTabs(){
try{var h=localStorage.getItem(_HIDDEN_TABS_LS_KEY);if(h){var p=JSON.parse(h);if(Array.isArray(p))return p;}}catch(e){}
return[];
}
function _setHiddenTabs(panels){
try{localStorage.setItem(_HIDDEN_TABS_LS_KEY,JSON.stringify(panels));}catch(e){}
}
function _applyTabVisibility(hidden){
if(!Array.isArray(hidden)) hidden=[];
// Hide/unhide all [data-panel] elements (sidebar-nav buttons + rail buttons)
document.querySelectorAll('[data-panel]').forEach(function(el){
var panel=el.dataset.panel;
if(!panel)return;
var shouldHide=hidden.indexOf(panel)!==-1;
// Never hide always-visible panels (chat, settings) even if present in hidden_tabs
if(_ALWAYS_VISIBLE_TABS.has(panel)) shouldHide=false;
el.classList.toggle('nav-tab-hidden',shouldHide);
});
// If the currently active tab is hidden, switch to chat
var activeRail=document.querySelector('.rail .rail-btn.nav-tab.active[data-panel]');
var activeSidebar=document.querySelector('.sidebar-nav .nav-tab.active[data-panel]');
var activeEl=activeRail||activeSidebar;
if(activeEl&&activeEl.classList.contains('nav-tab-hidden')){
if(typeof switchPanel==='function') switchPanel('chat');
}
}
function _renderTabVisibilityChips(){
var container=$('tabVisibilityChips');
if(!container)return;
var hidden=_getHiddenTabs();
// Scan rail buttons to discover all available panels (skip always-visible + dashboard-link)
var tabs=document.querySelectorAll('.rail .rail-btn.nav-tab[data-panel]');
container.innerHTML='';
tabs.forEach(function(tab){
var panel=tab.dataset.panel;
if(!panel||_ALWAYS_VISIBLE_TABS.has(panel))return;
if(tab.classList.contains('dashboard-link'))return;
var label=tab.dataset.tooltip||tab.dataset.label||panel;
// Capitalize first letter
label=label.charAt(0).toUpperCase()+label.slice(1);
var chip=document.createElement('button');
chip.type='button';
chip.className='tab-visibility-chip';
var isOff=hidden.indexOf(panel)!==-1;
if(isOff)chip.classList.add('chip-off');
chip.textContent=label;
chip.setAttribute('data-tab-panel',panel);
// Use role="switch" + aria-checked instead of aria-pressed so screen
// readers narrate "Tasks switch on/off" (matches user mental model) rather
// than "Tasks toggle button pressed/not-pressed" (where the polarity is
// confusing because chip-off looks like the "off" state).
chip.setAttribute('role','switch');
chip.setAttribute('aria-checked',isOff?'false':'true');
chip.onclick=function(){_toggleTabVisibilityChip(panel);};
container.appendChild(chip);
});
}
function _toggleTabVisibilityChip(panel){
if(_ALWAYS_VISIBLE_TABS.has(panel))return;
var hidden=_getHiddenTabs();
var idx=hidden.indexOf(panel);
if(idx!==-1){
hidden.splice(idx,1);
}else{
hidden.push(panel);
}
_setHiddenTabs(hidden);
_applyTabVisibility(hidden);
_renderTabVisibilityChips();
_scheduleAppearanceAutosave();
}
function switchSettingsSection(name){
const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='plugins'||name==='system')?name:'conversation';
_settingsSection=section;
@@ -5355,6 +5499,7 @@ function _appearancePayloadFromUi(){
font_size: ($('settingsFontSize')||{}).value || localStorage.getItem('hermes-font-size') || 'default',
session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked,
session_endless_scroll: !!($('settingsSessionEndlessScroll')||{}).checked,
hidden_tabs: _getHiddenTabs(),
};
}
@@ -5432,6 +5577,8 @@ function _preferencesPayloadFromUi(){
if(showUsageCb) payload.show_token_usage=showUsageCb.checked;
const showQuotaChipCb=$('settingsShowQuotaChip');
if(showQuotaChipCb) payload.show_quota_chip=showQuotaChipCb.checked;
const hideSuggestionsCb=$('settingsHideSuggestions');
if(hideSuggestionsCb) payload.hide_empty_state_suggestions=hideSuggestionsCb.checked;
const showTpsCb=$('settingsShowTps');
if(showTpsCb) payload.show_tps=showTpsCb.checked;
const fadeTextCb=$('settingsFadeTextEffect');
@@ -5458,6 +5605,8 @@ function _preferencesPayloadFromUi(){
if(notifCb) payload.notifications_enabled=notifCb.checked;
const sidebarDensitySel=$('settingsSidebarDensity');
if(sidebarDensitySel) payload.sidebar_density=sidebarDensitySel.value;
const pinnedLimitField=$('settingsPinnedSessionsLimit');
if(pinnedLimitField) payload.pinned_sessions_limit=parseInt(pinnedLimitField.value,10);
const autoTitleRefreshSel=$('settingsAutoTitleRefresh');
if(autoTitleRefreshSel) payload.auto_title_refresh_every=parseInt(autoTitleRefreshSel.value,10);
const busyInputModeSel=$('settingsBusyInputMode');
@@ -5509,11 +5658,16 @@ async function _autosavePreferencesSettings(payload){
if(typeof renderMessages==='function') renderMessages();
}
if(payload&&Object.prototype.hasOwnProperty.call(payload,'fade_text_effect')) window._fadeTextEffect=!!payload.fade_text_effect;
if(saved&&Object.prototype.hasOwnProperty.call(saved,'pinned_sessions_limit')) window._pinnedSessionsLimit=parseInt(saved.pinned_sessions_limit,10)||3;
if(payload&&payload.show_tps!==undefined){
window._showTps=!!(saved&&saved.show_tps);
if(typeof clearMessageRenderCache==='function') clearMessageRenderCache();
if(typeof renderMessages==='function') renderMessages();
}
if(payload&&payload.hide_empty_state_suggestions!==undefined){
window._hideEmptyStateSuggestions=!!(saved&&saved.hide_empty_state_suggestions);
if(typeof applyEmptyStateSuggestionPref==='function') applyEmptyStateSuggestionPref();
}
_settingsPreferencesAutosaveRetryPayload=null;
_setPreferencesAutosaveStatus('saved');
// Only clear the global dirty flag and hide the unsaved-changes bar when
@@ -5610,6 +5764,18 @@ async function loadSettingsPanel(){
_scheduleAppearanceAutosave();
};
}
// Tab visibility chips (dynamically populated from DOM)
var hiddenTabs=[];
if(Array.isArray(settings.hidden_tabs)){
// Server value takes priority — even an empty array means "no tabs hidden"
hiddenTabs=settings.hidden_tabs.filter(function(s){return typeof s==='string'&&s.trim();});
}else{
// Server has no hidden_tabs key — fall back to localStorage
hiddenTabs=_getHiddenTabs();
}
_setHiddenTabs(hiddenTabs);
_applyTabVisibility(hiddenTabs);
_renderTabVisibilityChips();
const resolvedLanguage=(typeof resolvePreferredLocale==='function')
? resolvePreferredLocale(settings.language, localStorage.getItem('hermes-lang'))
: (settings.language || localStorage.getItem('hermes-lang') || 'en');
@@ -5686,8 +5852,26 @@ async function loadSettingsPanel(){
_schedulePreferencesAutosave();
},{once:false});
}
const hideSuggestionsCb=$('settingsHideSuggestions');
if(hideSuggestionsCb){
hideSuggestionsCb.checked=settings.hide_empty_state_suggestions===true;
window._hideEmptyStateSuggestions=hideSuggestionsCb.checked;
if(typeof applyEmptyStateSuggestionPref==='function') applyEmptyStateSuggestionPref();
hideSuggestionsCb.addEventListener('change',()=>{
window._hideEmptyStateSuggestions=hideSuggestionsCb.checked;
if(typeof applyEmptyStateSuggestionPref==='function') applyEmptyStateSuggestionPref();
_schedulePreferencesAutosave();
},{once:false});
}
const showTpsCb=$('settingsShowTps');
if(showTpsCb){showTpsCb.checked=!!settings.show_tps;showTpsCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const pinnedLimitField=$('settingsPinnedSessionsLimit');
if(pinnedLimitField){
pinnedLimitField.value=parseInt(settings.pinned_sessions_limit||3,10)||3;
window._pinnedSessionsLimit=parseInt(pinnedLimitField.value,10)||3;
pinnedLimitField.addEventListener('change',_schedulePreferencesAutosave,{once:false});
pinnedLimitField.addEventListener('input',()=>{window._pinnedSessionsLimit=parseInt(pinnedLimitField.value,10)||3;_schedulePreferencesAutosave();},{once:false});
}
const fadeTextCb=$('settingsFadeTextEffect');
if(fadeTextCb){fadeTextCb.checked=!!settings.fade_text_effect;window._fadeTextEffect=fadeTextCb.checked;fadeTextCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const simplifiedToolCb=$('settingsSimplifiedToolCalling');
@@ -5872,20 +6056,42 @@ function _buildPluginCard(plugin){
const card=document.createElement('div');
card.className='provider-card plugin-card';
card.dataset.plugin=(plugin&&plugin.key)||'';
// `activation` is the canonical state from /api/plugins (added in #2659).
// Fall back to the older `enabled` boolean when the field is missing so
// the panel still works against older backends.
const activation=(plugin&&typeof plugin.activation==='string')
? plugin.activation
: (plugin&&plugin.enabled===false ? 'disabled' : 'enabled');
const isProvider=activation==='exclusive'||activation==='provider';
const hooks=Array.isArray(plugin&&plugin.hooks)?plugin.hooks:[];
// Provider plugins (memory/web/browser/etc.) register hooks on their
// category's dispatcher, not the four agent-wide visibility hooks the
// payload filters to. Show an explanatory line instead of the generic
// "No registered lifecycle hooks" when the visibility-hook list is empty.
const hookHtml=hooks.length
? hooks.map(h=>`<span class="plugin-hook-badge">${esc(h)}</span>`).join('')
: '<span class="plugin-hook-empty">'+t('plugins_no_hooks')+'</span>';
: '<span class="plugin-hook-empty">'+t(isProvider?'plugins_provider_no_hooks':'plugins_no_hooks')+'</span>';
const version=(plugin&&plugin.version)?' · v'+esc(plugin.version):'';
const desc=(plugin&&plugin.description)?esc(plugin.description):t('plugins_no_description');
const enabled=plugin&&plugin.enabled!==false;
let badgeText;
let badgeClass;
if(isProvider){
badgeText=t('plugins_active_provider');
badgeClass='plugin-card-badge-provider';
}else if(activation==='enabled'){
badgeText=t('plugins_enabled');
badgeClass='';
}else{
badgeText=t('plugins_disabled');
badgeClass='plugin-card-badge-disabled';
}
card.innerHTML=`
<div class="provider-card-header plugin-card-header">
<div class="provider-card-info">
<div class="provider-card-name">${esc((plugin&&plugin.name)||t('plugins_unnamed'))}</div>
<div class="provider-card-meta">${esc((plugin&&plugin.key)||'plugin')}${version}</div>
</div>
<span class="provider-card-badge ${enabled?'':'plugin-card-badge-disabled'}">${enabled?t('plugins_enabled'):t('plugins_disabled')}</span>
<span class="provider-card-badge ${badgeClass}">${badgeText}</span>
</div>
<div class="provider-card-body plugin-card-body">
<div class="provider-card-hint">${desc}</div>
@@ -5914,7 +6120,7 @@ async function loadProvidersPanel(){
try{
const data=await api('/api/providers');
const quota=await _fetchProviderQuotaStatus(false).catch(e=>({ok:false,status:'unavailable',quota:null,message:e.message||t('provider_quota_unavailable'),client_fetched_at:new Date().toISOString()}));
const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth);
const providers=(data.providers||[]).filter(p=>p.configurable||p.is_oauth||p.is_custom);
list.innerHTML='';
_providerCardEls.clear();
const quotaCard=_buildProviderQuotaCard(quota);
@@ -6241,48 +6447,59 @@ function _buildProviderCard(p){
return card;
}
const field=document.createElement('div');
field.className='provider-card-field';
const label=document.createElement('label');
label.className='provider-card-label';
label.textContent=t('providers_status_api_key');
field.appendChild(label);
let input=null;
let saveBtn=null;
if(p.configurable){
const field=document.createElement('div');
field.className='provider-card-field';
const label=document.createElement('label');
label.className='provider-card-label';
label.textContent=t('providers_status_api_key');
field.appendChild(label);
const row=document.createElement('div');
row.className='provider-card-row';
const input=document.createElement('input');
input.type='password';
input.className='provider-card-input';
input.placeholder=p.has_key?t('providers_key_placeholder_replace'):t('providers_key_placeholder_new');
input.autocomplete='off';
const toggleBtn=document.createElement('button');
toggleBtn.type='button';
toggleBtn.className='provider-card-btn provider-card-btn-ghost';
toggleBtn.textContent='Show';
toggleBtn.onclick=()=>{
const revealed=input.type==='text';
input.type=revealed?'password':'text';
toggleBtn.textContent=revealed?'Show':'Hide';
};
const saveBtn=document.createElement('button');
saveBtn.type='button';
saveBtn.className='provider-card-btn provider-card-btn-primary';
saveBtn.textContent=t('providers_save');
saveBtn.onclick=()=>_saveProviderKey(p.id);
saveBtn.disabled=true;
row.appendChild(input);
row.appendChild(toggleBtn);
row.appendChild(saveBtn);
if(p.has_key){
const removeBtn=document.createElement('button');
removeBtn.type='button';
removeBtn.className='provider-card-btn provider-card-btn-danger';
removeBtn.textContent=t('providers_remove');
removeBtn.onclick=()=>_removeProviderKey(p.id);
row.appendChild(removeBtn);
const row=document.createElement('div');
row.className='provider-card-row';
input=document.createElement('input');
input.type='password';
input.className='provider-card-input';
input.placeholder=p.has_key?t('providers_key_placeholder_replace'):t('providers_key_placeholder_new');
input.autocomplete='off';
const toggleBtn=document.createElement('button');
toggleBtn.type='button';
toggleBtn.className='provider-card-btn provider-card-btn-ghost';
toggleBtn.textContent='Show';
toggleBtn.onclick=()=>{
const revealed=input.type==='text';
input.type=revealed?'password':'text';
toggleBtn.textContent=revealed?'Show':'Hide';
};
saveBtn=document.createElement('button');
saveBtn.type='button';
saveBtn.className='provider-card-btn provider-card-btn-primary';
saveBtn.textContent=t('providers_save');
saveBtn.onclick=()=>_saveProviderKey(p.id);
saveBtn.disabled=true;
row.appendChild(input);
row.appendChild(toggleBtn);
row.appendChild(saveBtn);
if(p.has_key){
const removeBtn=document.createElement('button');
removeBtn.type='button';
removeBtn.className='provider-card-btn provider-card-btn-danger';
removeBtn.textContent=t('providers_remove');
removeBtn.onclick=()=>_removeProviderKey(p.id);
row.appendChild(removeBtn);
}
field.appendChild(row);
body.appendChild(field);
}else{
const hint=document.createElement('div');
hint.className='provider-card-hint';
hint.textContent=p.is_custom
? 'Custom provider loaded from config.yaml / hermes model. Edit it from the CLI or config file.'
: 'Provider is managed outside the WebUI.';
body.appendChild(hint);
}
field.appendChild(row);
body.appendChild(field);
// Model list — show when provider has known models
if(modelCount>0){
@@ -6336,8 +6553,10 @@ function _buildProviderCard(p){
body.appendChild(refreshRow);
card.appendChild(body);
_providerCardEls.set(p.id,{card,input,saveBtn,hasKey:p.has_key});
input.addEventListener('input',()=>{saveBtn.disabled=!input.value.trim();});
if(input&&saveBtn){
_providerCardEls.set(p.id,{card,input,saveBtn,hasKey:p.has_key});
input.addEventListener('input',()=>{saveBtn.disabled=!input.value.trim();});
}
header.addEventListener('click',e=>{
// Don't toggle when clicking inside body (defensive; body isn't inside header)
if(e.target.closest('.provider-card-body')) return;
@@ -6512,6 +6731,14 @@ async function checkUpdatesNow(){
if(data.disabled){
if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';}
} else {
const errorParts=[];
const formatUpdateError=(typeof _formatUpdateCheckError==='function')
? _formatUpdateCheckError
: ((label,info)=>info&&info.error?label:null);
const webuiError=formatUpdateError('WebUI',data.webui);
const agentError=formatUpdateError('Agent',data.agent);
if(webuiError) errorParts.push(webuiError);
if(agentError) errorParts.push(agentError);
const parts=[];
const formatUpdatePart=(typeof _formatUpdateTargetStatus==='function')
? _formatUpdateTargetStatus
@@ -6524,6 +6751,8 @@ async function checkUpdatesNow(){
if(status){status.textContent=t('settings_updates_available').replace('{count}',parts.join(', '));status.style.color='var(--accent)';}
// Also trigger the update banner
if(typeof _showUpdateBanner==='function') _showUpdateBanner(data);
} else if(errorParts.length){
if(status){status.textContent=t('settings_update_check_failed')+': '+errorParts.join(', ');status.style.color='var(--error)';}
} else {
if(status){status.textContent=t('settings_up_to_date');status.style.color='var(--success)';}
if(typeof _showUpdateBanner==='function') _showUpdateBanner(data);
@@ -6558,6 +6787,7 @@ async function saveSettings(andClose){
const fadeTextEffect=!!($('settingsFadeTextEffect')||{}).checked;
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
const showPreviousMessagingSessions=!!($('settingsShowPreviousMessagingSessions')||{}).checked;
const pinnedSessionsLimit=parseInt(($('settingsPinnedSessionsLimit')||{}).value,10)||3;
const pw=($('settingsPassword')||{}).value;
const theme=($('settingsTheme')||{}).value||'dark';
const skin=($('settingsSkin')||{}).value||'default';
@@ -6582,6 +6812,7 @@ async function saveSettings(andClose){
body.api_redact_enabled=!!($('settingsApiRedact')||{}).checked;
body.show_cli_sessions=showCliSessions;
body.show_previous_messaging_sessions=showPreviousMessagingSessions;
body.pinned_sessions_limit=pinnedSessionsLimit;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
+266 -28
View File
@@ -310,6 +310,9 @@ function _purgeStaleInflightEntries() {
}
}
for (const sid of Object.keys(INFLIGHT)) {
if (typeof _sendInProgress !== 'undefined' && _sendInProgress && sid === _sendInProgressSid) {
continue;
}
if (!sessionsById.has(sid)) {
// Session is absent from _allSessions — it was deleted / archived /
// filtered and can never stream again, so drop the entry.
@@ -460,23 +463,7 @@ async function newSession(flash, options={}){
const switchWs=S._profileSwitchWorkspace;
S._profileSwitchWorkspace=null;
const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null);
// Use the saved default model for new sessions (#872). The user's saved
// default_model (from Settings) takes priority over the chat-header dropdown
// value, which reflects the *previous* session's model. Fall back to the
// dropdown value only when no default_model is configured.
const modelSel=$('modelSelect');
const selectedDefaultModel=window._defaultModel||(modelSel&&modelSel.value)||'';
let defaultApplied=false;
if(window._defaultModel&&modelSel&&typeof _applyModelToDropdown==='function'){
defaultApplied=!!_applyModelToDropdown(window._defaultModel,modelSel,window._activeProvider||null);
}
const canQualify=!window._defaultModel||defaultApplied||(modelSel&&modelSel.value===selectedDefaultModel);
const newModelState=(canQualify&&typeof _modelStateForSelect==='function')
? _modelStateForSelect(modelSel,selectedDefaultModel)
: {model:selectedDefaultModel,model_provider:null};
const reqBody={
model:newModelState.model,
model_provider:newModelState.model_provider||null,
workspace:inheritWs,
profile:S.activeProfile||'default',
};
@@ -490,11 +477,31 @@ async function newSession(flash, options={}){
try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){}
_setActiveSessionUrl(S.session.session_id);
_setSessionViewedCount(S.session.session_id, S.session.message_count || 0);
// Sync chat-header dropdown to the session's model so the UI reflects
// the default model the server actually used (#872).
if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){
_applyModelToDropdown(S.session.model,$('modelSelect'),S.session.model_provider||null);
if(typeof syncModelChip==='function') syncModelChip();
// Sync chat-header dropdown to the session's model/provider so the UI reflects
// the default route the server actually used (#872). Compare provider state too:
// duplicate model ids can exist under several providers, and a stale persisted
// picker selection with the same model id should not mask the new session's
// configured default provider.
const modelSel=$('modelSelect');
if(S.session.model && modelSel && typeof _applyModelToDropdown==='function'){
const currentModelState=(typeof _modelStateForSelect==='function')
? _modelStateForSelect(modelSel,modelSel.value)
: {model:modelSel.value,model_provider:null};
const sessionProvider=S.session.model_provider||null;
const currentProvider=currentModelState.model_provider||null;
if(S.session.model!==modelSel.value || sessionProvider !== currentProvider){
let sessionModelApplied=_applyModelToDropdown(S.session.model,modelSel,sessionProvider);
if(!sessionModelApplied){
const opt=document.createElement('option');
opt.value=S.session.model;
opt.textContent=typeof getModelLabel==='function'?getModelLabel(S.session.model):S.session.model;
opt.dataset.custom='1';
opt.dataset.provider=sessionProvider||'';
modelSel.appendChild(opt);
sessionModelApplied=_applyModelToDropdown(S.session.model,modelSel,sessionProvider);
}
if(sessionModelApplied&&typeof syncModelChip==='function') syncModelChip();
}
}
// Reset per-session visual state: a fresh chat is idle even if another
// conversation is still streaming in the background.
@@ -518,7 +525,12 @@ async function newSession(flash, options={}){
});
}
updateQueueBadge(S.session.session_id);
syncTopbar();renderMessages();loadDir('.');
syncTopbar();renderMessages();
const dirLoad=loadDir('.');
// loadDir('.') is fire-and-forget while the workspace panel is closed:
// waiting would block new-chat/profile-switch flow for users who never open
// the file tree. When visible, wait so the file list lands with the session.
if(options&&options.awaitWorkspaceLoad) await dirLoad;
// don't call renderSessionList here - callers do it when needed
})();
try{
@@ -564,10 +576,14 @@ async function loadSession(sid){
if (_msgInner && currentSid !== sid) _msgInner.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text-muted);font-size:14px;padding:40px;text-align:center;">Loading conversation...</div>';
}
// Phase 1: Load metadata only (~1KB) for fast session switching.
// Resolve model immediately: old sessions can persist stale provider-shaped
// IDs (e.g. openai/gpt-5.4-mini) and assigning those to S.session creates a
// short race where the composer can display/send the wrong model before the
// deferred resolver catches up.
// Guard against network/server failures to prevent a permanently stuck loading state.
let data;
try {
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=0`);
data = await api(`/api/session?session_id=${encodeURIComponent(sid)}&messages=0&resolve_model=1`);
} catch(e) {
const _msgInner = $('msgInner');
if(_msgInner){
@@ -606,6 +622,7 @@ async function loadSession(sid){
if (typeof window !== 'undefined' && typeof window._resetScrollDirectionTracker === 'function') {
try { window._resetScrollDirectionTracker(); } catch (_) {}
}
if(typeof _applyPendingSessionModelForSession==='function') _applyPendingSessionModelForSession(sid);
// Sync workspace display immediately so the chip label reflects the new session's workspace
// before any async message-loading begins (mirrors how model is handled).
if(typeof syncTopbar==='function') syncTopbar();
@@ -1264,10 +1281,21 @@ function _messageComparableText(m){
return String(m.content||'').trim();
}
function _stripAttachedFilesMarker(text){
return String(text||'').replace(/\n\n\[Attached files: [^\]]+\]$/,'').trim();
}
function _sameTranscriptMessage(a,b){
return !!(a&&b) &&
String(a.role||'')===String(b.role||'') &&
_messageComparableText(a)===_messageComparableText(b);
if(!(a&&b)) return false;
const role=String(a.role||'');
if(role!==String(b.role||'')) return false;
const aText=_messageComparableText(a);
const bText=_messageComparableText(b);
if(aText===bText) return true;
if(role==='user'){
return _stripAttachedFilesMarker(aText)===_stripAttachedFilesMarker(bText);
}
return false;
}
function _mergeInflightTailMessages(baseMessages, inflightMessages){
@@ -1475,6 +1503,17 @@ function _sessionSnapshotById(sid){
if(S.session&&S.session.session_id===sid) return S.session;
return (_allSessions||[]).find(s=>s&&s.session_id===sid)||null;
}
function _pinnedSessionCount(){
return (_allSessions||[]).filter(s=>s&&s.pinned&&!s.archived).length;
}
function _getPinnedSessionsLimit(){
const limit=parseInt(window._pinnedSessionsLimit||3,10);
return (Number.isFinite(limit)&&limit>0)?limit:3;
}
function _pinnedSessionsLimitMessage(){
const limit=_getPinnedSessionsLimit();
return `Only ${limit} conversations can be pinned. Unpin one before pinning another.`;
}
function _worktreeSessionCount(ids){
return (ids||[]).reduce((count,sid)=>{
const session=_sessionSnapshotById(sid);
@@ -1785,12 +1824,17 @@ function _openSessionActionMenu(session, anchorEl){
}
));
}
const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();
menu.appendChild(_buildSessionAction(
session.pinned?t('session_unpin'):t('session_pin'),
session.pinned?t('session_unpin_desc'):t('session_pin_desc'),
pinLimitReached?_pinnedSessionsLimitMessage():(session.pinned?t('session_unpin_desc'):t('session_pin_desc')),
session.pinned?ICONS.pin:ICONS.unpin,
async()=>{
closeSessionActionMenu();
if(pinLimitReached){
if(typeof showToast==='function') showToast(_pinnedSessionsLimitMessage(),3000,'error');
return;
}
const newPinned=!session.pinned;
try{
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
@@ -1799,7 +1843,7 @@ function _openSessionActionMenu(session, anchorEl){
renderSessionList();
}catch(err){showToast(t('session_pin_failed')+err.message);}
},
session.pinned?'is-active':''
(session.pinned?'is-active':'')+(pinLimitReached?' is-disabled':'')
));
menu.appendChild(_buildSessionAction(
t('session_move_project'),
@@ -1914,6 +1958,56 @@ window.addEventListener('resize',()=>{
// concurrently. Without this guard, a slower older response can overwrite _allSessions
// with stale data, causing sessions to vanish from the sidebar.
let _renderSessionListGen = 0;
let _sessionListRefreshAnimationPending = false;
let _sessionListFirstRenderAnimated = false;
let _sessionListEnterAllAnimationPending = false;
function animateNextSessionListRefresh(options={}){
_sessionListRefreshAnimationPending = true;
if(options&&options.enterAll) _sessionListEnterAllAnimationPending = true;
}
function _captureSessionListFlipPositions(){
const list=$('sessionList');
if(!list) return null;
const positions=new Map();
list.querySelectorAll('.session-item[data-sid]').forEach(row=>{
positions.set(row.dataset.sid,row.getBoundingClientRect().top);
});
return positions;
}
function _sessionListPrefersReducedMotion(){
try{return window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;}
catch(_){return false;}
}
function _playSessionListFlipAnimation(before){
if(!before||!before.size||_sessionListPrefersReducedMotion()) return;
const list=$('sessionList');
if(!list) return;
list.querySelectorAll('.session-item[data-sid]').forEach(row=>{
const oldTop=before.get(row.dataset.sid);
if(oldTop===undefined) return;
const delta=oldTop-row.getBoundingClientRect().top;
if(Math.abs(delta)<1) return;
row.style.setProperty('--session-reflow-offset',delta+'px');
row.classList.add('session-reflowing');
row.getBoundingClientRect();
row.style.setProperty('--session-reflow-offset','0px');
let cleared=false;
const clear=()=>{
if(cleared) return;
cleared=true;
row.classList.remove('session-reflowing');
row.style.removeProperty('--session-reflow-offset');
row.removeEventListener('transitionend',onEnd);
};
const onEnd=(event)=>{ if(event.propertyName==='transform') clear(); };
row.addEventListener('transitionend',onEnd);
setTimeout(clear,460);
});
}
function _isOptimisticFirstTurnSessionRow(s){
if(!s||!s.session_id||s.archived) return false;
@@ -2018,6 +2112,11 @@ function _applySessionListPayload(sessData, projData){
}
ensureSessionTimeRefreshPoll();
ensureActiveSessionExternalRefreshPoll();
if(!_sessionListFirstRenderAnimated&&Array.isArray(_allSessions)&&_allSessions.length){
animateNextSessionListRefresh({enterAll:true});
_sessionListFirstRenderAnimated=true;
}
ensureSessionEventsSSE();
renderSessionListFromCache(); // no-ops if rename is in progress
}
@@ -2056,6 +2155,22 @@ let _streamingPollTimer = null;
let _sessionTimeRefreshTimer = null;
let _activeSessionExternalRefreshTimer = null;
let _activeSessionExternalRefreshInFlight = false;
let _sessionEventsSSE = null;
let _sessionEventsRefreshTimer = 0;
let _sessionEventsReconnectTimer = 0;
let _sessionEventsNeedsRefreshOnOpen = false;
let _sessionEventsReconnectAttempt = 0;
const _sessionEventsReconnectBaseMs = 5000;
const _sessionEventsReconnectMaxMs = 30000;
function _sessionEventsReconnectDelayMs(){
const attempt = Math.max(0, Number(_sessionEventsReconnectAttempt || 0));
const base = Math.min(_sessionEventsReconnectMaxMs, _sessionEventsReconnectBaseMs * Math.pow(2, attempt));
const jitter = Math.floor(Math.random() * Math.max(1, Math.floor(base * 0.35)));
return Math.min(_sessionEventsReconnectMaxMs, Math.floor(base * 0.75) + jitter);
}
let _sessionListRefreshInFlight = false;
let _sessionListRefreshPendingReason = '';
function startStreamingPoll(){
if(_streamingPollTimer) return;
@@ -2121,6 +2236,86 @@ function ensureActiveSessionExternalRefreshPoll(){
}
}
async function refreshSessionList(reason='manual', opts={}){
const force = !!(opts && opts.force);
const refreshActive = !!(opts && opts.refreshActive);
if(!force && typeof document !== 'undefined' && document.hidden) return;
if(_sessionListRefreshInFlight){
_sessionListRefreshPendingReason = reason || 'session-list';
return;
}
_sessionListRefreshInFlight = true;
try{
await renderSessionList({deferWhileInteracting:!force});
if(refreshActive) await refreshActiveSessionIfExternallyUpdated(reason||'session-list');
}finally{
_sessionListRefreshInFlight = false;
const pendingReason = _sessionListRefreshPendingReason;
_sessionListRefreshPendingReason = '';
if(pendingReason) _scheduleSessionEventsRefresh(pendingReason);
}
}
function _scheduleSessionEventsRefresh(reason){
if(_sessionEventsRefreshTimer) return;
_sessionEventsRefreshTimer = setTimeout(() => {
_sessionEventsRefreshTimer = 0;
void refreshSessionList(reason||'event');
}, 300);
}
function _closeSessionEventsSSE(){
if(_sessionEventsSSE){
_sessionEventsSSE.close();
_sessionEventsSSE = null;
}
}
function ensureSessionEventsSSE(){
if(typeof document !== 'undefined' && !document._hermesSessionEventsVisibilityHook){
document.addEventListener('visibilitychange', () => {
if(document.hidden){
_closeSessionEventsSSE();
}else{
ensureSessionEventsSSE();
void refreshSessionList('visible');
}
});
document._hermesSessionEventsVisibilityHook = true;
}
if(typeof EventSource==='undefined') return;
if(typeof document !== 'undefined' && document.hidden) return;
if(_sessionEventsSSE) return;
try{
// Same-origin relative URL preserves subpath mounts and normal WebUI cookies.
_sessionEventsSSE = new EventSource('api/sessions/events');
_sessionEventsSSE.onopen = () => {
_sessionEventsReconnectAttempt = 0;
if(!_sessionEventsNeedsRefreshOnOpen) return;
_sessionEventsNeedsRefreshOnOpen = false;
void refreshSessionList('reconnect');
};
_sessionEventsSSE.addEventListener('sessions_changed', () => {
_scheduleSessionEventsRefresh('event');
});
_sessionEventsSSE.onerror = () => {
_sessionEventsNeedsRefreshOnOpen = true;
_closeSessionEventsSSE();
if(_sessionEventsReconnectTimer) return;
const delayMs = _sessionEventsReconnectDelayMs();
_sessionEventsReconnectAttempt = Math.min(_sessionEventsReconnectAttempt + 1, 6);
_sessionEventsReconnectTimer = setTimeout(() => {
_sessionEventsReconnectTimer = 0;
ensureSessionEventsSSE();
}, delayMs);
};
}catch(e){
_closeSessionEventsSSE();
}
}
if(typeof window!=='undefined') window.refreshSessionList = refreshSessionList;
function startGatewayPollFallback(ms){
const intervalMs = Math.max(5000, Number(ms) || _gatewayFallbackPollMs);
if(_gatewayPollTimer) clearInterval(_gatewayPollTimer);
@@ -2770,9 +2965,30 @@ function _markSessionListPointerUp(){
if(_pendingSessionListPayload) _schedulePendingSessionListApply();
}
let _sessionVirtualResyncRaf = 0;
function _resyncSessionVirtualWindowAfterRender(list, expectedScrollTop, virtualWindow){
if(!list||!virtualWindow||!virtualWindow.virtualized) return;
expectedScrollTop=Number(expectedScrollTop)||0;
if(expectedScrollTop<=0) return;
if(_sessionVirtualResyncRaf) cancelAnimationFrame(_sessionVirtualResyncRaf);
_sessionVirtualResyncRaf=requestAnimationFrame(()=>{
_sessionVirtualResyncRaf=0;
if(_renamingSid) return;
const actualScrollTop=Number(list.scrollTop)||0;
const tolerance=Math.max(2, Number(virtualWindow.itemHeight||SESSION_VIRTUAL_ROW_HEIGHT)/2);
if(Math.abs(actualScrollTop-expectedScrollTop)<=tolerance) return;
renderSessionListFromCache();
});
}
function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
// Keep the per-conversation actions menu stable while the user is trying to
// click it. Sidebar syncs, stream/unread updates, and panel-resync repairs can
// all call this while the fixed-position menu is open; rebuilding the row DOM
// here removes the anchor and makes the menu feel unclickable.
if(_sessionActionMenu) return;
closeSessionActionMenu();
// Purge stale INFLIGHT entries for sessions the server confirms are NOT
// streaming. This runs on every list refresh to prevent memory leaks from
@@ -2817,6 +3033,11 @@ function renderSessionListFromCache(){
_syncSidebarExpansionForActiveSession(sessions, activeSidForSidebar);
const archivedCount=projectFiltered.filter(s=>s.archived).length;
const list=$('sessionList');
const animateRefresh=_sessionListRefreshAnimationPending;
_sessionListRefreshAnimationPending=false;
const enterAllAnimatedRows=animateRefresh&&_sessionListEnterAllAnimationPending;
_sessionListEnterAllAnimationPending=false;
const flipBefore=animateRefresh?_captureSessionListFlipPositions():null;
const listScrollTopBeforeRender=list.scrollTop||0;
list.innerHTML='';
// Batch select bar (when in select mode)
@@ -3047,6 +3268,7 @@ function renderSessionListFromCache(){
// scrollTop drops to 0 — producing a "scroll keeps jumping back" feel
// when the list scrolls naturally. Fixed for #1669 follow-up.
list.scrollTop=listScrollTopBeforeRender;
_resyncSessionVirtualWindowAfterRender(list, listScrollTopBeforeRender, virtualWindow);
}
// Select mode toggle button (only when NOT in select mode)
if(!_sessionSelectMode){
@@ -3055,6 +3277,9 @@ function renderSessionListFromCache(){
toggleBtn.onclick=(e)=>{e.stopPropagation();toggleSessionSelectMode();};
list.appendChild(toggleBtn);
}
if(animateRefresh){
_playSessionListFlipAnimation(flipBefore);
}
// Note: declared after the groups loop but available via function hoisting.
function _renderOneSession(s, isPinnedGroup=false){
const el=document.createElement('div');
@@ -3065,6 +3290,9 @@ function renderSessionListFromCache(){
const hasUnread=_hasUnreadForSession(s)&&!isActive;
const readOnly=_isReadOnlySession(s);
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(isStreaming?' streaming':'')+(hasUnread?' unread':'');
if(animateRefresh&&(enterAllAnimatedRows||!(flipBefore&&flipBefore.has(s.session_id)))){
el.classList.add('session-list-flip-enter');
}
if(s.is_cli_session){
el.classList.add('cli-session');
el.dataset.source=_getChannelLabel(s)||'CLI';
@@ -3388,6 +3616,16 @@ function renderSessionListFromCache(){
actions.appendChild(menuBtn);
el.appendChild(actions);
}
el.oncontextmenu=(e)=>{
if(readOnly) return;
e.preventDefault();
e.stopPropagation();
clearTimeout(_tapTimer);
_tapTimer=null;
_lastTapTime=0;
_clearPointerDragState();
_openSessionActionMenu(s, actions||el);
};
// Use pointerup + manual double-tap detection instead of onclick/ondblclick.
// onclick/ondblclick are unreliable on touch devices (iPad Safari especially):
+197 -1
View File
@@ -297,6 +297,179 @@
:root.dark[data-skin="nous"] .mcp-http{color:#7EB6E0;}
:root.dark[data-skin="nous"] .mcp-status-active{color:#7EB6E0;}
/* ── Skin: Geist Contrast (Geist-inspired neutral precision) ──
Dark mode uses a #FFF175 accent; solid accent controls use black
foreground, while selected/navigation states stay neutral and restrained. */
:root[data-skin="geist-contrast"]{
color-scheme:light;
--bg:#ffffff;--sidebar:#fafafa;--surface:#ffffff;--surface-subtle:#fafafa;--surface-subtle-hover:#f5f5f5;
--main-bg:#ffffff;--topbar-bg:rgba(255,255,255,.92);
--border:#eaeaea;--border2:#d4d4d4;--border-subtle:#ededed;--border-muted:#d4d4d4;
--text:#111111;--strong:#000000;--muted:#666666;--em:#444444;
--accent:#0070f3;--accent-hover:#005bd1;--accent-bg:rgba(0,112,243,.075);--accent-bg-strong:rgba(0,112,243,.16);--accent-text:#005bd1;
--blue:#0070f3;--gold:#111111;--focus-ring:rgba(0,112,243,.28);--focus-glow:rgba(0,112,243,.08);
--input-bg:#ffffff;--hover-bg:#f5f5f5;--code-bg:#fafafa;--code-inline-bg:#f5f5f5;--code-text:#111111;--pre-text:#111111;
--error:#e5484d;--success:#007a45;--warning:#b45309;--info:#0070f3;
--radius-sm:4px;--radius-md:6px;--radius-card:8px;--radius-lg:10px;
--font-ui:"Geist","Geist Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
}
:root.dark[data-skin="geist-contrast"]{
color-scheme:dark;
--bg:#000000;--sidebar:#050505;--surface:#0a0a0a;--surface-subtle:#111111;--surface-subtle-hover:#171717;
--main-bg:#000000;--topbar-bg:rgba(0,0,0,.88);
--border:#262626;--border2:#3f3f3f;--border-subtle:#171717;--border-muted:#2a2a2a;
--text:#ededed;--strong:#ffffff;--muted:#a1a1a1;--em:#d4d4d4;
--accent:#FFF175;--accent-hover:#fff7a8;--accent-bg:rgba(255,241,117,.075);--accent-bg-strong:rgba(255,241,117,.14);--accent-text:#f5e65f;
--blue:#FFF175;--gold:#FFF175;--focus-ring:rgba(255,241,117,.34);--focus-glow:rgba(255,241,117,.10);
--input-bg:#0a0a0a;--hover-bg:#111111;--code-bg:#0a0a0a;--code-inline-bg:#171717;--code-text:#f5f5f5;--pre-text:#ededed;
--error:#ff6369;--success:#3dd68c;--warning:#f5a524;--info:#FFF175;
}
:root[data-skin="geist-contrast"],
:root[data-skin="geist-contrast"] body{font-family:var(--font-ui)!important;letter-spacing:-0.011em;background:var(--bg)!important;}
:root[data-skin="geist-contrast"] .app-titlebar,
:root[data-skin="geist-contrast"] .rail,
:root[data-skin="geist-contrast"] .sidebar,
:root[data-skin="geist-contrast"] .rightpanel,
:root[data-skin="geist-contrast"] .topbar,
:root[data-skin="geist-contrast"] .composer-wrap{background:var(--sidebar)!important;border-color:var(--border)!important;box-shadow:none!important;backdrop-filter:saturate(180%) blur(12px);}
:root[data-skin="geist-contrast"] .main,
:root[data-skin="geist-contrast"] .chat,
:root[data-skin="geist-contrast"] .empty-state,
:root[data-skin="geist-contrast"] .panel-view,
:root[data-skin="geist-contrast"] .settings-section,
:root[data-skin="geist-contrast"] .settings-card,
:root[data-skin="geist-contrast"] .model-dropdown,
:root[data-skin="geist-contrast"] .profile-menu,
:root[data-skin="geist-contrast"] .session-actions-menu,
:root[data-skin="geist-contrast"] .app-dialog,
:root[data-skin="geist-contrast"] .kanban-modal,
:root[data-skin="geist-contrast"] .cron-item,
:root[data-skin="geist-contrast"] .tool-card,
:root[data-skin="geist-contrast"] .msg-body pre,
:root[data-skin="geist-contrast"] .preview-md pre,
:root[data-skin="geist-contrast"] .composer-box{background:var(--surface)!important;border:none!important;box-shadow:none!important;}
:root[data-skin="geist-contrast"] .session-item,
:root[data-skin="geist-contrast"] .nav-tab,
:root[data-skin="geist-contrast"] .rail-btn,
:root[data-skin="geist-contrast"] .icon-btn,
:root[data-skin="geist-contrast"] .panel-head-btn,
:root[data-skin="geist-contrast"] .sm-btn,
:root[data-skin="geist-contrast"] .btn,
:root[data-skin="geist-contrast"] button,
:root[data-skin="geist-contrast"] select,
:root[data-skin="geist-contrast"] input,
:root[data-skin="geist-contrast"] textarea,
:root[data-skin="geist-contrast"] .chip,
:root[data-skin="geist-contrast"] .project-chip,
:root[data-skin="geist-contrast"] .profile-chip,
:root[data-skin="geist-contrast"] .model-chip,
:root[data-skin="geist-contrast"] .reasoning-chip,
:root[data-skin="geist-contrast"] .app-dialog-btn{border-radius:6px!important;}
:root[data-skin="geist-contrast"] .session-item:hover,
:root[data-skin="geist-contrast"] .nav-tab:hover,
:root[data-skin="geist-contrast"] .rail-btn:hover,
:root[data-skin="geist-contrast"] .icon-btn:hover,
:root[data-skin="geist-contrast"] .panel-head-btn:hover,
:root[data-skin="geist-contrast"] .sm-btn:hover,
:root[data-skin="geist-contrast"] .project-chip:hover,
:root[data-skin="geist-contrast"] .chip:hover,
:root[data-skin="geist-contrast"] .profile-opt:hover,
:root[data-skin="geist-contrast"] .ws-opt:hover,
:root[data-skin="geist-contrast"] .file-item:hover,
:root[data-skin="geist-contrast"] .suggestion:hover{background:var(--hover-bg)!important;}
:root[data-skin="geist-contrast"] .session-item.active,
:root[data-skin="geist-contrast"] .nav-tab.active,
:root[data-skin="geist-contrast"] .rail-btn.active,
:root[data-skin="geist-contrast"] .project-chip.active,
:root[data-skin="geist-contrast"] .profile-opt.active,
:root[data-skin="geist-contrast"] .chip.model,
:root[data-skin="geist-contrast"] .side-menu-item.active,
:root[data-skin="geist-contrast"] .skin-pick-btn.active,
:root[data-skin="geist-contrast"] .theme-pick-btn.active,
:root[data-skin="geist-contrast"] .font-size-pick-btn.active{background:var(--surface-subtle)!important;border-color:var(--border2)!important;color:var(--text)!important;box-shadow:inset 0 0 0 1px var(--border)!important;font-weight:500!important;}
:root[data-skin="geist-contrast"] .session-item.active{position:relative;border:1px solid var(--border2)!important;}
:root[data-skin="geist-contrast"] .session-item.active::before{content:"";position:absolute;left:6px;top:10px;bottom:10px;width:2px;border-radius:999px;background:var(--accent);}
:root[data-skin="geist-contrast"] .session-item.active .session-title,
:root[data-skin="geist-contrast"] .session-item.active .session-meta,
:root[data-skin="geist-contrast"] .session-item.active .session-preview,
:root[data-skin="geist-contrast"] .session-item.active *{color:inherit!important;}
:root.dark[data-skin="geist-contrast"] .session-item.active,
:root.dark[data-skin="geist-contrast"] .session-item.active *{color:var(--text)!important;}
:root[data-skin="geist-contrast"] .nav-tab.active,
:root[data-skin="geist-contrast"] .rail-btn.active{color:var(--accent-text)!important;}
:root.dark[data-skin="geist-contrast"] .nav-tab.active svg,
:root.dark[data-skin="geist-contrast"] .rail-btn.active svg,
:root.dark[data-skin="geist-contrast"] .nav-tab.active [data-lucide],
:root.dark[data-skin="geist-contrast"] .rail-btn.active [data-lucide]{color:var(--accent-text)!important;stroke:var(--accent-text)!important;}
:root[data-skin="geist-contrast"] .profile-chip,
:root[data-skin="geist-contrast"] .model-chip,
:root[data-skin="geist-contrast"] .reasoning-chip,
:root[data-skin="geist-contrast"] .composer-workspace-chip,
:root[data-skin="geist-contrast"] .composer-profile-chip,
:root[data-skin="geist-contrast"] .composer-model-chip{color:var(--muted)!important;border-color:transparent!important;background:transparent!important;}
:root[data-skin="geist-contrast"] .profile-chip:hover,
:root[data-skin="geist-contrast"] .model-chip:hover,
:root[data-skin="geist-contrast"] .reasoning-chip:hover,
:root[data-skin="geist-contrast"] .composer-workspace-chip:hover,
:root[data-skin="geist-contrast"] .composer-profile-chip:hover,
:root[data-skin="geist-contrast"] .composer-model-chip:hover{color:var(--text)!important;background:var(--surface-subtle)!important;border-color:var(--border)!important;}
:root[data-skin="geist-contrast"] .new-chat-btn,
:root[data-skin="geist-contrast"] button.send-btn:not(:disabled),
:root[data-skin="geist-contrast"] .btn.primary,
:root[data-skin="geist-contrast"] .update-primary,
:root[data-skin="geist-contrast"] .app-dialog-btn.confirm,
:root[data-skin="geist-contrast"] .approval-btn.once,
:root[data-skin="geist-contrast"] .approval-btn.session,
:root[data-skin="geist-contrast"] .clarify-submit{background:var(--accent)!important;border-color:var(--accent)!important;color:#050505!important;font-weight:600!important;box-shadow:none!important;}
:root[data-skin="geist-contrast"]:not(.dark) .new-chat-btn,
:root[data-skin="geist-contrast"]:not(.dark) button.send-btn:not(:disabled),
:root[data-skin="geist-contrast"]:not(.dark) .btn.primary,
:root[data-skin="geist-contrast"]:not(.dark) .update-primary,
:root[data-skin="geist-contrast"]:not(.dark) .app-dialog-btn.confirm,
:root[data-skin="geist-contrast"]:not(.dark) .approval-btn.once,
:root[data-skin="geist-contrast"]:not(.dark) .approval-btn.session,
:root[data-skin="geist-contrast"]:not(.dark) .clarify-submit{color:#ffffff!important;}
:root[data-skin="geist-contrast"] .new-chat-btn:hover,
:root[data-skin="geist-contrast"] button.send-btn:not(:disabled):hover,
:root[data-skin="geist-contrast"] .btn.primary:hover,
:root[data-skin="geist-contrast"] .update-primary:hover,
:root[data-skin="geist-contrast"] .app-dialog-btn.confirm:hover,
:root[data-skin="geist-contrast"] .approval-btn.once:hover,
:root[data-skin="geist-contrast"] .approval-btn.session:hover,
:root[data-skin="geist-contrast"] .clarify-submit:hover{background:var(--accent-hover)!important;border-color:var(--accent-hover)!important;transform:none!important;}
:root[data-skin="geist-contrast"] button.send-btn:disabled{background:var(--surface-subtle)!important;border-color:var(--border)!important;color:var(--muted)!important;opacity:1!important;}
:root.dark[data-skin="geist-contrast"] button.send-btn:disabled svg,
:root.dark[data-skin="geist-contrast"] button.send-btn:disabled [data-lucide]{color:var(--muted)!important;stroke:var(--muted)!important;}
:root[data-skin="geist-contrast"] .composer-box:focus-within{border-color:transparent!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;}
:root[data-skin="geist-contrast"] input:focus,
:root[data-skin="geist-contrast"] textarea:focus,
:root[data-skin="geist-contrast"] select:focus{border-color:var(--accent)!important;box-shadow:none!important;outline:none!important;}
:root[data-skin="geist-contrast"] .app-dialog-input:focus,
:root[data-skin="geist-contrast"] .sidebar-search input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;}
:root[data-skin="geist-contrast"] .logo,
:root[data-skin="geist-contrast"] .sidebar-header .logo{background:var(--surface-subtle)!important;color:var(--accent-text)!important;border:1px solid var(--border2)!important;border-radius:8px!important;box-shadow:none!important;font-weight:650!important;}
:root[data-skin="geist-contrast"] .app-titlebar-icon rect,
:root[data-skin="geist-contrast"] .app-titlebar-icon path,
:root[data-skin="geist-contrast"] .app-titlebar-icon circle{color:var(--accent-text)!important;fill:var(--accent-text)!important;}
:root[data-skin="geist-contrast"] .msg-row[data-role="user"] .msg-body{background:var(--surface-subtle)!important;border:1px solid var(--border)!important;border-radius:10px!important;}
:root[data-skin="geist-contrast"] .suggestion{background:var(--surface)!important;border-color:var(--border)!important;color:var(--muted)!important;transition:background .15s ease,border-color .15s ease,color .15s ease!important;}
:root[data-skin="geist-contrast"] .suggestion:hover{border-color:var(--border2)!important;color:var(--text)!important;}
:root[data-skin="geist-contrast"] .msg-body a,
:root[data-skin="geist-contrast"] .preview-md a,
:root[data-skin="geist-contrast"] .empty-state a,
:root[data-skin="geist-contrast"] .tool-arg-key,
:root[data-skin="geist-contrast"] .tool-card-more,
:root[data-skin="geist-contrast"] .session-pin-indicator,
:root[data-skin="geist-contrast"] .sidebar-date-header.pinned{color:var(--accent-text)!important;}
:root[data-skin="geist-contrast"]::-webkit-scrollbar-thumb{background:var(--border2)!important;}
:root[data-skin="geist-contrast"] ::selection{background:var(--accent-bg-strong);color:var(--strong);}
/* ── Geist Contrast: composer fixes ── */
/* Light mode: override white user-bubble-text so textarea text is black */
:root[data-skin="geist-contrast"]:not(.dark){--user-bubble-text:#111111;}
/* Remove scrollbar from textarea */
:root[data-skin="geist-contrast"] textarea#msg{scrollbar-width:none;overflow-y:auto;}
:root[data-skin="geist-contrast"] textarea#msg::-webkit-scrollbar{display:none;}
/* #594: app-dialog light mode overrides — base styles use hardcoded dark gradients */
:root:not(.dark) .app-dialog{
background:linear-gradient(180deg,rgba(240,237,232,.99),rgba(228,224,216,.99));
@@ -488,7 +661,7 @@
and attention indicator (26x26 at right:6px) still need 40px reserved
when they're visible — covered by the hover / streaming / unread /
menu-open / focus-within rule below. */
.session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s;display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:manipulation;-webkit-tap-highlight-color:transparent;}
.session-item{padding:8px 8px;margin-bottom:2px;border-radius:8px;cursor:pointer;font-size:13px;color:var(--muted);transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1);display:flex;align-items:flex-start;gap:8px;min-width:0;position:relative;touch-action:manipulation;-webkit-tap-highlight-color:transparent;transform:translateY(var(--session-reflow-offset,0));}
.session-item.streaming,.session-item.unread,.session-item:focus-within,.session-item.menu-open{padding-right:40px;}
.session-item:hover{background:var(--hover-bg);color:var(--text);}
/* Suppress hover highlight during drag to avoid visual noise mid-scroll */
@@ -566,13 +739,23 @@
.session-action-copy{display:flex;flex-direction:column;gap:2px;min-width:0;}
.session-action-meta{font-size:11px;color:var(--muted);line-height:1.3;white-space:normal;opacity:.72;}
.session-action-opt.is-active{background:var(--accent-bg);}
.session-action-opt.is-disabled{opacity:.55;cursor:not-allowed;}
.session-action-opt.is-disabled:hover{background:transparent;}
.session-action-opt.danger:hover{background:rgba(239,83,80,.08);}
.session-action-opt.danger .ws-opt-icon,.session-action-opt.danger .ws-opt-name{color:var(--error);}
/* Hide overlay during inline rename */
.session-item:has(.session-title-input) .session-actions{display:none;}
@keyframes newflash{0%{background:var(--accent-bg-strong);color:var(--accent);}100%{background:transparent;color:var(--muted);}}
@keyframes sessionListFlipIn{0%{opacity:0;transform:perspective(700px) rotateX(-8deg) translateY(10px) scale(.985);}100%{opacity:1;transform:perspective(700px) rotateX(0) translateY(0) scale(1);}}
@keyframes spin{to{transform:rotate(360deg);}}
.session-item.new-flash{animation:newflash 1.4s ease-out forwards;}
.session-item.session-reflowing{transition:background .15s,color .15s,transform .36s cubic-bezier(.2,.8,.2,1);will-change:transform;}
.session-item.session-list-flip-enter{animation:sessionListFlipIn .28s cubic-bezier(.2,.8,.2,1) backwards;transform-origin:center top;}
@media (prefers-reduced-motion: reduce){
.session-item,
.session-item.session-reflowing{transition:background .15s,color .15s;}
.session-item.session-list-flip-enter{animation:none;}
}
/* Collapsible date group headers */
.session-date-header{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);padding:8px 10px 4px;cursor:pointer;user-select:none;opacity:.8;transition:opacity .15s;}
.session-date-header:hover{opacity:1;}
@@ -1118,6 +1301,7 @@
.dot:nth-child(2){animation-delay:.22s;}.dot:nth-child(3){animation-delay:.44s;}
@keyframes pulse{0%,80%,100%{opacity:.2;transform:scale(.8)}40%{opacity:.8;transform:scale(1)}}
.empty-state{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:40px;color:var(--muted);}
.empty-state.no-suggestions .suggestion-grid{display:none}
.empty-logo{width:64px;height:64px;border-radius:20px;background:linear-gradient(145deg,var(--accent-bg),var(--accent-bg));border:1px solid var(--accent-bg);display:flex;align-items:center;justify-content:center;font-size:28px;font-weight:700;color:var(--accent-text);margin-bottom:4px;box-shadow:0 4px 20px var(--accent-bg);}
.empty-state h2{font-size:20px;color:var(--text);font-weight:700;letter-spacing:-.02em;}
.empty-state p{font-size:14px;text-align:center;max-width:320px;}
@@ -1783,6 +1967,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-provider-hint{margin:2px 14px 6px;padding:7px 9px;border:1px solid var(--border2);border-radius:7px;background:color-mix(in srgb,var(--surface) 90%,var(--warning));color:var(--muted);font-size:11px;line-height:1.35;}
.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;}
@@ -2614,6 +2799,17 @@ main.main.showing-logs > #mainLogs{display:flex;}
#mainSettings .settings-field > div[style*="color:var(--muted)"],
#mainSettings .settings-field > div[style*="color: var(--muted)"]{font-size:12px;color:var(--muted);line-height:1.5;}
/* Sidebar tab visibility — hide nav-tab and matching rail-btn when toggled off by user */
.nav-tab-hidden{display:none!important;}
/* Tab visibility chips in Settings > Appearance */
.tab-visibility-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:4px;}
.tab-visibility-chip{font-size:12px;line-height:1;padding:5px 10px;border-radius:var(--radius-pill,999px);border:1px solid var(--accent,var(--link));background:var(--accent,var(--link));color:#1a1a1a;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s,opacity .15s;white-space:nowrap;user-select:none;}
.tab-visibility-chip:hover{filter:brightness(0.92);}
.tab-visibility-chip:focus-visible{outline:2px solid var(--link,var(--accent));outline-offset:2px;}
.tab-visibility-chip.chip-off{background:transparent;color:var(--muted);border-color:var(--border);font-weight:400;}
.tab-visibility-chip.chip-off:hover{border-color:var(--muted);color:var(--text);}
/* Selects & text/password inputs — uniform. Uses !important to win
over legacy inline styles that were written for the modal era. */
#mainSettings select,
+242 -49
View File
@@ -401,10 +401,15 @@ function _dashboardIsBrowserLoopback(){
return host==='127.0.0.1'||host==='localhost'||host==='::1';
}
function _dashboardBrowserUrl(status){
if(!status||!status.running||!status.port) return '';
if(!status||!status.running) return '';
if(status.browser_url||status.url){
try{return new URL(status.browser_url||status.url).toString().replace(/\/$/,'');}
catch(_){}
}
if(!status.port) return '';
let source;
try{source=new URL(status.url||('http://127.0.0.1:'+status.port));}
catch(_){source=new URL('http://127.0.0.1:'+status.port);}
try{source=new URL('http://127.0.0.1:'+status.port);}
catch(_){return '';}
const browserHost=window.location.hostname||source.hostname;
const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost;
return source.protocol+'//'+displayHost+':'+status.port;
@@ -728,6 +733,8 @@ window.addEventListener('visibilitychange',()=>{
let _dynamicModelLabels={};
window._configuredModelBadges=window._configuredModelBadges||{};
const MODEL_STATE_KEY='hermes-webui-model-state';
const PENDING_SESSION_MODEL_PREFIX='hermes-webui-pending-session-model:';
const PENDING_SESSION_MODEL_MAX_AGE_MS=10*60*1000;
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a <select> for a given model ID.
@@ -809,6 +816,71 @@ function _clearPersistedModelState(){
localStorage.removeItem('hermes-webui-model');
localStorage.removeItem(MODEL_STATE_KEY);
}
function _pendingSessionModelKey(sessionId){
return PENDING_SESSION_MODEL_PREFIX+String(sessionId||'');
}
function _rememberPendingSessionModel(sessionId, model, modelProvider){
const sid=String(sessionId||'').trim();
const value=String(model||'').trim();
if(!sid||!value) return;
const provider=modelProvider?String(modelProvider).trim():(_providerFromModelValue(value)||null);
try{
sessionStorage.setItem(_pendingSessionModelKey(sid), JSON.stringify({
model:value,
model_provider:provider||null,
saved_at:Date.now(),
}));
}catch(_){}
}
function _readPendingSessionModel(sessionId){
const sid=String(sessionId||'').trim();
if(!sid) return null;
try{
const raw=sessionStorage.getItem(_pendingSessionModelKey(sid));
if(!raw) return null;
const parsed=JSON.parse(raw);
const model=String(parsed&&parsed.model||'').trim();
if(!model){
sessionStorage.removeItem(_pendingSessionModelKey(sid));
return null;
}
const savedAt=Number(parsed.saved_at||0);
if(savedAt&&Date.now()-savedAt>PENDING_SESSION_MODEL_MAX_AGE_MS){
sessionStorage.removeItem(_pendingSessionModelKey(sid));
return null;
}
return {
model,
model_provider:parsed&&parsed.model_provider?String(parsed.model_provider):(_providerFromModelValue(model)||null),
};
}catch(_){
try{sessionStorage.removeItem(_pendingSessionModelKey(sid));}catch(__){}
return null;
}
}
function _clearPendingSessionModel(sessionId){
const sid=String(sessionId||'').trim();
if(!sid) return;
try{sessionStorage.removeItem(_pendingSessionModelKey(sid));}catch(_){}
}
function _applyPendingSessionModelForSession(sessionId){
if(!S.session||S.session.session_id!==sessionId) return false;
const pending=_readPendingSessionModel(sessionId);
if(!pending) return false;
const sameModel=String(S.session.model||'')===pending.model;
const sameProvider=String(S.session.model_provider||'')===String(pending.model_provider||'');
if(sameModel&&sameProvider){
_clearPendingSessionModel(sessionId);
return false;
}
S.session.model=pending.model;
S.session.model_provider=pending.model_provider||null;
const retry=_persistSessionModelCorrection(pending.model,pending.model_provider||null,{propagateErrors:true});
if(retry&&typeof retry.then==='function'){
retry.then(()=>_clearPendingSessionModel(sessionId)).catch(()=>{});
}
return true;
}
function _findModelInDropdown(modelId, sel, preferredProviderId){
if(!modelId||!sel) return null;
const options=Array.from(sel.options);
@@ -864,19 +936,38 @@ function _applyModelToDropdown(modelId, sel, preferredProviderId){
}
return null;
}
function _ensureModelOptionInDropdown(modelId, sel, preferredProviderId){
if(!modelId||!sel) return null;
const applied=_applyModelToDropdown(modelId,sel,preferredProviderId);
if(applied) return applied;
const opt=document.createElement('option');
opt.value=modelId;
opt.textContent=typeof getModelLabel==='function'?getModelLabel(modelId):modelId;
opt.dataset.custom='1';
const provider=preferredProviderId||_providerFromModelValue(modelId)||'';
if(provider) opt.dataset.provider=provider;
sel.appendChild(opt);
sel.value=modelId;
if(sel.id==='modelSelect'){
if(typeof syncModelChip==='function') syncModelChip();
_refreshOpenModelDropdown();
}
return modelId;
}
function _modelStateFromAppliedDropdown(sel, modelValue){
const state=(typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel,modelValue)
: {model:modelValue,model_provider:null};
return {model:state.model||modelValue,model_provider:state.model_provider||null};
}
function _persistSessionModelCorrection(model, provider){
function _persistSessionModelCorrection(model, provider, opts){
if(!S.session) return;
fetch(new URL('api/session/update',document.baseURI||location.href).href,{
const request=fetch(new URL('api/session/update',document.baseURI||location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:model,model_provider:provider||null})
}).catch(()=>{});
});
return opts&&opts.propagateErrors ? request : request.catch(()=>{});
}
function _applySessionModelFallback(sel){
if(!sel) return null;
@@ -910,6 +1001,9 @@ async function populateModelDropdown(){
// Per-page-load — not synced across browser tabs.
window._defaultModel=data.default_model||null;
window._configuredModelBadges=data.configured_model_badges||{};
// Keep the g.extra_models label hydration path below in this function; tests
// assert populateModelDropdown preserves that full-catalog label contract.
window._modelEndpointErrors={};
const _synthGroupsFromConfigured=()=>{
const badgeMap=window._configuredModelBadges||{};
@@ -959,6 +1053,11 @@ async function populateModelDropdown(){
const og=document.createElement('optgroup');
og.label=g.provider;
if(g.provider_id) og.dataset.provider=g.provider_id;
if(g.models_endpoint_error){
const errorKey=g.provider_id||g.provider||'';
og.dataset.modelsEndpointError=JSON.stringify(g.models_endpoint_error);
if(errorKey) window._modelEndpointErrors[errorKey]=g.models_endpoint_error;
}
for(const m of (Array.isArray(g.models)?g.models:[])){
const opt=document.createElement('option');
opt.value=m.id;
@@ -979,8 +1078,10 @@ async function populateModelDropdown(){
}
sel.appendChild(og);
}
// Set default model from server if no localStorage preference
if(data.default_model && !(typeof _readPersistedModelState==='function'&&_readPersistedModelState()) && !localStorage.getItem('hermes-webui-model')){
// Set default model from server on fresh/blank boot. Loaded sessions keep
// their own persisted model and apply it via loadSession(). Do not let stale
// browser localStorage suppress the profile default.
if(data.default_model && !(S.session&&S.session.model)){
_applyModelToDropdown(data.default_model, sel, data.active_provider||null);
}
if(typeof syncModelChip==='function') syncModelChip();
@@ -1213,12 +1314,19 @@ function renderModelDropdown(){
for(const child of Array.from(sel.children)){
if(child.tagName==='OPTGROUP'){
const providerId=child.dataset&&child.dataset.provider?child.dataset.provider:'';
let modelsEndpointError=null;
if(child.dataset&&child.dataset.modelsEndpointError){
try{ modelsEndpointError=JSON.parse(child.dataset.modelsEndpointError); }catch(_e){ modelsEndpointError=null; }
}
for(const opt of Array.from(child.children)){
const rawValue=String(opt.value||'');
const displayName=rawValue.startsWith('@custom:')
? getModelLabel(rawValue)
: (opt.textContent||getModelLabel(rawValue));
_modelData.push({value:opt.value,name:esc(displayName),id:esc(opt.value),group:child.label||'',badge:_getConfiguredModelBadge(opt.value,_badgeMap,providerId)});
_modelData.push({value:opt.value,name:esc(displayName),id:esc(opt.value),group:child.label||'',providerId,modelsEndpointError,badge:_getConfiguredModelBadge(opt.value,_badgeMap,providerId)});
}
if(modelsEndpointError && !child.children.length){
_modelData.push({value:`__models_endpoint_error__:${providerId||child.label||''}`,name:'',id:'',group:child.label||'',providerId,modelsEndpointError,endpointErrorOnly:true});
}
}
if(child.tagName==='OPTION'){
@@ -1357,8 +1465,17 @@ function renderModelDropdown(){
const _groupCounts={};
for(const m of _modelData){
if(configuredIds.has(m.value)) continue;
if(m.group) _groupCounts[m.group]=(_groupCounts[m.group]||0)+1;
if(m.group&&!m.endpointErrorOnly) _groupCounts[m.group]=(_groupCounts[m.group]||0)+1;
}
const _renderProviderEndpointHint=(groupName)=>{
if(!groupName) return;
const entry=_modelData.find(m=>m.group===groupName&&m.modelsEndpointError);
if(!entry||!entry.modelsEndpointError) return;
const hint=document.createElement('div');
hint.className='model-provider-hint';
hint.textContent=entry.modelsEndpointError.message||'Models endpoint could not be reached for this provider.';
dd.appendChild(hint);
};
for(const m of _modelData){
if(configuredIds.has(m.value)||!matches(m)) continue;
if(m.group&&m.group!==_lastGroup){
@@ -1367,8 +1484,10 @@ function renderModelDropdown(){
const count=_groupCounts[m.group]||0;
heading.textContent=count>1?`${m.group} (${count})`:m.group;
dd.appendChild(heading);
_renderProviderEndpointHint(m.group);
_lastGroup=m.group;
}
if(m.endpointErrorOnly) continue;
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>`:'';
@@ -1445,9 +1564,9 @@ async function toggleModelDropdown(){
if(typeof closeWsDropdown==='function') closeWsDropdown();
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
const ready=window._modelDropdownReady;
if(ready&&typeof ready.then==='function'){
try{await ready;}catch(_){}
if(typeof window._ensureModelDropdownReady==='function'){
const ready=window._ensureModelDropdownReady();
if(ready&&typeof ready.catch==='function') ready.catch(()=>{});
}
if(dd.classList.contains('open')) return;
renderModelDropdown();
@@ -1980,7 +2099,14 @@ if(typeof window!=='undefined') window._resetScrollDirectionTracker=_resetScroll
if(progress>0.3) e.preventDefault();
},{passive:false});
el.addEventListener('touchend',function(){
if(_ptrState===2){ window.location.reload(); return; }
if(_ptrState===2){
if(typeof window.refreshSessionList==='function'){
Promise.resolve(window.refreshSessionList('pull', {force:true, refreshActive:true})).catch(()=>{}).finally(_ptrReset);
}else{
window.location.reload();
}
return;
}
_ptrReset();
},{passive:true});
el.addEventListener('touchcancel',_ptrReset,{passive:true});
@@ -4256,6 +4382,11 @@ function _formatUpdateTargetStatus(label,info){
const noun=info.release_based?'release':'update';
return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`;
}
function _formatUpdateCheckError(label,info){
if(!info||!info.error) return null;
const detail=String(info.error).replace(/^fetch failed:?\s*/i,'').trim();
return detail ? `${label}: ${detail}` : label;
}
function _isSafeUpdateCompareUrl(url){
if(!url||!/^https?:\/\//i.test(url)) return false;
try{
@@ -4767,8 +4898,9 @@ function syncTopbar(){
}
} else {
const applied=_applyModelToDropdown(currentModel,modelSel,S.session.model_provider||null);
// If the model isn't in the current provider list, reset to the configured
// default rather than silently retaining the previous chat's selection (#1771).
// If the session model is missing from the current provider list, inject
// a session-scoped option instead of displaying the previous/static
// selection. Only fall back if that repair path is unavailable.
if(!applied){
const deferModelCorrection=Boolean(S.session._modelResolutionDeferred);
const missingModelIsRoutable=_providerDefersMissingModelFallback(S.session.model_provider||window._activeProvider||null);
@@ -4781,14 +4913,25 @@ function syncTopbar(){
// _addLiveModelsToSelect() will re-apply S.session.model once done (#1169).
// Named custom providers/OpenRouter can also route vendor-prefixed IDs
// outside the static catalog, so preserve the user's explicit choice.
if(typeof _ensureModelOptionInDropdown==='function'){
const sessionOption=_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null);
if(sessionOption) currentModel=sessionOption;
}
} else {
const fallback=_applySessionModelFallback(modelSel);
if(fallback&&!deferModelCorrection){
S.session.model=fallback.model;
S.session.model_provider=fallback.model_provider||null;
currentModel=fallback.model;
// Persist the correction so the session doesn't re-inject on next load.
_persistSessionModelCorrection(fallback.model,S.session.model_provider||null);
const sessionOption=(typeof _ensureModelOptionInDropdown==='function')
? _ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null)
: null;
if(sessionOption){
currentModel=sessionOption;
} else {
const fallback=_applySessionModelFallback(modelSel);
if(fallback&&!deferModelCorrection){
S.session.model=fallback.model;
S.session.model_provider=fallback.model_provider||null;
currentModel=fallback.model;
// Persist the correction so the session doesn't re-inject on next load.
_persistSessionModelCorrection(fallback.model,S.session.model_provider||null);
}
}
}
}
@@ -5498,14 +5641,9 @@ function renderCompressionUi(){
el.style.display='none';
}
// Session render cache: avoids full markdown+DOM rebuild when switching back
// to a session that was already rendered with the same message count.
// to a session whose rendered transcript inputs are unchanged.
// Keyed by session_id. Only used on cross-session navigation, never for
// in-session updates (new messages, edits, stream events).
//
// Known limitation: cache key is session_id + message count. Edits and retries
// that mutate message content without changing the count will serve stale HTML
// on back-navigation until the user triggers an in-session update. Acceptable
// for the common read-only back-navigation case; not suitable as a general cache.
const _sessionHtmlCache=new Map();
let _sessionHtmlCacheSid=null; // session_id currently rendered in the DOM
function clearMessageRenderCache(){
@@ -5513,6 +5651,55 @@ function clearMessageRenderCache(){
_sessionHtmlCacheSid=null;
}
function _messageRenderCacheSignature(){
let hash=2166136261;
function add(value){
const s=String(value==null?'':value);
for(let i=0;i<s.length;i++){
hash^=s.charCodeAt(i);
hash=Math.imul(hash,16777619)>>>0;
}
hash^=31;
hash=Math.imul(hash,16777619)>>>0;
}
const messages=Array.isArray(S.messages)?S.messages:[];
add(messages.length);
for(const m of messages){
if(!m||typeof m!=='object'){ add('missing'); continue; }
add(m.role);add(m.timestamp);add(m._ts);add(m._error);add(m._statusCard);
add(msgContent(m));
if(Array.isArray(m.content)){
add('content-array');
m.content.forEach(part=>{
if(!part||typeof part!=='object'){ add(part); return; }
add(part.type);add(part.id);add(part.name);add(part.text);add(part.content);
});
}
if(Array.isArray(m.tool_calls)){
add('message-tool-calls');add(m.tool_calls.length);
m.tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.type);add(JSON.stringify(tc&&tc.function||{}));});
}
if(Array.isArray(m._partial_tool_calls)){
add('partial-tool-calls');add(m._partial_tool_calls.length);
m._partial_tool_calls.forEach(tc=>{add(tc&&tc.id);add(tc&&tc.name);add(tc&&tc.snippet);});
}
if(_messageHasReasoningPayload(m)) add(m.reasoning||m.thinking||m._reasoning||'reasoning');
if(Array.isArray(m.attachments)) m.attachments.forEach(a=>add(a&&typeof a==='object'?JSON.stringify(a):a));
}
const toolCalls=Array.isArray(S.toolCalls)?S.toolCalls:[];
add('settled-tool-calls');add(toolCalls.length);
toolCalls.forEach(tc=>{
if(!tc||typeof tc!=='object'){ add(tc); return; }
add(tc.tid);add(tc.id);add(tc.name);add(tc.done);add(tc.is_diff);add(tc.assistant_msg_idx);add(tc.snippet);add(JSON.stringify(tc.args||{}));
});
if(S.session){
add(S.session.message_count);add(S.session.updated_at);add(S.session.compression_anchor_visible_idx);
add(JSON.stringify(S.session.compression_anchor_message_key||null));
add(S.session.compression_anchor_summary||'');
}
return `${messages.length}:${toolCalls.length}:${hash.toString(16)}`;
}
function _clipCliToolSnippet(text, maxLen=20000){
const s=String(text||'');
if(s.length<=maxLen) return s;
@@ -5659,6 +5846,7 @@ function renderMessages(options){
const msgCount=S.messages.length;
if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid);
const renderWindowSize=_currentMessageRenderWindowSize();
const renderSignature=_messageRenderCacheSignature();
const hasTransientTranscriptUi=!!(
(window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) ||
(window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid))
@@ -5674,7 +5862,7 @@ function renderMessages(options){
// before those cards can be inserted.
if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){
const cached=_sessionHtmlCache.get(sid);
if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize){
if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize&&cached.signature===renderSignature){
inner.innerHTML=cached.html;
_sessionHtmlCacheSid=sid;
_wireMessageWindowLoadEarlierButton();
@@ -6140,17 +6328,15 @@ function renderMessages(options){
if(!anchorRow) continue;
const anchorParent=anchorRow.parentElement;
let insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow;
const thinkingText=assistantThinking.get(aIdx);
if(thinkingText){
const thinkingNode=_thinkingActivityNode(thinkingText, false);
anchorParent.insertBefore(thinkingNode, anchorRow);
}
if(!cards.length) continue;
const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode,activityKey:`assistant:${aIdx}`});
const sourceMsg=S.messages[aIdx]||{};
if(sourceMsg._turnDuration!==undefined) group.setAttribute('data-turn-duration', String(sourceMsg._turnDuration));
const body=group&&group.querySelector('.tool-call-group-body');
if(!body) continue;
const thinkingText=assistantThinking.get(aIdx);
if(thinkingText){
body.appendChild(_thinkingActivityNode(thinkingText, false));
}
for(const tc of cards){
body.appendChild(buildToolCard(tc));
}
@@ -6287,7 +6473,7 @@ function renderMessages(options){
const _html=inner.innerHTML;
// Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions.
if(_html.length<300_000){
_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize});
_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature});
if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);}
}
}
@@ -7146,7 +7332,10 @@ let _katexReady=false;
function renderKatexBlocks(container){
const root=container||document;
const blocks=root.querySelectorAll('.katex-block:not([data-rendered]),.katex-inline:not([data-rendered])');
const blocks=root.querySelectorAll(
'.katex-block:not([data-rendered]),.katex-inline:not([data-rendered]),'+
'equation-block:not([data-rendered]),equation-inline:not([data-rendered])'
);
if(!blocks.length) return;
if(!_katexReady){
if(!_katexLoading){
@@ -7168,7 +7357,8 @@ function renderKatexBlocks(container){
blocks.forEach(el=>{
el.dataset.rendered='true';
const src=el.textContent||'';
const displayMode=el.dataset.katex==='display';
const tagName=(el.tagName||'').toLowerCase();
const displayMode=el.dataset.katex==='display'||tagName==='equation-block';
try{
katex.render(src,el,{
displayMode,
@@ -7299,25 +7489,28 @@ function appendThinking(text='', options){
return;
}
const thinkingText=String(text||'').trim()||'Thinking…';
let row=blocks.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
const allChildren=Array.from(blocks.children);
const anchor=allChildren.filter(el=>
el.id!=='toolRunningRow' &&
el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row')
).pop();
const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
const body=group&&group.querySelector('.tool-call-group-body');
if(!body) return;
let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
if(!row){
const thinkingCards=Array.from(blocks.querySelectorAll('.agent-activity-thinking'));
row=thinkingCards.filter(el=>el.closest('.assistant-turn-blocks')===blocks).pop()||null;
const thinkingCards=Array.from(body.querySelectorAll('.agent-activity-thinking'));
row=thinkingCards.pop()||null;
if(row) row.setAttribute('data-thinking-active','1');
}
if(!row){
row=_thinkingActivityNode(thinkingText, false);
row.setAttribute('data-thinking-active','1');
const allChildren=Array.from(blocks.children);
const anchor=allChildren.filter(el=>
el.id!=='toolRunningRow' &&
el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')
).pop();
if(anchor) anchor.insertAdjacentElement('afterend', row);
else blocks.appendChild(row);
body.appendChild(row);
}else{
_renderThinkingInto(row,thinkingText);
}
_syncToolCallGroupSummary(group);
scrollIfPinned();
if(_scrollPinned){
const body=row&&row.querySelector('.thinking-card-body');
+1
View File
@@ -536,6 +536,7 @@ def test_server():
# pytest-side block can't see.
env["HERMES_WEBUI_TEST_NETWORK_BLOCK"] = "1"
env.update({
"HERMES_WEBUI_WORKSPACE_GIT_DESTRUCTIVE": "1",
"HERMES_WEBUI_PORT": str(TEST_PORT),
"HERMES_WEBUI_HOST": "127.0.0.1",
"HERMES_WEBUI_STATE_DIR": str(TEST_STATE_DIR),
@@ -67,18 +67,26 @@ class TestSettingsPickerActiveState:
assert ".skin-pick-btn.active" in STYLE_CSS, (
"style.css must have a .skin-pick-btn.active rule"
)
# The active rule must use !important to beat the base !important rule
idx = STYLE_CSS.find(".theme-pick-btn.active")
rule = STYLE_CSS[idx:idx + 200]
# The active rule must use !important to beat the base !important rule.
# Search for the GLOBAL #mainSettings-scoped rule (skin-specific overrides
# like `:root[data-skin="geist-contrast"] .theme-pick-btn.active` are
# allowed to use their own values and would otherwise be the first match).
idx = STYLE_CSS.find("#mainSettings .theme-pick-btn.active")
assert idx != -1, "Could not find global #mainSettings .theme-pick-btn.active rule"
rule = STYLE_CSS[idx:idx + 300]
assert "!important" in rule, (
".theme-pick-btn.active must use !important to override "
"#mainSettings .theme-pick-btn.active must use !important to override "
"the base border-color:var(--border)!important rule"
)
def test_active_rule_uses_accent_color(self):
"""The .active rule must apply the accent color to make selection visible."""
idx = STYLE_CSS.find(".theme-pick-btn.active")
rule = STYLE_CSS[idx:idx + 200]
# Use the global #mainSettings-scoped rule (skin-specific overrides like
# `:root[data-skin="geist-contrast"] .theme-pick-btn.active` use their
# own palette values and would otherwise be the first match).
idx = STYLE_CSS.find("#mainSettings .theme-pick-btn.active")
assert idx != -1, "Could not find global #mainSettings .theme-pick-btn.active rule"
rule = STYLE_CSS[idx:idx + 300]
assert "var(--accent)" in rule, (
".theme-pick-btn.active must set border-color to var(--accent)"
"#mainSettings .theme-pick-btn.active must set border-color to var(--accent)"
)
+21
View File
@@ -0,0 +1,21 @@
from pathlib import Path
def test_root_does_not_shadow_agents_md_with_hermes_context_file():
repo_root = Path(__file__).resolve().parents[1]
for name in ("HERMES.md", ".hermes.md"):
assert not (repo_root / name).exists(), (
f"{name} at the repository root is auto-loaded by Hermes Agent as "
"project context before AGENTS.md; long human-facing Hermes overview "
"docs belong under docs/."
)
def test_why_hermes_doc_remains_linked_from_readme():
repo_root = Path(__file__).resolve().parents[1]
readme = (repo_root / "README.md").read_text(encoding="utf-8")
assert (repo_root / "docs" / "why-hermes.md").exists()
assert "docs/why-hermes.md" in readme
assert "[HERMES.md](HERMES.md)" not in readme
+73
View File
@@ -0,0 +1,73 @@
"""Regression coverage for chat sidebar virtualization after panel navigation."""
from pathlib import Path
PANELS_JS = Path(__file__).parent.parent / "static" / "panels.js"
def _read_source() -> str:
return PANELS_JS.read_text(encoding="utf-8")
def _function_block(src: str, name: str) -> str:
marker = f"function {name}"
start = src.find(marker)
if start == -1:
marker = f"async function {name}"
start = src.find(marker)
assert start != -1, f"{name} not found"
paren = src.find("(", start)
assert paren != -1, f"{name} signature not found"
paren_depth = 1
j = paren + 1
while j < len(src) and paren_depth:
if src[j] == "(":
paren_depth += 1
elif src[j] == ")":
paren_depth -= 1
j += 1
assert paren_depth == 0, f"{name} signature did not close"
brace = src.find("{", j)
assert brace != -1, f"{name} body not found"
depth = 1
i = brace + 1
while i < len(src) and depth:
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
assert depth == 0, f"{name} body did not close"
return src[start:i]
def test_switching_back_to_chat_schedules_sidebar_resync_after_panel_is_visible():
"""Chat navigation must re-render the sidebar after layout becomes visible.
The sidebar session list is virtualized. After switching away to Settings,
Logs, etc. and then back to Chat, the browser can clamp the preserved
scrollTop during the layout transition. If no render runs after the chat
view is visible again, stale virtual spacer/header DOM can remain until the
next manual scroll event.
"""
src = _read_source()
switch_body = _function_block(src, "switchPanel")
assert "_resyncChatSidebarAfterPanelSwitch();" in switch_body
assert switch_body.index("if (panelEl) panelEl.classList.add('active');") < switch_body.index(
"_resyncChatSidebarAfterPanelSwitch();"
)
def test_chat_sidebar_resync_helper_is_guarded_and_bounded_to_one_animation_frame():
"""The resync must not poll or destroy rename state; one guarded rAF is enough."""
src = _read_source()
helper = _function_block(src, "_resyncChatSidebarAfterPanelSwitch")
assert "if (_currentPanel !== 'chat') return;" in helper
assert "typeof renderSessionListFromCache !== 'function'" in helper
assert "requestAnimationFrame" in helper
assert "setInterval" not in helper
assert "setTimeout" not in helper
assert "typeof _sessionActionMenu !== 'undefined' && _sessionActionMenu" in helper
assert helper.index("typeof _sessionActionMenu") < helper.index("renderSessionListFromCache();")
assert "renderSessionListFromCache();" in helper
+5 -3
View File
@@ -137,14 +137,16 @@ class TestClarifyModuleExports:
class TestClarifyHTTPEndpoints:
"""Regression tests for /api/clarify/respond against the live test server."""
def test_respond_returns_ok_no_pending(self):
def test_respond_returns_stale_when_no_pending(self):
"""When no clarify prompt is pending, respond returns 409 (issue #2639)."""
sid = f"http-no-pending-{uuid.uuid4().hex[:8]}"
result, status = post("/api/clarify/respond", {
"session_id": sid,
"response": "Use option A",
})
assert status == 200
assert result["ok"] is True
assert status == 409
assert result["ok"] is False
assert result.get("stale") is True
def test_respond_requires_session_id(self):
result, status = post("/api/clarify/respond", {"response": "Hello"})
@@ -0,0 +1,101 @@
"""Regression checks for configurable pinned session limits."""
import json
import pathlib
import urllib.error
import urllib.request
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).resolve().parent.parent
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def make_session(created, title):
payload = {
"title": title,
"messages": [{"role": "user", "content": "keep this conversation handy"}],
"model": "test/pin-limit-setting",
}
d, status = post("/api/session/import", payload)
assert status == 200
sid = d["session"]["session_id"]
created.append(sid)
return sid
def test_pin_limit_setting_is_exposed_and_wired_through_ui():
assert '"pinned_sessions_limit": 3' in CONFIG_PY
assert '"pinned_sessions_limit": (1, 99)' in CONFIG_PY
assert 'id="settingsPinnedSessionsLimit"' in INDEX_HTML
assert 'type="number"' in INDEX_HTML
assert 'min="1"' in INDEX_HTML
assert 'max="99"' in INDEX_HTML
assert 'payload.pinned_sessions_limit=parseInt(pinnedLimitField.value,10)' in PANELS_JS
assert "settings.pinned_sessions_limit" in PANELS_JS
assert "window._pinnedSessionsLimit=parseInt(s.pinned_sessions_limit||3,10)||3" in BOOT_JS
assert "function _getPinnedSessionsLimit()" in SESSIONS_JS
assert "function _pinnedSessionsLimit()" not in SESSIONS_JS
assert "_pinnedSessionCount()>=_getPinnedSessionsLimit()" in SESSIONS_JS
def test_settings_api_persists_integer_pin_limit_and_rejects_invalid_values():
try:
d, status = post("/api/settings", {"pinned_sessions_limit": 5})
assert status == 200
assert d["pinned_sessions_limit"] == 5
d, status = post("/api/settings", {"pinned_sessions_limit": "7"})
assert status == 200
assert d["pinned_sessions_limit"] == 7
d, status = post("/api/settings", {"pinned_sessions_limit": 0})
assert status == 200
assert d["pinned_sessions_limit"] == 7
d, status = post("/api/settings", {"pinned_sessions_limit": 100})
assert status == 200
assert d["pinned_sessions_limit"] == 7
finally:
post("/api/settings", {"pinned_sessions_limit": 3})
def test_session_pin_endpoint_uses_configured_limit():
created = []
try:
d, status = post("/api/settings", {"pinned_sessions_limit": 4})
assert status == 200
assert d["pinned_sessions_limit"] == 4
pinned = [make_session(created, f"Configured pin cap {i}") for i in range(4)]
for sid in pinned:
d, status = post("/api/session/pin", {"session_id": sid, "pinned": True})
assert status == 200
assert d["session"]["pinned"] is True
fifth = make_session(created, "Configured pin cap overflow")
d, status = post("/api/session/pin", {"session_id": fifth, "pinned": True})
assert status == 400
assert "4 sessions" in d.get("error", "")
finally:
post("/api/settings", {"pinned_sessions_limit": 3})
for sid in created:
post("/api/session/delete", {"session_id": sid})
+175
View File
@@ -0,0 +1,175 @@
"""Tests for context message deduplication.
Verifies that _deduplicate_context_messages and _merge_display_messages_after_agent_result
correctly remove duplicate messages from agent context, preventing the agent from
seeing the same message twice in conversation_history.
"""
def test_deduplicate_context_messages_removes_duplicates():
from api.streaming import _deduplicate_context_messages
messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "hello"}, # duplicate of [0]
{"role": "assistant", "content": "Hi there!"}, # duplicate of [1]
]
result = _deduplicate_context_messages(messages)
assert len(result) == 2
assert result[0]["content"] == "hello"
assert result[1]["content"] == "Hi there!"
def test_deduplicate_context_messages_preserves_different_content():
from api.streaming import _deduplicate_context_messages
messages = [
{"role": "user", "content": "first question"},
{"role": "assistant", "content": "answer one"},
{"role": "user", "content": "second question"}, # different content
{"role": "assistant", "content": "answer two"}, # different content
]
result = _deduplicate_context_messages(messages)
assert len(result) == 4
def test_deduplicate_context_messages_preserves_identical_answers_in_different_turns():
"""Identical assistant answers in separate user turns should be preserved."""
from api.streaming import _deduplicate_context_messages
messages = [
{"role": "user", "content": "what is 2+2?"},
{"role": "assistant", "content": "4"},
{"role": "user", "content": "what is 3+1?"}, # different user turn
{"role": "assistant", "content": "4"}, # same answer, different turn
]
result = _deduplicate_context_messages(messages)
# _message_identity is identity-based, not turn-aware:
# second assistant "4" has the same identity as first → removed.
# Second user "what is 3+1?" has different content → kept.
# This is intentional: the dedup catches context pollution from
# merge_session_messages_append_only, not replayed turns.
assert len(result) == 3 # user "2+2", assistant "4", user "3+1"
def test_deduplicate_context_messages_empty_input():
from api.streaming import _deduplicate_context_messages
assert _deduplicate_context_messages([]) == []
assert _deduplicate_context_messages(None) is None
def test_deduplicate_context_messages_with_tool_calls():
from api.streaming import _deduplicate_context_messages
messages = [
{"role": "assistant", "content": "", "tool_calls": [{"id": "abc", "function": {"name": "echo"}, "type": "function"}]},
{"role": "tool", "content": "result", "tool_call_id": "abc"},
{"role": "assistant", "content": "", "tool_calls": [{"id": "abc", "function": {"name": "echo"}, "type": "function"}]}, # dup
]
result = _deduplicate_context_messages(messages)
assert len(result) == 2 # third message (dup) removed
def test_deduplicate_context_messages_different_timestamps_same_content():
"""Messages with same content but different timestamps should be deduped."""
from api.streaming import _deduplicate_context_messages
messages = [
{"role": "user", "content": "hello", "timestamp": 1779348286},
{"role": "assistant", "content": "Hi!", "timestamp": 1779348286},
{"role": "user", "content": "hello", "timestamp": 1779348286.3954952}, # same content, different ts
{"role": "assistant", "content": "Hi!", "timestamp": 1779348286.3976274}, # same content, different ts
]
result = _deduplicate_context_messages(messages)
assert len(result) == 2 # duplicates removed despite different timestamps
def test_message_identity_strips_workspace_prefix():
"""_message_identity should strip [Workspace::v1: ...] prefix from user messages."""
from api.streaming import _message_identity
msg1 = {"role": "user", "content": "hello"}
msg2 = {"role": "user", "content": "[Workspace::v1: /workspace]\nhello"}
assert _message_identity(msg1) == _message_identity(msg2)
def test_message_identity_different_roles_not_duplicates():
"""Messages with same content but different roles should not be considered duplicates."""
from api.streaming import _message_identity
user_msg = {"role": "user", "content": "hello"}
assistant_msg = {"role": "assistant", "content": "hello"}
assert _message_identity(user_msg) != _message_identity(assistant_msg)
def test_merge_display_messages_dedup_via_prefix():
"""_merge_display_messages_after_agent_result dedups via prefix stripping,
not by general seen check — identical content in different turns is preserved."""
from api.streaming import _merge_display_messages_after_agent_result
# Agent returns full history (includes previous messages) — prefix-based
# dedup should strip the replayed tail, not the general seen check.
previous_display = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there!"},
]
previous_context = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there!"},
]
result_messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "next question"},
{"role": "assistant", "content": "answer"},
]
msg_text = "next question"
merged = _merge_display_messages_after_agent_result(
previous_display, previous_context, result_messages, msg_text
)
# Should have 4 messages — prefix-based dedup strips replayed tail
assert len(merged) == 4
assert merged[0]["content"] == "hello"
assert merged[1]["content"] == "Hi there!"
assert merged[2]["content"] == "next question"
assert merged[3]["content"] == "answer"
def test_merge_display_messages_preserves_current_user_turn():
"""The current user turn replacement logic should still work."""
from api.streaming import _merge_display_messages_after_agent_result
previous_display = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there!"},
]
previous_context = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there!"},
]
result_messages = [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "next question"},
{"role": "assistant", "content": "answer"},
]
msg_text = "next question"
merged = _merge_display_messages_after_agent_result(
previous_display, previous_context, result_messages, msg_text
)
# Current user message should use msg_text
user_msgs = [m for m in merged if m.get("role") == "user"]
assert any(m.get("content") == "next question" for m in user_msgs)
+9
View File
@@ -91,6 +91,7 @@ class TestCustomProvidersInGetProviders:
assert glmcode["configurable"] is False, (
"custom providers should not be configurable via WebUI"
)
assert glmcode["is_custom"] is True
assert glmcode["key_source"] == "config_yaml"
assert glmcode["display_name"] == "glmcode"
@@ -99,9 +100,17 @@ class TestCustomProvidersInGetProviders:
assert "glm-5.1" in model_ids, (
f"Expected glm-5.1 in models, got: {model_ids}"
)
assert glmcode["models_total"] == 1
finally:
self._restore_cfg(old_cfg, old_mtime)
def test_providers_panel_renders_config_yaml_custom_providers(self):
"""Settings → Providers must not filter out read-only custom providers."""
src = open("static/panels.js", encoding="utf-8").read()
assert "filter(p=>p.configurable||p.is_oauth||p.is_custom)" in src
assert "Custom provider loaded from config.yaml / hermes model" in src
assert "if(p.configurable){" in src
def test_custom_provider_with_multi_models(self, monkeypatch, tmp_path):
"""Custom provider with `models` list should expose all entries."""
_install_fake_hermes_cli(monkeypatch)
+9
View File
@@ -59,3 +59,12 @@ def test_dashboard_settings_controls_live_in_system_panel():
assert 'id="settingsDashboardUrl"' in INDEX_HTML
assert "function saveDashboardSettings" in UI_JS
assert "api('/api/dashboard/config'" in UI_JS
def test_dashboard_frontend_uses_browser_url_without_requiring_probe_port():
match = re.search(r"function _dashboardBrowserUrl\(status\).*?\n}\nfunction _applyDashboardStatus", UI_JS, re.DOTALL)
assert match is not None
helper = match.group(0)
assert "status.browser_url||status.url" in helper
assert "!status.port" in helper
assert helper.index("status.browser_url||status.url") < helper.index("!status.port")
+27 -11
View File
@@ -132,11 +132,18 @@ def test_status_tries_default_loopback_targets_until_dashboard_found(monkeypatch
assert attempts == [("127.0.0.1", 9119, 0.5, "http"), ("localhost", 9119, 0.5, "http")]
def test_status_honors_never_and_strict_override(monkeypatch):
def test_dashboard_browser_url_preserves_ipv6_host_brackets():
from api.dashboard_probe import normalize_dashboard_browser_url
assert normalize_dashboard_browser_url("http://[::1]:9119") == "http://[::1]:9119"
assert normalize_dashboard_browser_url("https://[2001:db8::1]") == "https://[2001:db8::1]"
def test_status_honors_never_and_external_browser_link_without_probe(monkeypatch):
from api import dashboard_probe
def fail_probe(*args, **kwargs):
raise AssertionError("disabled dashboard must not probe")
raise AssertionError("this status path must not probe a dashboard")
monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fail_probe)
assert dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"enabled": "never"}}}) == {
@@ -144,9 +151,13 @@ def test_status_honors_never_and_strict_override(monkeypatch):
"enabled": "never",
}
result = dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"url": "http://example.com:9119"}}})
assert result["running"] is False
assert "invalid" in result["error"]
result = dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"enabled": "always", "url": "https://dashboard.example.test"}}})
assert result == {
"running": True,
"enabled": "always",
"url": "https://dashboard.example.test",
"browser_url": "https://dashboard.example.test",
}
@@ -203,9 +214,14 @@ def test_dashboard_config_roundtrip_writes_profile_config_yaml(tmp_path, monkeyp
assert saved == {"enabled": "auto", "url": "http://127.0.0.1:19119"}
assert "dashboard:" in (tmp_path / "config.yaml").read_text(encoding="utf-8")
try:
save_dashboard_config({"enabled": "auto", "url": "http://example.com:9119"})
except ValueError:
pass
else:
raise AssertionError("external dashboard URL override must be rejected")
saved = save_dashboard_config({"enabled": "always", "url": "https://dashboard.example.test"})
assert saved == {"enabled": "always", "url": "https://dashboard.example.test"}
assert get_dashboard_config() == {"enabled": "always", "url": "https://dashboard.example.test"}
for unsafe_url in ("https://example.com/path", "https://user:pass@example.com", "javascript:alert(1)"):
try:
save_dashboard_config({"enabled": "auto", "url": unsafe_url})
except ValueError:
pass
else:
raise AssertionError(f"unsafe dashboard URL must be rejected: {unsafe_url}")
+123
View File
@@ -0,0 +1,123 @@
"""Test that new_session() reads display.personality from config and uses it as default.
Regression test for the feature that makes /personality taleb sticky across
new sessions — when display.personality is set in config.yaml, every new
session should inherit it without requiring an explicit /personality command.
"""
import pytest
from unittest.mock import patch
# ---------------------------------------------------------------------------
# R1: new_session() inherits display.personality from config
# ---------------------------------------------------------------------------
def test_new_session_reads_default_personality_from_config():
"""When display.personality is set to 'taleb', new_session() should
create a Session with personality='taleb'."""
import api.models as m
import api.config as cfg_mod
_cfg = {
"display": {"personality": "taleb"},
"agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb", "tone": "blunt"}}},
}
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality")
assert s.personality == "taleb", (
f"Expected personality='taleb', got {s.personality!r}"
)
# ---------------------------------------------------------------------------
# R2: 'none', 'default', 'neutral' are treated as no personality
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("personality_value", ["none", "default", "neutral", ""])
def test_new_session_ignores_neutral_personality_values(personality_value):
"""Values like 'none', 'default', 'neutral', and '' should NOT be set as
the session personality — they mean 'no personality overlay'."""
import api.models as m
import api.config as cfg_mod
_cfg = {
"display": {"personality": personality_value},
"agent": {"personalities": {}},
}
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality-neutral")
assert s.personality is None, (
f"Expected None for display.personality={personality_value!r}, "
f"got {s.personality!r}"
)
# ---------------------------------------------------------------------------
# R3: Missing display.personality → personality=None
# ---------------------------------------------------------------------------
def test_new_session_no_personality_when_config_missing():
"""When config has no display.personality (or display section is absent),
new_session() should set personality=None."""
import api.models as m
import api.config as cfg_mod
_cfg = {"agent": {"personalities": {}}} # No display section at all
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality-missing")
assert s.personality is None
# ---------------------------------------------------------------------------
# R4: Config exception is handled gracefully → personality=None
# ---------------------------------------------------------------------------
def test_new_session_handles_config_exception_gracefully():
"""If get_config() raises, we should still get a valid session with
personality=None (the try/except should swallow the error)."""
import api.models as m
import api.config as cfg_mod
def _boom():
raise RuntimeError("config exploded")
with patch.object(cfg_mod, "get_config", side_effect=_boom), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality-boom")
assert s.personality is None
# ---------------------------------------------------------------------------
# R5: display.personality is case-insensitive
# ---------------------------------------------------------------------------
def test_new_session_personality_is_case_insensitive():
"""display.personality='Taleb' should be normalized to 'taleb'."""
import api.models as m
import api.config as cfg_mod
_cfg = {
"display": {"personality": "Taleb"},
"agent": {"personalities": {"taleb": {"system_prompt": "Be like Taleb"}}},
}
with patch.object(cfg_mod, "get_config", return_value=_cfg), \
patch.object(m.Session, "save", return_value=None):
s = m.new_session(workspace="/tmp/test-personality-case")
assert s.personality == "taleb"
+54
View File
@@ -0,0 +1,54 @@
"""Geist Contrast skin registration and contrast affordances."""
from pathlib import Path
REPO = Path(__file__).parent.parent
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
COMMANDS_JS = (REPO / "static" / "commands.js").read_text(encoding="utf-8")
def test_geist_contrast_skin_is_registered_with_matching_key_and_label():
assert "{name:'Geist Contrast'" in BOOT_JS
assert "value:'geist-contrast'" in BOOT_JS
assert "s.value||s.name" in BOOT_JS
assert "'geist-contrast':1" in INDEX_HTML
assert '"geist-contrast"' in CONFIG_PY
def test_geist_contrast_slash_theme_uses_skin_value_key():
assert "const skins=(_SKINS||[]).map(s=>(s.value||s.name).toLowerCase());" in COMMANDS_JS
assert "skins.includes(val)" in COMMANDS_JS
assert "showToast(t('theme_set')+appearance.skin)" in COMMANDS_JS
def test_geist_contrast_dark_tokens_use_yellow_accent_with_neutral_surfaces():
assert ':root.dark[data-skin="geist-contrast"]' in CSS
assert "--bg:#000000" in CSS
assert "--surface:#0a0a0a" in CSS
assert "--text:#ededed" in CSS
assert "--accent:#FFF175" in CSS
assert "--accent-text:#f5e65f" in CSS
def test_geist_contrast_selection_is_neutral_not_solid_yellow():
active_rule = ':root[data-skin="geist-contrast"] .session-item.active{position:relative;border:1px solid var(--border2)!important;}'
marker_rule = ':root[data-skin="geist-contrast"] .session-item.active::before{content:"";position:absolute;left:6px;top:10px;bottom:10px;width:2px;border-radius:999px;background:var(--accent);}'
dark_text_rule = ':root.dark[data-skin="geist-contrast"] .session-item.active,\n :root.dark[data-skin="geist-contrast"] .session-item.active *{color:var(--text)!important;}'
assert active_rule in CSS
assert marker_rule in CSS
assert dark_text_rule in CSS
def test_geist_contrast_solid_accent_controls_use_black_text_in_dark_mode():
assert ':root[data-skin="geist-contrast"] button.send-btn:not(:disabled)' in CSS
assert "color:#050505!important" in CSS
assert ':root[data-skin="geist-contrast"] button.send-btn:disabled{background:var(--surface-subtle)!important;border-color:var(--border)!important;color:var(--muted)!important;opacity:1!important;}' in CSS
assert ':root.dark[data-skin="geist-contrast"] button.send-btn:disabled svg' in CSS
def test_geist_contrast_composer_chips_are_neutral_until_hovered():
neutral_rule = ':root[data-skin="geist-contrast"] .profile-chip,\n :root[data-skin="geist-contrast"] .model-chip,\n :root[data-skin="geist-contrast"] .reasoning-chip,\n :root[data-skin="geist-contrast"] .composer-workspace-chip,\n :root[data-skin="geist-contrast"] .composer-profile-chip,\n :root[data-skin="geist-contrast"] .composer-model-chip{color:var(--muted)!important;border-color:transparent!important;background:transparent!important;}'
assert neutral_rule in CSS
+51
View File
@@ -0,0 +1,51 @@
"""Regression coverage for send/start optimistic INFLIGHT races."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def _function_body(src: str, name: str) -> str:
marker = f"function {name}"
start = src.index(marker)
brace = src.index("{", start)
depth = 1
i = brace + 1
while depth and i < len(src):
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
return src[brace + 1 : i - 1]
def test_send_preserves_optimistic_messages_across_chat_start_await():
"""send() must not dereference INFLIGHT[activeSid] after await without a fallback."""
body = _function_body(MESSAGES_JS, "send")
setup_idx = body.index("const optimisticMessages=[...S.messages];")
inflight_idx = body.index("INFLIGHT[activeSid]={messages:optimisticMessages")
await_idx = body.index("const startData=await api('/api/chat/start'")
save_idx = body.index("saveInflightState(activeSid,{streamId", await_idx)
assert setup_idx < inflight_idx < await_idx < save_idx
post_await = body[await_idx:save_idx]
assert "if(!INFLIGHT[activeSid])" in post_await, (
"send() should recreate the INFLIGHT entry if a session-list refresh pruned it"
)
assert "messages:INFLIGHT[activeSid].messages" not in body[save_idx : save_idx + 220], (
"saveInflightState() should use a guarded local/current inflight object, not a blind nested read"
)
def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists():
"""Sidebar cleanup must not delete the active send before /api/chat/start responds."""
body = _function_body(SESSIONS_JS, "_purgeStaleInflightEntries")
assert "_sendInProgress" in body and "_sendInProgressSid" in body, (
"_purgeStaleInflightEntries() should skip the current send while start is in progress"
)
skip_idx = body.index("_sendInProgress")
delete_idx = body.index("delete INFLIGHT[sid];")
assert skip_idx < delete_idx, "the current-send skip must run before any purge deletion"
@@ -291,7 +291,8 @@ class TestProviderListFilter:
"Settings → Providers panel. The filter must be 'filter(p=>p.configurable||p.is_oauth)'."
)
fixed_filter_idx = self.JS.find("filter(p=>p.configurable||p.is_oauth)")
assert fixed_filter_idx != -1, (
"The provider filter 'filter(p=>p.configurable||p.is_oauth)' is missing from panels.js. "
extended_filter_idx = self.JS.find("filter(p=>p.configurable||p.is_oauth||p.is_custom)")
assert fixed_filter_idx != -1 or extended_filter_idx != -1, (
"The provider filter must include p.is_oauth in panels.js. "
"OAuth providers will not appear in Settings → Providers."
)
@@ -5,6 +5,7 @@ from types import SimpleNamespace
from api.streaming import (
_assistant_reply_added_after_current_turn,
_context_messages_for_new_turn,
_dedupe_replayed_active_context,
_merge_display_messages_after_agent_result,
_new_turn_context_from_messages,
_sanitize_messages_for_api,
@@ -227,6 +228,60 @@ def test_append_only_agent_result_preserves_normal_delta_behavior():
assert merged == result_messages
def test_replayed_active_tail_after_compression_is_not_duplicated():
previous_display = [
{"role": "assistant", "content": "old visible transcript"},
{"role": "user", "content": "choose agent"},
{"role": "assistant", "content": "checking agents"},
{"role": "tool", "content": "agent list"},
{"role": "assistant", "content": "agent answer"},
]
previous_context = [
{"role": "assistant", "content": "[Session Arc Summary] compacted history"},
{"role": "user", "content": "choose agent"},
{"role": "assistant", "content": "checking agents"},
{"role": "tool", "content": "agent list"},
{"role": "assistant", "content": "agent answer"},
]
result_messages = previous_context + [
# The new compressed state.db segment can replay the already-visible
# active tail before adding the next turn.
{"role": "user", "content": "choose agent"},
{"role": "assistant", "content": "checking agents"},
{"role": "tool", "content": "agent list"},
{"role": "assistant", "content": "agent answer"},
{"role": "user", "content": "choose B"},
{"role": "assistant", "content": "using B"},
]
merged = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
result_messages,
"choose B",
)
next_context = _dedupe_replayed_active_context(previous_context, result_messages)
assert [m["content"] for m in merged] == [
"old visible transcript",
"choose agent",
"checking agents",
"agent list",
"agent answer",
"choose B",
"using B",
]
assert [m["content"] for m in next_context] == [
"[Session Arc Summary] compacted history",
"choose agent",
"checking agents",
"agent list",
"agent answer",
"choose B",
"using B",
]
def test_repeated_user_text_after_compaction_is_not_dropped():
previous_display = [
{"role": "user", "content": "continue"},
+8 -5
View File
@@ -12,13 +12,16 @@ def _body_between(src: str, start: str, end: str) -> str:
return src[start_idx:end_idx]
def test_model_picker_open_waits_for_async_model_catalog_before_rendering():
"""Opening the visible picker must not render stale static <select> options."""
def test_model_picker_opens_before_async_model_catalog_finishes():
"""Opening the visible picker must not block on a slow /api/models request."""
body = _body_between(UI_JS, "async function toggleModelDropdown", "function closeModelDropdown")
assert "window._modelDropdownReady" in body
assert "await" in body
assert body.index("await") < body.index("renderModelDropdown()")
assert "window._ensureModelDropdownReady" in body
render_idx = body.index("renderModelDropdown()")
open_idx = body.index("dd.classList.add('open')")
await_idx = body.find("await")
assert render_idx < open_idx
assert await_idx == -1 or open_idx < await_idx
def test_populate_model_dropdown_rerenders_if_picker_is_already_open():
@@ -61,13 +61,12 @@ class TestFastPathInvocation:
)
assert result == ("gpt-5.5", "openai-codex", False)
def test_fast_path_with_slash_qualified_model_skips_catalog(self):
"""Slash-qualified IDs (openrouter/...) still hit the fast path.
def test_fast_path_with_openrouter_slash_qualified_model_skips_catalog(self):
"""OpenRouter slash-qualified IDs still hit the fast path.
The fast path only excludes @provider:model strings, not slash-
qualified ones — those are valid model IDs that the picker emits
for OpenRouter and custom-provider routing, and a stored
model_provider is the authoritative routing decision.
Slash-qualified IDs are valid picker output for OpenRouter and a stored
model_provider is the authoritative routing decision. This remains fast
for explicit OpenRouter selections.
"""
from api.routes import _resolve_compatible_session_model_state
@@ -80,6 +79,33 @@ class TestFastPathInvocation:
assert mock_catalog.call_count == 0
assert result == ("anthropic/claude-opus-4.7", "openrouter", False)
def test_codex_with_stale_openai_slash_id_uses_catalog_repair(self):
"""Codex must repair stale OpenRouter-shaped OpenAI IDs.
Browser/localStorage state can submit ``openai/gpt-...`` while the
session/provider is ``openai-codex``. If the fast path preserves that
pair, runtime resolution routes through OpenRouter instead of Codex.
The Codex + ``openai/...`` shape must therefore use the slow-path repair
and normalize back to the active Codex default.
"""
from api.routes import _resolve_compatible_session_model_state
with patch("api.routes.get_available_models") as mock_catalog:
mock_catalog.return_value = {
"active_provider": "openai-codex",
"default_model": "gpt-5.5",
"groups": [
{"provider_id": "openai-codex", "models": [{"id": "gpt-5.5"}]}
],
}
result = _resolve_compatible_session_model_state(
"openai/gpt-5.4-mini",
"openai-codex",
)
assert mock_catalog.call_count == 1
assert result == ("gpt-5.5", "openai-codex", True)
def test_fast_path_normalizes_provider_default_alias(self):
"""`'default'` is treated as None by _clean_session_model_provider.
@@ -0,0 +1,107 @@
"""Regression test for Issue #2233: per-turn SQLite connection leak.
Two functions on the /api/session/handoff-summary hot path were opening
``sqlite3.connect(...)`` inside a bare ``with`` statement, which commits
the transaction at scope exit but does NOT close the connection. Looping
those calls per chat turn accumulated file descriptors (state.db and
state.db-wal) and CPython heap pages on long-lived worker threads.
The fix wraps both connect() calls with ``contextlib.closing(...)`` so
the connection is closed deterministically:
* api/models.py :: count_conversation_rounds
* api/routes.py :: _persist_handoff_summary_to_state_db
This test loops the two patched functions ~20 times against a tmp state.db
and asserts the parent process open-fd count does not climb.
Linux-only because the check reads ``/proc/<pid>/fd`` directly. Skipped
on macOS/Windows.
"""
import os
import sqlite3
import sys
from pathlib import Path
import pytest
_IS_LINUX = sys.platform.startswith("linux")
def _open_fd_count() -> int:
return len(os.listdir(f"/proc/{os.getpid()}/fd"))
def _make_state_db(path: Path) -> None:
"""Create a state.db with the minimum schema the two patched functions touch."""
conn = sqlite3.connect(str(path))
try:
conn.execute(
"CREATE TABLE messages ("
" session_id TEXT,"
" role TEXT,"
" content TEXT,"
" timestamp REAL"
")"
)
conn.execute(
"CREATE TABLE sessions ("
" id TEXT PRIMARY KEY,"
" message_count INTEGER"
")"
)
conn.execute(
"INSERT INTO sessions (id, message_count) VALUES (?, 0)",
("20260101_000000_abcdef",),
)
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (?, 'user', 'hi', 1.0)",
("20260101_000000_abcdef",),
)
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (?, 'agent', 'hello', 2.0)",
("20260101_000000_abcdef",),
)
conn.commit()
finally:
conn.close()
@pytest.mark.skipif(not _IS_LINUX, reason="fd counting via /proc only available on Linux")
def test_handoff_summary_path_does_not_leak_fds(tmp_path, monkeypatch):
"""Loop both patched functions and assert open-fd count stays bounded."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
state_db = hermes_home / "state.db"
_make_state_db(state_db)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
from api.models import count_conversation_rounds
from api.routes import _persist_handoff_summary_to_state_db
sid = "20260101_000000_abcdef"
marker = {
"role": "tool",
"content": "{\"handoff_summary\": \"test\"}",
"timestamp": 3.0,
}
count_conversation_rounds(sid)
_persist_handoff_summary_to_state_db(sid, marker)
fd_before = _open_fd_count()
for _ in range(20):
count_conversation_rounds(sid)
_persist_handoff_summary_to_state_db(sid, marker)
fd_after = _open_fd_count()
growth = fd_after - fd_before
assert growth <= 2, (
f"open fd count grew by {growth} (before={fd_before}, after={fd_after}); "
"suggests sqlite3 connections from the handoff-summary path are not being closed"
)
+1 -1
View File
@@ -29,7 +29,7 @@ def test_theme_command_help_mentions_current_theme_and_skin_values():
"""Every /theme help string should describe the current Theme × Skin contract."""
required_fragments = (
"system/dark/light",
"default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous",
"default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast",
)
for locale in ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "fr"):
value = _literal_value(_locale_block(locale), "cmd_theme")
@@ -49,6 +49,23 @@ def test_messaging_merge_helper_matches_session_get_coordinate_space():
]
def test_messaging_merge_helper_dedupes_equivalent_timestamp_formats():
session = SimpleNamespace(
messages=[
{"role": "user", "content": "hi", "timestamp": "10.0"},
{"role": "assistant", "content": "same answer", "timestamp": "11.000000"},
]
)
cli_messages = [
{"role": "user", "content": "hi", "timestamp": 10},
{"role": "assistant", "content": "same answer", "timestamp": 11},
]
merged = routes._merged_session_messages_for_display(session, cli_messages)
assert [m["content"] for m in merged] == ["hi", "same answer"]
def test_branch_handler_uses_merged_messaging_messages_for_keep_count():
branch_idx = ROUTES_PY.index('parsed.path == "/api/session/branch":')
block = ROUTES_PY[branch_idx : branch_idx + 2600]
+92
View File
@@ -0,0 +1,92 @@
"""Regression checks for issue #2508 session pinning bounds and context menu access."""
import json
import pathlib
import urllib.error
import urllib.request
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).resolve().parent.parent
ROUTES_PY = (ROOT / "api" / "routes.py").read_text()
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text()
STYLE_CSS = (ROOT / "static" / "style.css").read_text()
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
return json.loads(r.read()), r.status
except urllib.error.HTTPError as e:
return json.loads(e.read()), e.code
def make_session(created):
payload = {
"title": f"Pin cap {len(created) + 1}",
"messages": [{"role": "user", "content": "keep this conversation handy"}],
"model": "test/pin-cap",
}
d, status = post("/api/session/import", payload)
assert status == 200
sid = d["session"]["session_id"]
created.append(sid)
return sid
def test_session_pin_endpoint_caps_pinned_sessions_at_three():
created = []
try:
pinned = [make_session(created) for _ in range(3)]
for sid in pinned:
d, status = post("/api/session/pin", {"session_id": sid, "pinned": True})
assert status == 200
assert d["session"]["pinned"] is True
fourth = make_session(created)
d, status = post("/api/session/pin", {"session_id": fourth, "pinned": True})
assert status == 400
assert "3 sessions" in d.get("error", "")
d, status = post("/api/session/pin", {"session_id": pinned[0], "pinned": False})
assert status == 200
assert d["session"]["pinned"] is False
d, status = post("/api/session/pin", {"session_id": fourth, "pinned": True})
assert status == 200
assert d["session"]["pinned"] is True
finally:
for sid in created:
post("/api/session/delete", {"session_id": sid})
def test_session_pin_cap_has_backend_and_frontend_guards():
assert 'pinned_ids = {' in ROUTES_PY
assert 'pinned_ids.update(' in ROUTES_PY
assert 'pinned_sessions_limit = int(load_settings().get("pinned_sessions_limit", 3) or 3)' in ROUTES_PY
assert 'if len(pinned_ids) >= pinned_sessions_limit:' in ROUTES_PY
assert 'Up to {pinned_sessions_limit} sessions can be pinned' in ROUTES_PY
assert 'function _pinnedSessionCount()' in SESSIONS_JS
assert 'function _getPinnedSessionsLimit()' in SESSIONS_JS
assert 'function _pinnedSessionsLimit()' not in SESSIONS_JS
assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();' in SESSIONS_JS
assert 'Only ${limit} conversations can be pinned' in SESSIONS_JS
assert ".session-action-opt.is-disabled{opacity:.55;cursor:not-allowed;}" in STYLE_CSS
def test_session_rows_open_action_menu_from_right_click():
assert 'el.oncontextmenu=(e)=>{' in SESSIONS_JS
context_idx = SESSIONS_JS.find('el.oncontextmenu=(e)=>{')
assert context_idx != -1
block = SESSIONS_JS[context_idx:SESSIONS_JS.find('};', context_idx) + 2]
assert 'e.preventDefault();' in block
assert 'e.stopPropagation();' in block
assert '_openSessionActionMenu(s, actions||el);' in block
@@ -0,0 +1,124 @@
import urllib.error
import urllib.request
from email.message import Message
from api import config
class _ConfigState:
def __enter__(self):
self.old_cfg = config.cfg
self.old_mtime = config._cfg_mtime
self.old_cache = config._available_models_cache
self.old_cache_ts = config._available_models_cache_ts
self.old_cache_fp = config._available_models_cache_source_fingerprint
config._cfg_mtime = 0.0
config._available_models_cache = None
config._available_models_cache_ts = 0.0
config._available_models_cache_source_fingerprint = None
return self
def __exit__(self, exc_type, exc, tb):
config.cfg = self.old_cfg
config._cfg_mtime = self.old_mtime
config._available_models_cache = self.old_cache
config._available_models_cache_ts = self.old_cache_ts
config._available_models_cache_source_fingerprint = self.old_cache_fp
return False
def _configure_named_custom_provider(tmp_path, monkeypatch, *, model=None):
monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json")
monkeypatch.setattr(config, "_get_auth_store_path", lambda: tmp_path / "auth.json")
entry = {
"name": "Broken Proxy",
"base_url": "https://broken.example/v1",
"api_key": "bad-key",
}
if model:
entry["model"] = model
config.cfg = {
"model": {"provider": "openai-codex", "default": "gpt-5.5"},
"providers": {},
"fallback_providers": [],
"custom_providers": [entry],
}
def _groups_by_provider(data):
return {group["provider_id"]: group for group in data["groups"]}
def test_named_custom_provider_models_endpoint_401_surfaces_error(monkeypatch, tmp_path):
def fake_urlopen(req, timeout=10):
raise urllib.error.HTTPError(
getattr(req, "full_url", "https://broken.example/v1/models"),
401,
"Unauthorized",
hdrs=Message(),
fp=None,
)
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
with _ConfigState():
_configure_named_custom_provider(tmp_path, monkeypatch, model="broken/manual")
data = config.get_available_models()
group = _groups_by_provider(data)["custom:broken-proxy"]
error = group["models_endpoint_error"]
assert error["kind"] == "auth"
assert error["code"] == 401
assert "check the API key" in error["message"]
assert "broken-proxy" in error["message"]
assert "@custom:broken-proxy:broken/manual" in {m["id"] for m in group["models"]}
def test_named_custom_provider_models_endpoint_network_error_surfaces_empty_group(monkeypatch, tmp_path):
def fake_urlopen(req, timeout=10):
raise urllib.error.URLError("connection refused")
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
with _ConfigState():
_configure_named_custom_provider(tmp_path, monkeypatch)
data = config.get_available_models()
group = _groups_by_provider(data)["custom:broken-proxy"]
assert group["models"] == []
assert group["models_endpoint_error"]["kind"] == "network"
assert group["models_endpoint_error"]["code"] is None
assert "verify base_url" in group["models_endpoint_error"]["message"]
def test_named_custom_provider_models_endpoint_5xx_preserves_status(monkeypatch, tmp_path):
def fake_urlopen(req, timeout=10):
raise urllib.error.HTTPError(
getattr(req, "full_url", "https://broken.example/v1/models"),
502,
"Bad Gateway",
hdrs=Message(),
fp=None,
)
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
with _ConfigState():
_configure_named_custom_provider(tmp_path, monkeypatch, model="broken/manual")
data = config.get_available_models()
error = _groups_by_provider(data)["custom:broken-proxy"]["models_endpoint_error"]
assert error["kind"] == "http"
assert error["code"] == 502
assert "returned 502" in error["message"]
def test_frontend_model_picker_renders_provider_endpoint_hint():
ui = open("static/ui.js", encoding="utf-8").read()
css = open("static/style.css", encoding="utf-8").read()
assert "models_endpoint_error" in ui
assert "dataset.modelsEndpointError" in ui
assert "model-provider-hint" in ui
assert "entry.modelsEndpointError.message" in ui
assert ".model-provider-hint" in css
@@ -0,0 +1,26 @@
from pathlib import Path
UI_JS = Path("static/ui.js").read_text(encoding="utf-8")
def test_session_html_cache_uses_render_signature_not_only_count():
assert "function _messageRenderCacheSignature()" in UI_JS
assert "const renderSignature=_messageRenderCacheSignature();" in UI_JS
assert "cached.signature===renderSignature" in UI_JS
assert "signature:renderSignature" in UI_JS
def test_render_signature_tracks_message_content_and_settled_tool_cards():
signature_fn = UI_JS[UI_JS.index("function _messageRenderCacheSignature()"):UI_JS.index("function _clipCliToolSnippet")]
assert "msgContent(m)" in signature_fn
assert "m.tool_calls" in signature_fn
assert "m._partial_tool_calls" in signature_fn
assert "S.toolCalls" in signature_fn
assert "tc.snippet" in signature_fn
assert "compression_anchor_summary" in signature_fn
def test_documentation_no_longer_allows_same_count_stale_html():
assert "Known limitation: cache key is session_id + message count" not in UI_JS
assert "mutate message content without changing the count will serve stale HTML" not in UI_JS
+115
View File
@@ -0,0 +1,115 @@
"""Regression coverage for capped CLI/agent session sidebar scans (#2628)."""
import pathlib
import sqlite3
import time
import api.agent_sessions as agent_sessions
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
def _make_state_db(path, *, sessions=80, messages_per_session=3):
conn = sqlite3.connect(str(path))
conn.executescript(
"""
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
message_count INTEGER DEFAULT 0,
parent_session_id TEXT,
ended_at REAL,
end_reason TEXT
);
CREATE INDEX idx_sessions_started ON sessions(started_at);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
timestamp REAL
);
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
"""
)
base = time.time() - sessions
for i in range(sessions):
sid = f"cli_perf_{i:04d}"
started = base + i
conn.execute(
"""
INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, 'cli', 'cli', ?, 'openai/gpt-5', ?, ?, NULL, NULL, NULL)
""",
(sid, sid, started, messages_per_session),
)
for j in range(messages_per_session):
conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, 'hello', ?)",
(f"msg_{i:04d}_{j:02d}", sid, "user" if j == 0 else "assistant", started + j / 10),
)
conn.commit()
conn.close()
def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path):
"""A capped sidebar scan should not aggregate the entire state.db first."""
db = tmp_path / "state.db"
_make_state_db(db, sessions=120, messages_per_session=5)
rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",))
assert len(rows) == 20
assert [row["id"] for row in rows][:3] == ["cli_perf_0119", "cli_perf_0118", "cli_perf_0117"]
assert {row["actual_message_count"] for row in rows} == {5}
src = (REPO_ROOT / "api" / "agent_sessions.py").read_text()
assert "WITH candidates AS" in src
assert "JOIN candidates c ON c.id = s.id" in src
assert "SELECT MAX(mx.timestamp) FROM messages mx WHERE mx.session_id = s.id" in src
assert "candidate_limit = max(result_limit * 8, result_limit)" in src
def test_importable_agent_rows_limit_includes_resumed_old_session(tmp_path):
"""The capped candidate window must not hide old sessions resumed recently."""
db = tmp_path / "state.db"
_make_state_db(db, sessions=200, messages_per_session=1)
old_started = time.time() - 60 * 60 * 24 * 30
recent_activity = time.time() + 60
conn = sqlite3.connect(str(db))
conn.execute(
"""
INSERT INTO sessions
(id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES ('cli_resumed_old', 'cli', 'cli', 'Old resumed session', 'openai/gpt-5', ?, 2, NULL, NULL, NULL)
""",
(old_started,),
)
conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES ('old_msg_1', 'cli_resumed_old', 'user', 'old hello', ?)",
(old_started,),
)
conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES ('old_msg_2', 'cli_resumed_old', 'assistant', 'recent reply', ?)",
(recent_activity,),
)
conn.commit()
conn.close()
rows = agent_sessions.read_importable_agent_session_rows(db, limit=20, exclude_sources=("webui",))
assert rows[0]["id"] == "cli_resumed_old"
assert rows[0]["actual_message_count"] == 2
def test_importable_agent_rows_zero_limit_skips_query_work(tmp_path):
db = tmp_path / "state.db"
_make_state_db(db, sessions=5, messages_per_session=1)
assert agent_sessions.read_importable_agent_session_rows(db, limit=0, exclude_sources=("webui",)) == []
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
PANELS_JS = Path("static/panels.js").read_text(encoding="utf-8")
CHANGELOG = Path("CHANGELOG.md").read_text(encoding="utf-8")
def test_session_events_reconnect_uses_jittered_backoff_not_fixed_delay():
assert "function _sessionEventsReconnectDelayMs()" in SESSIONS_JS
assert "Math.random()" in SESSIONS_JS
assert "_sessionEventsReconnectMaxMs" in SESSIONS_JS
assert "_sessionEventsReconnectAttempt = 0" in SESSIONS_JS
ensure_fn = SESSIONS_JS[SESSIONS_JS.find("function ensureSessionEventsSSE()") :]
assert "const delayMs = _sessionEventsReconnectDelayMs();" in ensure_fn
assert "}, 5000);" not in ensure_fn
def test_cron_expanded_run_renders_full_content_inline():
assert "const expanded = _cronExpansionGet(_cronRunExpandKey(jobId, filename));" in PANELS_JS
assert "const output = expanded ? (data.content || data.snippet || '') : (data.snippet || data.content || '');" in PANELS_JS
assert "if (!expanded && data.content && data.snippet && data.content.length > data.snippet.length)" in PANELS_JS
assert "_cronExpansionSet(_cronRunExpandKey(jobId, filename), true);" in PANELS_JS
def test_changelog_mentions_session_and_cron_polish():
unreleased = CHANGELOG.split("## [v0.51.103]", 1)[0]
assert "bounded jitter/backoff" in unreleased
assert "Expanded cron run rows" in unreleased
assert "no longer drops content when Markdown rendering is unavailable" in unreleased
+57
View File
@@ -0,0 +1,57 @@
"""Regression coverage for #2679: optional hiding of empty-chat suggestions."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INDEX = REPO_ROOT / "static" / "index.html"
STYLE = REPO_ROOT / "static" / "style.css"
PANELS = REPO_ROOT / "static" / "panels.js"
BOOT = REPO_ROOT / "static" / "boot.js"
I18N = REPO_ROOT / "static" / "i18n.js"
CONFIG = REPO_ROOT / "api" / "config.py"
CHANGELOG = REPO_ROOT / "CHANGELOG.md"
def test_hide_suggestions_setting_is_default_off_and_allowed():
src = CONFIG.read_text(encoding="utf-8")
assert '"hide_empty_state_suggestions": False' in src
assert '"hide_empty_state_suggestions",' in src
def test_settings_preferences_expose_hide_suggestions_toggle():
html = INDEX.read_text(encoding="utf-8")
assert 'id="settingsHideSuggestions"' in html
assert 'data-i18n="settings_label_hide_suggestions"' in html
assert 'data-i18n="settings_desc_hide_suggestions"' in html
def test_empty_state_has_hideable_suggestions_hook_and_css():
html = INDEX.read_text(encoding="utf-8")
css = STYLE.read_text(encoding="utf-8")
assert 'class="suggestion-grid"' in html
assert '.empty-state.no-suggestions .suggestion-grid{display:none}' in css
def test_boot_applies_saved_hide_suggestions_preference():
js = BOOT.read_text(encoding="utf-8")
assert "function applyEmptyStateSuggestionPref()" in js
assert "window._hideEmptyStateSuggestions=s.hide_empty_state_suggestions===true" in js
assert "window._hideEmptyStateSuggestions=false" in js
assert "$('emptyState').classList.toggle('no-suggestions',window._hideEmptyStateSuggestions===true)" in js
def test_panels_round_trip_and_hot_apply_hide_suggestions():
js = PANELS.read_text(encoding="utf-8")
assert "const hideSuggestionsCb=$('settingsHideSuggestions');" in js
assert "payload.hide_empty_state_suggestions=hideSuggestionsCb.checked;" in js
assert "hideSuggestionsCb.checked=settings.hide_empty_state_suggestions===true;" in js
assert "window._hideEmptyStateSuggestions=hideSuggestionsCb.checked;" in js
assert "if(typeof applyEmptyStateSuggestionPref==='function') applyEmptyStateSuggestionPref();" in js
def test_hide_suggestions_i18n_all_locales_and_changelog():
js = I18N.read_text(encoding="utf-8")
assert js.count("settings_label_hide_suggestions:") == 11
assert js.count("settings_desc_hide_suggestions:") == 11
changelog = CHANGELOG.read_text(encoding="utf-8")
assert "#2679" in changelog
assert "hide_empty_state_suggestions" in changelog
@@ -138,3 +138,55 @@ def test_session_list_only_moves_to_active_when_active_row_is_not_visible():
assert before_idx < visible_idx < move_idx < final_idx < anchor_idx
assert "activeIndex:-1" in render_body[before_idx:visible_idx]
assert "activeIndex:shouldAnchorActive?activeIndex:-1" not in render_body
def test_session_list_resyncs_when_browser_clamps_virtual_scroll_restore():
"""If a hidden/reflowed sidebar rejects restored scrollTop, re-render the visible window."""
js = SESSIONS_JS_PATH.read_text(encoding="utf-8")
render_start = js.index("function renderSessionListFromCache()")
render_end = js.index("async function _handleActiveSessionStorageEvent", render_start)
render_body = js[render_start:render_end]
assert "_resyncSessionVirtualWindowAfterRender(list, listScrollTopBeforeRender, virtualWindow);" in render_body
source = _extract_func_script(js) + """
let renderCount = 0;
let rafCount = 0;
let _renamingSid = null;
const SESSION_VIRTUAL_ROW_HEIGHT = 52;
function requestAnimationFrame(cb){ rafCount += 1; cb(); return rafCount; }
function cancelAnimationFrame(_id){}
function renderSessionListFromCache(){ renderCount += 1; }
const makeHelper = new Function(
'requestAnimationFrame',
'cancelAnimationFrame',
'renderSessionListFromCache',
`let _sessionVirtualResyncRaf = 0;
let _renamingSid = null;
const SESSION_VIRTUAL_ROW_HEIGHT = 52;
${extractFunc('_resyncSessionVirtualWindowAfterRender')}
return _resyncSessionVirtualWindowAfterRender;`
);
const _resyncSessionVirtualWindowAfterRender = makeHelper(
requestAnimationFrame,
cancelAnimationFrame,
renderSessionListFromCache
);
_resyncSessionVirtualWindowAfterRender(
{scrollTop: 0},
52 * 10,
{virtualized: true, itemHeight: 52}
);
const afterClamp = renderCount;
_resyncSessionVirtualWindowAfterRender(
{scrollTop: 52 * 10},
52 * 10,
{virtualized: true, itemHeight: 52}
);
console.log(JSON.stringify({afterClamp, final: renderCount, rafCount}));
"""
metrics = json.loads(_run_node(source))
assert metrics["afterClamp"] == 1
assert metrics["final"] == 1
assert metrics["rafCount"] == 2
+2 -1
View File
@@ -33,7 +33,8 @@ def test_windowed_render_keeps_streaming_and_tool_activity_anchored_to_rendered_
def test_window_state_participates_in_cache_and_cached_button_is_rewired():
assert "cached.renderWindowSize===renderWindowSize" in UI_JS
assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize})" in UI_JS
assert "cached.signature===renderSignature" in UI_JS
assert "_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature})" in UI_JS
assert "function _wireMessageWindowLoadEarlierButton()" in UI_JS
assert "_wireMessageWindowLoadEarlierButton();" in UI_JS
assert UI_JS.count("_wireMessageWindowLoadEarlierButton();") >= 2
+1 -1
View File
@@ -337,7 +337,7 @@ class TestIssue765FollowupHardening:
"with _agent_lock:\n"
" if not ephemeral and not _stream_writeback_is_current(s, stream_id):"
)
save_idx = src.find("s.context_messages = _next_context_messages")
save_idx = src.find("_deduplicate_context_messages(_next_context_messages)")
assert stop_idx != -1, "Success path must stop the checkpoint thread"
assert join_idx != -1, "Success path must join the checkpoint thread"
+24
View File
@@ -266,6 +266,30 @@ def test_sessions_js_sends_profile_in_new_session_post():
)
def test_new_session_uses_explicit_profile_default_model_and_provider(tmp_path, monkeypatch):
"""New chats must inherit the selected profile's config default."""
import api.profiles as p
import api.models as m
monkeypatch.setattr(p, "_DEFAULT_HERMES_HOME", tmp_path)
profile_home = tmp_path / "profiles" / "pepper"
profile_home.mkdir(parents=True)
(profile_home / "config.yaml").write_text(
"model:\n default: pepper-profile-model\n provider: pepper-provider\n",
encoding="utf-8",
)
with patch.object(m.Session, 'save', return_value=None):
s = m.new_session(workspace=str(tmp_path), profile="pepper")
try:
assert s.model == "pepper-profile-model"
assert s.model_provider == "pepper-provider"
assert s.profile == "pepper"
finally:
with m.LOCK:
m.SESSIONS.pop(s.session_id, None)
def test_get_hermes_home_for_profile_rejects_path_traversal():
"""R19j: get_hermes_home_for_profile() must reject names that don't match
_PROFILE_ID_RE (e.g. path traversal like '../../etc') and return the base
+73
View File
@@ -0,0 +1,73 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _read(relpath: str) -> str:
return (ROOT / relpath).read_text(encoding="utf-8")
def _function_block(src: str, name: str) -> str:
start = src.find(f"def {name}")
assert start != -1, f"{name} not found"
next_def = src.find("\n def ", start + 1)
assert next_def != -1, f"end of {name} not found"
return src[start:next_def]
def test_tool_start_callback_emits_existing_tool_sse_event_with_tool_id():
src = _read("api/streaming.py")
block = _function_block(src, "on_tool_start")
assert "put('tool'" in block, (
"The dedicated Hermes Agent tool_start_callback must emit the existing "
"tool SSE event; otherwise WebUI stays visually silent while tools run."
)
assert "'event_type': 'tool.started'" in block
assert "'tid': tool_call_id" in block, (
"Live frontend cards need the tool_call_id so tool_complete can update "
"the running card in place."
)
assert "_live_tool_event_start_ids" in block, (
"Tool start SSE emission should be idempotent per callback id."
)
assert "STREAM_LIVE_TOOL_CALLS" in block and "'done': False" in block
def test_tool_complete_callback_emits_existing_tool_complete_sse_event_with_tool_id():
src = _read("api/streaming.py")
block = _function_block(src, "on_tool_complete")
assert "put('tool_complete'" in block, (
"The dedicated Hermes Agent tool_complete_callback must emit the existing "
"tool_complete SSE event so the frontend can settle the running tool card."
)
assert "'event_type': 'tool.completed'" in block
assert "'tid': tool_call_id" in block
assert "_live_tool_event_complete_ids" in block, (
"Tool completion SSE emission should be idempotent per callback id."
)
assert "result_snippet = _tool_result_snippet(function_result)" in block
assert "_checkpoint_activity[0] += 1" in block
def test_legacy_progress_events_are_suppressed_when_structured_callbacks_are_wired():
src = _read("api/streaming.py")
block = _function_block(src, "on_tool")
assert "event_type in (None, 'tool.started') and 'tool_start_callback' in _agent_params" in block
assert "event_type == 'tool.completed' and 'tool_complete_callback' in _agent_params" in block
assert block.index("'tool_start_callback' in _agent_params") < block.index("put('tool'")
assert block.index("'tool_complete_callback' in _agent_params") < block.index("put('tool_complete'")
def test_tool_callback_events_keep_existing_frontend_event_contract():
messages = _read("static/messages.js")
ui = _read("static/ui.js")
assert "source.addEventListener('tool',e=>{" in messages
assert "source.addEventListener('tool_complete',e=>{" in messages
assert "tid:d.tid" in messages
assert "data-live-tid" in ui
assert "existing.replaceWith(replacement)" in ui
+18
View File
@@ -270,6 +270,24 @@ def test_recover_all_sessions_on_startup_restores_orphan_bak(temp_session_dir):
assert len(restored["messages"]) == 293
def test_recover_all_sessions_on_startup_rebuilds_missing_index_without_restores(temp_session_dir, monkeypatch):
"""Startup recovery must rebuild a missing index even when no .bak restore runs."""
import api.models as _m
sid = _make_session_on_disk(temp_session_dir, n_msgs=42)
missing_index = temp_session_dir / "_index.json"
monkeypatch.setattr(_m, "SESSION_INDEX_FILE", missing_index)
assert not missing_index.exists()
from api.session_recovery import recover_all_sessions_on_startup
result = recover_all_sessions_on_startup(temp_session_dir, rebuild_index=True)
assert result["restored"] == 0
index = json.loads(missing_index.read_text(encoding="utf-8"))
assert [entry["session_id"] for entry in index] == [sid]
assert index[0]["message_count"] == 42
def test_recover_all_sessions_on_startup_rebuilds_index_after_orphan_restore(temp_session_dir, monkeypatch):
"""A restored orphan must be visible through the WebUI session index immediately."""
import api.models as _m
+18
View File
@@ -1188,6 +1188,24 @@ def test_mobile_enter_newline_uses_match_media():
"boot.js must use matchMedia('(pointer:coarse)') for mobile detection"
def test_mobile_enter_newline_checks_virtual_keyboard_viewport():
"""Touch devices should only force newline while the software keyboard is likely open."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function _isVirtualKeyboardLikelyOpen()" in boot_js, \
"boot.js must isolate the software-keyboard viewport heuristic"
assert "window.visualViewport" in boot_js and "window.innerHeight-vv.height>120" in boot_js, \
"software-keyboard detection must compare visualViewport height against window.innerHeight"
assert "&&_isVirtualKeyboardLikelyOpen()" in boot_js, \
"mobile Enter newline override must not apply when a hardware keyboard leaves the viewport unshrunk"
def test_mobile_enter_newline_preserves_legacy_fallback_without_visual_viewport():
"""Browsers without visualViewport should keep the previous touch Enter=newline behavior."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "if(!vv||!window.innerHeight)return true;" in boot_js, \
"missing visualViewport support must preserve the legacy touch-primary newline fallback"
def test_mobile_enter_newline_only_overrides_enter_default():
"""Mobile newline override must only apply when _sendKey is the default 'enter'."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
@@ -0,0 +1,34 @@
"""
Regression coverage for model selector drift on fresh browser boot.
A stale browser-persisted model (localStorage) must not suppress the configured
profile/server default on page load. Restored sessions may still apply their own
session model later through loadSession().
"""
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def test_boot_settings_applies_default_even_when_browser_model_state_exists():
assert "Fresh page boot must prefer the profile/server default" in BOOT_JS
assert "_clearPersistedModelState" in BOOT_JS
assert "localStorage.removeItem('hermes-webui-model-state')" in BOOT_JS
assert "if(sel&&typeof _applyModelToDropdown==='function')" in BOOT_JS
assert "if(sel&&!savedState&&typeof _applyModelToDropdown==='function')" not in BOOT_JS
def test_populate_model_dropdown_default_not_blocked_by_localstorage():
assert "Do not let stale\n // browser localStorage suppress the profile default" in UI_JS
assert "if(data.default_model && !(S.session&&S.session.model))" in UI_JS
assert "_readPersistedModelState()" not in _populate_default_guard_snippet()
assert "localStorage.getItem('hermes-webui-model')" not in _populate_default_guard_snippet()
def _populate_default_guard_snippet() -> str:
marker = "// Set default model from server on fresh/blank boot."
start = UI_JS.index(marker)
return UI_JS[start : start + 500]
@@ -0,0 +1,53 @@
"""Regression coverage for model selection surviving hard refresh.
The frontend updates the visible model chip before the async
``/api/session/update`` request returns. A hard refresh can abort that request,
so the browser must remember the session-scoped selection and reapply it on the
next ``loadSession()`` before ``syncTopbar()`` projects server metadata.
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BOOT_JS = (ROOT / "static" / "boot.js").read_text()
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text()
UI_JS = (ROOT / "static" / "ui.js").read_text()
def _body_between(src: str, start: str, end: str) -> str:
start_idx = src.index(start)
end_idx = src.index(end, start_idx)
return src[start_idx:end_idx]
def test_model_selection_records_pending_state_before_async_session_update():
"""A refresh during /api/session/update must not lose the selected model."""
body = _body_between(BOOT_JS, "$('modelSelect').onchange=async()=>", "$('msg').addEventListener")
pending_idx = body.index("_rememberPendingSessionModel")
local_model_idx = body.index("S.session.model=modelState.model")
update_idx = body.index("await api('/api/session/update'")
assert pending_idx < update_idx
assert local_model_idx < update_idx
assert "_clearPendingSessionModel" in body
def test_load_session_applies_pending_model_before_first_topbar_sync():
"""Reload should project the pending selection before server old metadata wins."""
body = _body_between(SESSIONS_JS, "async function loadSession", "const activeStreamId=")
apply_idx = body.index("_applyPendingSessionModelForSession")
sync_idx = body.index("syncTopbar()")
assert apply_idx < sync_idx
def test_pending_model_helpers_are_session_scoped_and_expire():
assert "const PENDING_SESSION_MODEL_PREFIX" in UI_JS
assert "function _pendingSessionModelKey" in UI_JS
assert "function _rememberPendingSessionModel" in UI_JS
assert "function _applyPendingSessionModelForSession" in UI_JS
assert "propagateErrors:true" in UI_JS
assert "sessionStorage" in UI_JS
assert "10*60*1000" in UI_JS
@@ -0,0 +1,100 @@
from pathlib import Path
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
MESSAGES_JS = Path("static/messages.js").read_text(encoding="utf-8")
CHANGELOG = Path("CHANGELOG.md").read_text(encoding="utf-8")
def _extract_function(source: str, signature: str) -> str:
start = source.index(signature)
# Look for the function body's opening brace, not an object literal inside
# a default argument such as `options={}`.
brace = source.index("{\n", start)
depth = 0
for idx in range(brace, len(source)):
ch = source[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return source[start : idx + 1]
raise AssertionError(f"Function body not closed for {signature}")
def _new_session_function() -> str:
return _extract_function(SESSIONS_JS, "async function newSession")
def test_new_chat_syncs_model_picker_when_default_provider_changes_but_model_id_matches():
fn = _new_session_function()
assert "currentModelState" in fn
assert "currentProvider" in fn
assert "sessionProvider" in fn
assert "sessionProvider !== currentProvider" in fn
assert "_applyModelToDropdown(S.session.model,modelSel,sessionProvider)" in fn
def test_new_chat_inserts_session_model_when_static_picker_lacks_default():
fn = _new_session_function()
assert "sessionModelApplied" in fn
assert "document.createElement('option')" in fn
assert "opt.value=S.session.model" in fn
assert "opt.dataset.provider=sessionProvider||''" in fn
assert "modelSel.appendChild(opt)" in fn
def test_boot_model_hydration_prefers_active_session_over_persisted_model():
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
marker = "const sessionModelState=S.session&&S.session.model"
assert marker in boot_js
session_branch = boot_js[boot_js.index(marker) : boot_js.index("if(S.session) syncTopbar();", boot_js.index(marker))]
assert "_applyModelToDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)" in session_branch
assert "savedState" in session_branch
assert session_branch.index("sessionModelState") < session_branch.index("savedState"), (
"active session model must be considered before localStorage so stale saved model preferences cannot override new chats"
)
def test_hard_refresh_hydrates_saved_session_model_before_revealing_model_chip():
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
load_marker = "await loadSession(saved);"
assert load_marker in boot_js
saved_restore = boot_js[boot_js.index(load_marker) : boot_js.index("await checkInflightOnBoot(saved);return;", boot_js.index(load_marker))]
assert "await _startBootModelDropdown();" in saved_restore
assert saved_restore.index("await _startBootModelDropdown();") > saved_restore.index(load_marker)
assert saved_restore.index("await _startBootModelDropdown();") < saved_restore.index("S._bootReady=true;"), (
"hard refresh must hydrate/re-apply the active session model before S._bootReady lets syncModelChip display stale static HTML defaults"
)
def test_hard_refresh_injects_missing_active_session_model_option():
boot_js = Path("static/boot.js").read_text(encoding="utf-8")
marker = "if(!applied&&sessionModelState&&typeof _ensureModelOptionInDropdown==='function')"
assert marker in boot_js
branch = boot_js[boot_js.index(marker) : boot_js.index("else if(!applied&&!sessionModelState", boot_js.index(marker))]
assert "_ensureModelOptionInDropdown(sessionModelState.model,$('modelSelect'),sessionModelState.model_provider||null)" in branch
def test_sync_topbar_preserves_missing_session_model_as_dropdown_option():
ui_js = Path("static/ui.js").read_text(encoding="utf-8")
assert "function _ensureModelOptionInDropdown" in ui_js
sync_topbar = _extract_function(ui_js, "function syncTopbar")
branch_start = sync_topbar.index("const applied=_applyModelToDropdown(currentModel,modelSel,S.session.model_provider||null);")
session_model_branch = sync_topbar[branch_start:]
assert "_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null)" in session_model_branch
assert "const fallback=_applySessionModelFallback(modelSel);" in session_model_branch
assert session_model_branch.index("_ensureModelOptionInDropdown(currentModel,modelSel,S.session.model_provider||null)") < session_model_branch.index("const fallback=_applySessionModelFallback(modelSel);"), (
"active session models missing from the current catalog must be injected before fallback can select the static/default model"
)
def test_new_chat_does_not_send_stale_dropdown_model_when_session_has_default_model():
assert "model:S.session.model||$('modelSelect').value" in MESSAGES_JS
assert "model_provider:S.session.model_provider||null" in MESSAGES_JS
def test_changelog_mentions_new_chat_default_model_provider_sync():
unreleased = CHANGELOG.split("## [v0.51.103]", 1)[0]
assert "New conversations now resync" in unreleased
assert "default model provider" in unreleased
+306
View File
@@ -0,0 +1,306 @@
"""
Regression tests for the _partial message bloat bug.
The bug: empty _partial messages (reasoning-only cancellations where thinking
markup was stripped, leaving content='') accumulated exponentially because:
1. _message_identity() returned None for empty _partial messages (no text,
no tool_calls, no tool_call_id), so _merge_display_messages_after_agent_result
couldn't dedup them — they doubled every turn.
2. _sanitize_messages_for_api() and _api_safe_message_positions() didn't skip
empty _partial messages, so they were sent to the model as empty assistant
turns, wasting tokens and causing API 400 errors on strict providers.
3. _merge_display_messages_after_agent_result() didn't dedup stale _partial
messages in previous_display, so identical empty partials survived across
turn merges.
"""
import pytest
import api.streaming as streaming
# ── Fix 2: _message_identity for empty _partial messages ─────────────────
class TestMessageIdentityForEmptyPartial:
def test_empty_partial_gets_stable_identity(self):
"""Empty _partial messages must return a non-None identity so merge
can dedup them. Before the fix they returned None."""
msg = {
'role': 'assistant',
'content': '',
'_partial': True,
'reasoning': 'Step 1: analyze the problem',
}
identity = streaming._message_identity(msg)
assert identity is not None, (
"Empty _partial message must have a stable identity for merge dedup"
)
# Identity should include the __partial__ marker and reasoning
assert '__partial__' in identity[3], (
"Empty _partial identity must include __partial__ marker"
)
def test_empty_partial_different_reasoning_gets_different_identity(self):
"""Two _partial messages with different reasoning must be distinct."""
msg_a = {
'role': 'assistant',
'content': '',
'_partial': True,
'reasoning': 'Step 1: analyze the problem',
}
msg_b = {
'role': 'assistant',
'content': '',
'_partial': True,
'reasoning': 'Step 1: consider alternatives',
}
assert streaming._message_identity(msg_a) != streaming._message_identity(msg_b), (
"Different reasoning text must produce different identities"
)
def test_empty_partial_same_reasoning_gets_same_identity(self):
"""Two _partial messages with same reasoning must have identical identity."""
msg_a = {
'role': 'assistant',
'content': '',
'_partial': True,
'reasoning': 'Step 1: analyze the problem',
}
msg_b = {
'role': 'assistant',
'content': '',
'_partial': True,
'reasoning': 'Step 1: analyze the problem',
}
assert streaming._message_identity(msg_a) == streaming._message_identity(msg_b), (
"Same reasoning text must produce identical identities for dedup"
)
def test_empty_partial_no_reasoning_still_gets_identity(self):
"""Even _partial messages with no reasoning at all must be dedupable."""
msg = {
'role': 'assistant',
'content': '',
'_partial': True,
}
identity = streaming._message_identity(msg)
assert identity is not None, (
"Empty _partial with no reasoning must still have a stable identity"
)
def test_empty_non_partial_still_returns_none(self):
"""Non-_partial empty assistant messages still return None identity."""
msg = {
'role': 'assistant',
'content': '',
}
assert streaming._message_identity(msg) is None, (
"Non-_partial empty messages should still return None identity"
)
def test_nonempty_partial_keeps_original_identity(self):
"""_partial messages with actual text use the normal identity path."""
msg = {
'role': 'assistant',
'content': 'Python is a high-level',
'_partial': True,
}
identity = streaming._message_identity(msg)
assert identity is not None
assert '__partial__' not in identity[3], (
"Non-empty _partial should use normal identity, not __partial__ path"
)
# ── Fix 3: _sanitize_messages_for_api skips empty _partial ───────────────
class TestSanitizeSkipsEmptyPartial:
def test_empty_partial_excluded_from_api(self):
"""Empty _partial messages must be stripped from API context —
they have nothing for the model to continue from."""
messages = [
{'role': 'user', 'content': 'Tell me about Python'},
{'role': 'assistant', 'content': '', '_partial': True, 'reasoning': 'thinking...'},
{'role': 'user', 'content': 'Continue'},
]
clean = streaming._sanitize_messages_for_api(messages)
contents = [m.get('content', '') for m in clean]
# The empty _partial must be gone
assert not any(m.get('_partial') and not str(m.get('content', '')).strip()
for m in clean), (
"Empty _partial must be excluded from sanitized API messages"
)
def test_nonempty_partial_kept_in_api(self):
"""Non-empty _partial messages with actual text MUST be kept —
the model continues from the cut-off point (#893)."""
messages = [
{'role': 'user', 'content': 'Tell me about Python'},
{'role': 'assistant', 'content': 'Python is a high-level', '_partial': True},
]
clean = streaming._sanitize_messages_for_api(messages)
contents = [m.get('content', '') for m in clean]
assert any('Python is a high-level' in c for c in contents), (
"Non-empty _partial must be kept in API context (#893)"
)
def test_whitespace_only_partial_excluded(self):
"""_partial messages with only whitespace content are also excluded."""
messages = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': ' \n ', '_partial': True},
]
clean = streaming._sanitize_messages_for_api(messages)
assert not any(m.get('_partial') for m in clean), (
"Whitespace-only _partial must be excluded from sanitized messages"
)
# ── Fix 3b: _api_safe_message_positions skips empty _partial ─────────────
class TestApiSafePositionsSkipsEmptyPartial:
def test_empty_partial_excluded_from_positions(self):
"""Empty _partial must not appear in API-safe positions."""
messages = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': '', '_partial': True},
{'role': 'user', 'content': 'Continue'},
]
positions = streaming._api_safe_message_positions(messages)
# The empty _partial at index 1 must not be in positions
indices = [idx for idx, _ in positions]
assert 1 not in indices, (
"Empty _partial must not be in API-safe positions"
)
def test_nonempty_partial_included_in_positions(self):
"""Non-empty _partial must still appear in positions (#893)."""
messages = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': 'Partial text here', '_partial': True},
{'role': 'user', 'content': 'Continue'},
]
positions = streaming._api_safe_message_positions(messages)
indices = [idx for idx, _ in positions]
assert 1 in indices, (
"Non-empty _partial must be in API-safe positions"
)
# ── Fix 4: _merge_display_messages_after_agent_result dedup ────────────
class TestMergeDisplayDedupPartials:
def test_duplicate_empty_partials_deduped_in_merge(self):
"""Multiple identical _partial messages in previous_display must be
deduped — only the last occurrence should survive."""
previous_display = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': '', '_partial': True, 'reasoning': 'thinking...'},
{'role': 'assistant', 'content': '', '_partial': True, 'reasoning': 'thinking...'},
{'role': 'assistant', 'content': '', '_partial': True, 'reasoning': 'thinking...'},
{'role': 'assistant', 'content': '', '_partial': True, 'reasoning': 'thinking...'},
]
# Simulate what the merge does: result_messages is the new turn's output
result_messages = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': 'Here is the answer'},
]
previous_context = [
{'role': 'user', 'content': 'Hello'},
]
merged = streaming._merge_display_messages_after_agent_result(
previous_display, previous_context, result_messages, 'Hello'
)
# Count how many empty _partial messages survived
empty_partials = [m for m in merged
if isinstance(m, dict) and m.get('_partial')
and not str(m.get('content', '')).strip()]
assert len(empty_partials) <= 1, (
f"Expected at most 1 empty _partial after dedup, got {len(empty_partials)}"
)
def test_different_reasoning_partials_not_deduped(self):
"""_partial messages with different reasoning must NOT be deduped."""
previous_display = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': '', '_partial': True, 'reasoning': 'Step A'},
{'role': 'assistant', 'content': '', '_partial': True, 'reasoning': 'Step B'},
]
result_messages = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': 'Answer'},
]
previous_context = [
{'role': 'user', 'content': 'Hello'},
]
merged = streaming._merge_display_messages_after_agent_result(
previous_display, previous_context, result_messages, 'Hello'
)
empty_partials = [m for m in merged
if isinstance(m, dict) and m.get('_partial')
and not str(m.get('content', '')).strip()]
assert len(empty_partials) == 2, (
f"Different-reasoning partials must not be deduped, expected 2 got {len(empty_partials)}"
)
def test_nonempty_partials_not_deduped_by_this_path(self):
"""Non-empty _partial messages are handled by the merge's normal
_message_identity dedup — the backward-scan only targets empty ones."""
previous_display = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': 'Part 1', '_partial': True},
{'role': 'assistant', 'content': 'Part 2', '_partial': True},
]
result_messages = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': 'Full answer'},
]
previous_context = [
{'role': 'user', 'content': 'Hello'},
]
merged = streaming._merge_display_messages_after_agent_result(
previous_display, previous_context, result_messages, 'Hello'
)
nonempty_partials = [m for m in merged
if isinstance(m, dict) and m.get('_partial')
and str(m.get('content', '')).strip()]
# Non-empty partials are handled by normal merge dedup, but the
# backward-scan is a no-op for them (they have non-None identity)
# and the merge's seen set handles them normally
assert len(nonempty_partials) >= 0 # just must not crash
def test_massive_bloat_deduped(self):
"""Simulate the actual bug: 1000 identical empty _partial messages
from the exponential doubling must collapse to 1."""
previous_display = [
{'role': 'user', 'content': 'Hello'},
] + [
{'role': 'assistant', 'content': '', '_partial': True, 'reasoning': 'think'}
for _ in range(1000)
]
result_messages = [
{'role': 'user', 'content': 'Hello'},
{'role': 'assistant', 'content': 'Done'},
]
previous_context = [
{'role': 'user', 'content': 'Hello'},
]
merged = streaming._merge_display_messages_after_agent_result(
previous_display, previous_context, result_messages, 'Hello'
)
empty_partials = [m for m in merged
if isinstance(m, dict) and m.get('_partial')
and not str(m.get('content', '')).strip()]
assert len(empty_partials) <= 1, (
f"1000 identical empty partials must collapse to ≤1, got {len(empty_partials)}"
)
+87 -2
View File
@@ -10,7 +10,7 @@ def read(path: str) -> str:
class _FakeManifest:
def __init__(self, *, name, key, version="", description="", provides_hooks=None, path=None):
def __init__(self, *, name, key, version="", description="", provides_hooks=None, path=None, kind="standalone"):
self.name = name
self.key = key
self.version = version
@@ -18,7 +18,7 @@ class _FakeManifest:
self.provides_hooks = provides_hooks or []
self.path = path
self.source = "user"
self.kind = "standalone"
self.kind = kind
class _FakeLoadedPlugin:
@@ -86,6 +86,8 @@ class TestPluginsApi:
"version": "1.2.3",
"description": "Blocks unsafe tool calls",
"enabled": True,
"kind": "standalone",
"activation": "enabled",
"hooks": ["pre_tool_call", "post_tool_call"],
}]
serialized = repr(payload)
@@ -128,9 +130,70 @@ class TestPluginsApi:
plugin = payload["plugins"][0]
assert plugin["hooks"] == ["pre_llm_call", "post_llm_call"]
assert plugin["enabled"] is False
assert plugin["kind"] == "standalone"
assert plugin["activation"] == "disabled"
assert "/tmp/not-a-hook" not in repr(payload)
assert "/secret" not in repr(payload)
def test_api_plugins_marks_exclusive_plugins_as_active_provider(self):
# Exclusive plugins (e.g. memory.provider: noema) leave loaded.enabled
# False by design — the bundled discovery scanner records the manifest
# but defers loading to the category's own activation path. Without
# the `activation`/`kind` fields, the panel rendered them as
# "Disabled + no hooks" indistinguishable from broken plugins (#2659).
manager = _FakePluginManager({
"noema": _FakeLoadedPlugin(
_FakeManifest(
name="noema",
key="noema",
version="0.1.0",
description="Structured memory backed by a Noema Cortex",
kind="exclusive",
),
enabled=False,
hooks_registered=[],
error="exclusive plugin — activate via <category>.provider config",
)
})
payload = self._capture_plugins_response(manager)
plugin = payload["plugins"][0]
assert plugin["kind"] == "exclusive"
assert plugin["activation"] == "exclusive"
# `enabled` stays False for back-compat with older WebUI clients that
# key off it directly; new clients must read `activation`.
assert plugin["enabled"] is False
assert plugin["hooks"] == []
# The raw error string is intentionally NOT surfaced — it can contain
# filesystem paths or other internals on other plugin kinds.
assert "exclusive plugin" not in repr(payload)
def test_api_plugins_marks_active_model_provider(self):
# model-provider plugins that loaded successfully (loaded.enabled True)
# get the "provider" activation badge so the panel can distinguish
# them from standalone hook plugins.
manager = _FakePluginManager({
"openrouter": _FakeLoadedPlugin(
_FakeManifest(
name="openrouter",
key="openrouter",
version="1.0.0",
description="OpenRouter model provider",
kind="model-provider",
),
enabled=True,
hooks_registered=[],
)
})
payload = self._capture_plugins_response(manager)
plugin = payload["plugins"][0]
assert plugin["kind"] == "model-provider"
assert plugin["activation"] == "provider"
assert plugin["enabled"] is True
class TestPluginsSettingsUi:
def test_settings_sidebar_has_plugins_section(self):
@@ -159,3 +222,25 @@ class TestPluginsSettingsUi:
segment = js[js.find("function _buildPluginCard"):js.find("// ── Providers panel")]
assert ".path" not in segment
assert ".callback" not in segment
def test_plugins_panel_renders_active_provider_badge(self):
# The card must distinguish exclusive/provider activation from a plain
# "Enabled" state and use the dedicated empty-hooks message for
# provider plugins instead of "No registered lifecycle hooks" (#2659).
js = read("static/panels.js")
segment = js[js.find("function _buildPluginCard"):js.find("// ── Providers panel")]
assert "plugin.activation" in segment
assert "'exclusive'" in segment
assert "'provider'" in segment
assert "plugins_active_provider" in segment
assert "plugins_provider_no_hooks" in segment
# Graceful fallback when the older payload shape (no `activation` field)
# is returned — the card should still resolve a badge from `enabled`.
assert "plugin.enabled===false" in segment
def test_plugins_panel_i18n_strings_present(self):
i18n = read("static/i18n.js")
assert "plugins_active_provider:" in i18n
assert "plugins_provider_no_hooks:" in i18n
+72 -11
View File
@@ -4,8 +4,8 @@ Tests for profile-switch UX improvements — spinner indicator + parallelized fe
Two changes:
1. switchToProfile() shows a spinner on the profile chip during the async switch,
with an optimistic name update and error revert.
2. populateModelDropdown() and loadWorkspaceList() are now parallelized via Promise.all
instead of sequential awaits.
2. loadWorkspaceList() refreshes in the background and the model catalog is
invalidated for lazy refresh instead of holding the visible switch animation open.
"""
import re
from pathlib import Path
@@ -79,7 +79,7 @@ class TestProfileSwitchSpinner:
class TestParallelizedFetches:
"""Verify that model and workspace fetches are parallelized."""
"""Verify that background refresh work does not block visible profile switching."""
JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
@@ -95,11 +95,15 @@ class TestParallelizedFetches:
return self.JS[idx: i + 1]
raise AssertionError("Could not extract switchToProfile")
def test_populate_and_workspace_in_promise_all(self):
"""Both fetches must be inside Promise.all([...])."""
def test_workspace_refresh_in_background_and_model_catalog_lazy(self):
"""Workspace refresh should run behind completion; model catalog waits for picker open."""
fn = self._get_switch_fn()
assert "Promise.all([populateModelDropdown(), loadWorkspaceList()])" in fn, (
"populateModelDropdown() and loadWorkspaceList() are not parallelized."
assert "_refreshProfileSwitchBackground(_switchGen)" in fn, (
"switchToProfile() must schedule non-visible refreshes after the switch."
)
assert "window._modelDropdownReady=null" in self.JS
assert "await Promise.all([populateModelDropdown(), loadWorkspaceList()])" not in fn, (
"Profile switching still awaits full model/workspace catalog refreshes."
)
def test_no_sequential_await_pattern(self):
@@ -114,14 +118,24 @@ class TestParallelizedFetches:
)
def test_apply_steps_after_promise_all(self):
"""Model apply step must come after Promise.all resolves."""
"""Model defaults must apply before background catalog refresh starts."""
fn = self._get_switch_fn()
promise_all_idx = fn.find("await Promise.all(")
background_idx = fn.find("_refreshProfileSwitchBackground(_switchGen)")
apply_model_idx = fn.find("S._pendingProfileModel = modelToUse")
assert apply_model_idx != -1
assert apply_model_idx > promise_all_idx, (
"Model apply step must come AFTER Promise.all resolves."
assert background_idx != -1
assert apply_model_idx < background_idx, (
"Model defaults must apply before background refresh starts."
)
assert "existingDefaultOpt.dataset.provider = providerId" in fn
def test_workspace_load_is_awaited_only_when_visible(self):
"""Profile switches should not duplicate workspace-tree loads."""
fn = self._get_switch_fn()
assert "awaitWorkspaceLoad: workspaceVisible" in fn
sessions_js = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
assert "if(options&&options.awaitWorkspaceLoad) await dirLoad" in sessions_js
assert "loadDir('.') is fire-and-forget while the workspace panel is closed" in sessions_js
class TestSpinnerCss:
@@ -143,3 +157,50 @@ class TestSpinnerCss:
assert idx != -1
block = self.CSS[idx: idx + 200]
assert "pointer-events:none" in block
class TestProfileSessionListFlip:
"""Verify session-list refreshes use row-level FLIP motion."""
JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8")
def test_profile_refresh_captures_row_positions(self):
assert "function _captureSessionListFlipPositions()" in self.JS
start = self.JS.index("function _captureSessionListFlipPositions()")
end = self.JS.index("function _sessionListPrefersReducedMotion()", start)
fn = self.JS[start:end]
assert "querySelectorAll('.session-item[data-sid]')" in fn
assert "positions.set(row.dataset.sid,row.getBoundingClientRect().top);" in fn
def test_profile_refresh_reflows_existing_rows(self):
assert "function _playSessionListFlipAnimation(before)" in self.JS
start = self.JS.index("function _playSessionListFlipAnimation(before)")
end = self.JS.index("function _isOptimisticFirstTurnSessionRow(s)", start)
fn = self.JS[start:end]
assert "const delta=oldTop-row.getBoundingClientRect().top;" in fn
assert "row.style.setProperty('--session-reflow-offset',delta+'px');" in fn
assert "row.classList.add('session-reflowing');" in fn
def test_profile_refresh_flips_new_rows(self):
assert "session-list-flip-enter" in self.JS
assert "@keyframes sessionListFlipIn" in self.CSS
assert "rotateX" in self.CSS
def test_profile_refresh_captures_before_render_and_plays_after_rows_exist(self):
capture = self.JS.index("const flipBefore=animateRefresh?_captureSessionListFlipPositions():null;")
clear = self.JS.index("list.innerHTML='';", capture)
row_render = self.JS.index("body.appendChild(_renderOneSession", clear)
play = self.JS.index("_playSessionListFlipAnimation(flipBefore);", row_render)
assert capture < clear < row_render < play
def test_first_non_empty_session_render_is_animated(self):
assert "_sessionListFirstRenderAnimated" in self.JS
assert "animateNextSessionListRefresh({enterAll:true});" in self.JS
assert "_sessionListFirstRenderAnimated=true;" in self.JS
assert "if(S&&S._bootReady) _sessionListFirstRenderAnimated=true;" not in self.JS
assert "enterAllAnimatedRows" in self.JS
def test_profile_refresh_is_not_whole_list_fade(self):
assert "session-list.profile-refresh" not in self.CSS
+6 -3
View File
@@ -1171,10 +1171,13 @@ class TestFrontendModelProviderState:
assert "_modelStateForSelect" in src
assert "model_provider:modelState.model_provider||null" in src
def test_new_session_sends_model_provider(self):
def test_new_session_lets_profile_config_choose_default_model_provider(self):
src = _read("static/sessions.js")
assert "_modelStateForSelect(modelSel,selectedDefaultModel)" in src
assert "model_provider:newModelState.model_provider||null" in src
start = src.index("async function newSession(")
body = src[start:src.index("const data=await api('/api/session/new'", start)]
assert "profile:S.activeProfile||'default'" in body
assert "model:newModelState.model" not in body
assert "model_provider:newModelState.model_provider||null" not in body
def test_ui_has_json_model_state_storage(self):
src = _read("static/ui.js")
+253
View File
@@ -0,0 +1,253 @@
"""Tests for provider/model-picker sort ordering.
Feature: configured and custom providers float to the top in both
the Settings provider list and the model picker dropdown.
Sort tiers (both endpoints):
0 — active provider
1 — custom:* providers
2 — providers with a configured key (credential pool or config.yaml api_key)
3 — everyone else, alphabetically by provider id
"""
from __future__ import annotations
import sys
import types
from unittest import mock
import pytest
import api.config as config
import api.providers as providers_mod
# ---------------------------------------------------------------------------
# Helpers — lightweight stubs so we don't need a real hermes-agent install
# ---------------------------------------------------------------------------
def _install_fake_hermes_cli(monkeypatch):
"""Stub hermes_cli so detection is deterministic in tests."""
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models.list_available_providers = lambda: []
fake_models.provider_model_ids = lambda pid: []
fake_auth = types.ModuleType("hermes_cli.auth")
fake_auth.get_auth_status = lambda _pid: {}
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)
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
monkeypatch.delitem(sys.modules, "agent", raising=False)
config.invalidate_models_cache()
@pytest.fixture(autouse=True)
def _isolate_cache():
"""Invalidate the TTL model cache around each test."""
try:
config.invalidate_models_cache()
except Exception:
pass
yield
try:
config.invalidate_models_cache()
except Exception:
pass
def _model_list(*ids):
"""Helper: build [{"id": x, "label": x}, ...] for _PROVIDER_MODELS."""
return [{"id": m, "label": m} for m in ids]
def _setup_config(tmp_path, monkeypatch, yaml_text, provider_models=None):
"""Write config.yaml, point config module at it, install stubs."""
_install_fake_hermes_cli(monkeypatch)
cfgfile = tmp_path / "config.yaml"
cfgfile.write_text(yaml_text, encoding="utf-8")
monkeypatch.setattr(config, "_get_config_path", lambda: cfgfile)
# Point auth store at a non-existent file so it stays empty
auth_path = tmp_path / "auth.json"
monkeypatch.setattr(config, "_get_auth_store_path", lambda: auth_path)
# Inject provider models if given
if provider_models:
for pid, models in provider_models.items():
monkeypatch.setitem(config._PROVIDER_MODELS, pid, models)
config.reload_config()
def _teardown_config():
config.reload_config()
# ===================================================================
# get_providers() sort order (api/providers.py)
# ===================================================================
class TestProviderSortOrder:
"""providers returned by get_providers() should respect tier ordering."""
def test_active_provider_comes_first(self, tmp_path, monkeypatch):
"""The active provider (from config model.provider) is tier-0."""
_setup_config(tmp_path, monkeypatch,
"model:\n provider: openrouter\n default: test-model\n"
"providers:\n"
" anthropic:\n api_key: sk-test-123\n"
" openai: {}\n"
" openrouter:\n api_key: sk-or-123\n"
)
result = providers_mod.get_providers()
prov_ids = [p["id"] for p in result["providers"]]
assert prov_ids[0] == "openrouter", (
f"Expected openrouter first (active), got order: {prov_ids}"
)
_teardown_config()
def test_custom_provider_before_plain(self, tmp_path, monkeypatch):
"""custom:* providers (tier-1) sort before providers with keys (tier-2)."""
_setup_config(tmp_path, monkeypatch,
"model:\n provider: openai\n default: gpt-4\n"
"providers:\n"
" openai: {}\n"
" anthropic:\n api_key: sk-ant-123\n"
"custom_providers:\n"
" - name: MyLocal\n"
" base_url: http://localhost:8080/v1\n"
" api_key: local-key\n"
" models:\n"
" - local-model-a\n"
)
result = providers_mod.get_providers()
prov_ids = [p["id"] for p in result["providers"]]
openai_idx = prov_ids.index("openai")
custom_idx = prov_ids.index("custom:mylocal")
anthropic_idx = prov_ids.index("anthropic")
assert openai_idx < custom_idx < anthropic_idx, (
f"Expected openai < custom:mylocal < anthropic, got {prov_ids}"
)
_teardown_config()
def test_has_key_provider_before_no_key(self, tmp_path, monkeypatch):
"""Providers with keys (tier-2) come before those without (tier-3)."""
_setup_config(tmp_path, monkeypatch,
"model:\n provider: openai\n default: gpt-4\n"
"providers:\n"
" openai: {}\n"
" deepseek:\n api_key: sk-ds-123\n"
" google: {}\n"
" groq: {}\n"
)
result = providers_mod.get_providers()
prov_ids = [p["id"] for p in result["providers"]]
# deepseek has api_key in config → should be tier-2 (before tier-3)
deepseek_idx = prov_ids.index("deepseek")
google_idx = prov_ids.index("google")
groq_idx = prov_ids.index("groq")
assert deepseek_idx < google_idx, (
f"deepseek(has_key) at {deepseek_idx} should be before google at {google_idx}"
)
assert deepseek_idx < groq_idx, (
f"deepseek(has_key) at {deepseek_idx} should be before groq at {groq_idx}"
)
_teardown_config()
# ===================================================================
# get_available_models() sort order (api/config.py)
# ===================================================================
class TestModelPickerSortOrder:
"""Model picker groups should follow the same tier ordering."""
def test_active_provider_group_is_first(self, tmp_path, monkeypatch):
"""The group for the active provider appears first in groups list."""
_setup_config(tmp_path, monkeypatch,
"model:\n provider: anthropic\n default: claude-3-5-sonnet\n"
"providers:\n"
" openai:\n api_key: sk-oai-123\n"
" anthropic: {}\n",
provider_models={
"openai": _model_list("gpt-4", "gpt-4o"),
"anthropic": _model_list("claude-3-5-sonnet"),
},
)
result = config.get_available_models()
group_ids = [g.get("provider_id") for g in result.get("groups", [])]
assert group_ids[0] == "anthropic", (
f"Expected anthropic first (active), got: {group_ids}"
)
_teardown_config()
def test_custom_groups_before_configured(self, tmp_path, monkeypatch):
"""custom:* groups sort before providers that merely have keys."""
_setup_config(tmp_path, monkeypatch,
"model:\n provider: openai\n default: gpt-4\n"
"providers:\n"
" openai:\n api_key: sk-oai-123\n"
"custom_providers:\n"
" - name: MyLocal\n"
" base_url: http://localhost:8080/v1\n"
" api_key: local-key\n"
" models:\n"
" - local-a\n",
provider_models={
"openai": _model_list("gpt-4"),
},
)
result = config.get_available_models()
group_ids = [g.get("provider_id") for g in result.get("groups", [])]
if "custom:mylocal" in group_ids:
custom_idx = group_ids.index("custom:mylocal")
for i, gid in enumerate(group_ids):
if gid not in ("openai", "custom:mylocal"):
assert custom_idx < i, (
f"custom:mylocal at {custom_idx} should precede {gid} at {i}"
)
_teardown_config()
def test_configured_key_groups_before_no_key(self, tmp_path, monkeypatch):
"""Providers with api_key in config sort before those without."""
_setup_config(tmp_path, monkeypatch,
"model:\n provider: openai\n default: gpt-4\n"
"providers:\n"
" openai:\n api_key: sk-oai-123\n"
" deepseek:\n api_key: sk-ds-123\n"
" google: {}\n",
provider_models={
"openai": _model_list("gpt-4"),
"deepseek": _model_list("deepseek-chat"),
"google": _model_list("gemini-pro"),
},
)
result = config.get_available_models()
group_ids = [g.get("provider_id") for g in result.get("groups", [])]
if "deepseek" in group_ids and "google" in group_ids:
ds_idx = group_ids.index("deepseek")
google_idx = group_ids.index("google")
assert ds_idx < google_idx, (
f"deepseek (has key) at {ds_idx} should precede google (no key) at {google_idx}"
)
_teardown_config()
+17
View File
@@ -650,6 +650,23 @@ def test_loadSession_inflight_merges_tail_with_persisted_transcript(cleanup_test
)
def test_inflight_merge_dedupes_uploaded_user_message(cleanup_test_sessions):
"""Uploaded-file turns render optimistically before the server stores the
final pending text with an `[Attached files: ...]` suffix. The INFLIGHT
merge must treat those as the same user turn instead of rendering both.
"""
src = (REPO_ROOT / "static/sessions.js").read_text()
assert "function _stripAttachedFilesMarker" in src, (
"sessions.js must normalize the server-side attached-files suffix before deduping user turns"
)
assert "_stripAttachedFilesMarker(aText)===_stripAttachedFilesMarker(bText)" in src, (
"INFLIGHT user-message comparison should dedupe optimistic upload text against final pending text"
)
assert "role==='user'" in src, (
"attached-files normalization should be limited to user turns"
)
def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_cards(cleanup_test_sessions):
"""#1715: returning to an active chat must replay persisted tool cards.
+88
View File
@@ -26,9 +26,65 @@ def test_runtime_adapter_interface_and_legacy_journal_methods_exist():
assert runtime.runtime_adapter_enabled({}) is False
assert runtime.runtime_adapter_mode({"HERMES_WEBUI_RUNTIME_ADAPTER": "legacy-journal"}) == "legacy-journal"
assert runtime.runtime_adapter_enabled({"HERMES_WEBUI_RUNTIME_ADAPTER": "legacy-journal"}) is True
assert runtime.runtime_adapter_mode({"HERMES_WEBUI_RUNTIME_ADAPTER": "runner-local"}) == "runner-local"
assert runtime.runtime_adapter_runner_enabled({"HERMES_WEBUI_RUNTIME_ADAPTER": "runner-local"}) is True
assert runtime.runtime_adapter_mode({"HERMES_WEBUI_RUNTIME_ADAPTER": "sidecar"}) == "legacy-direct"
def test_runtime_adapter_factory_selects_only_explicit_default_off_modes():
runtime = importlib.import_module("api.runtime_adapter")
calls = []
class FakeRunnerClient:
pass
def legacy_factory():
calls.append("legacy")
return runtime.LegacyJournalRuntimeAdapter(start_run_delegate=lambda request: {"stream_id": "s"})
def runner_factory():
calls.append("runner")
return FakeRunnerClient()
assert runtime.build_runtime_adapter(environ={}) is None
legacy = runtime.build_runtime_adapter(
environ={"HERMES_WEBUI_RUNTIME_ADAPTER": "legacy-journal"},
legacy_adapter_factory=legacy_factory,
runner_client_factory=runner_factory,
)
assert isinstance(legacy, runtime.LegacyJournalRuntimeAdapter)
runner = runtime.build_runtime_adapter(
environ={"HERMES_WEBUI_RUNTIME_ADAPTER": "runner-local"},
legacy_adapter_factory=legacy_factory,
runner_client_factory=runner_factory,
)
assert isinstance(runner, runtime.RunnerRuntimeAdapter)
assert calls == ["legacy", "runner"]
def test_runner_local_factory_requires_injected_client_and_does_not_fallback_to_legacy():
runtime = importlib.import_module("api.runtime_adapter")
calls = []
def legacy_factory():
calls.append("legacy")
return runtime.LegacyJournalRuntimeAdapter(start_run_delegate=lambda request: {"stream_id": "s"})
try:
runtime.build_runtime_adapter(
environ={"HERMES_WEBUI_RUNTIME_ADAPTER": "runner-local"},
legacy_adapter_factory=legacy_factory,
)
except NotImplementedError as exc:
assert "runner client factory" in str(exc)
else:
raise AssertionError("runner-local must require an injected runner client factory")
assert calls == []
def test_legacy_journal_adapter_start_run_delegates_without_owning_runtime_state():
runtime = importlib.import_module("api.runtime_adapter")
calls = []
@@ -404,10 +460,42 @@ def test_rfc_defines_slice4_runner_contract_before_runner_code():
assert "no removal of the legacy in-process backend" in rfc
assert "no default-on runner mode" in rfc
assert "#### Slice 4b: Runner adapter client facade" in rfc
assert "Status as of 2026-05-20: shipped in v0.51.94 via #2599" in rfc
assert "delegates to an injected runner client" in rfc
assert "without relying on process-local `STREAMS`" in rfc
def test_rfc_defines_slice4c_runner_backend_harness_gate():
routes = importlib.import_module("api.routes")
rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8")
assert "#### Slice 4c: Feature-flagged runner backend and restart/reattach harness" in rfc
assert "Status as of 2026-05-21: shipped in v0.51.105 via #2696" in rfc
assert "`HERMES_WEBUI_RUNTIME_ADAPTER=runner-local`" in rfc
assert "`legacy-direct` remains the default" in rfc
assert "No route-shape drift" in rfc
assert "Restart/reattach harness" in rfc
assert "discard the first WebUI adapter instance" in rfc
assert "No runtime-surrogate globals" in rfc
assert "no live chat route switch to the runner backend before the restart/reattach" in rfc
def test_rfc_defines_slice4d_supervised_runner_route_gate():
routes = importlib.import_module("api.routes")
rfc = (routes.Path(__file__).parent.parent / "docs" / "rfcs" / "hermes-run-adapter-contract.md").read_text(encoding="utf-8")
assert "#### Slice 4d: Supervised runner backend route gate" in rfc
assert "After `runner-local` selection exists" in rfc
assert "route-selection harness before live\nbrowser chat can use it" in rfc
assert "Route remains default-off" in rfc
assert "Restart/reattach harness proves ownership moved" in rfc
assert "No public response-shape drift" in rfc
assert "No runtime-surrogate globals" in rfc
assert "Explicit context payloads" in rfc
assert "active-run discovery, session-to-run lookup, command capability\n metadata, artifact events, and provider/tool routing" in rfc
assert "WebUI remains the rich workbench while\n only execution ownership moves" in rfc
def test_runner_runtime_adapter_passes_explicit_start_payload_without_env_mutation(monkeypatch):
runtime = importlib.import_module("api.runtime_adapter")
captured = []
@@ -234,6 +234,7 @@ def test_webui_installs_profile_context_on_in_process_scheduler_run_job(tmp_path
monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler)
monkeypatch.setattr(p, "_DEFAULT_HERMES_HOME", default_home)
monkeypatch.setattr(p, "cron_profile_context_for_home", Ctx)
monkeypatch.setattr(p, "publish_session_list_changed", lambda reason: events.append(("publish", reason)))
p.install_cron_scheduler_profile_isolation()
@@ -242,6 +243,7 @@ def test_webui_installs_profile_context_on_in_process_scheduler_run_job(tmp_path
("enter", str(research_home)),
("run", "job1575"),
("exit", str(research_home)),
("publish", "cron_complete"),
]
@@ -279,6 +281,7 @@ def test_scheduler_run_job_wrapper_does_not_reenter_manual_cron_context(tmp_path
monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler)
monkeypatch.setattr(p, "_DEFAULT_HERMES_HOME", tmp_path / "home")
monkeypatch.setattr(p, "cron_profile_context_for_home", Ctx)
monkeypatch.setattr(p, "publish_session_list_changed", lambda reason: events.append(("unexpected-publish", reason)))
monkeypatch.setattr(p._tls, "cron_profile_depth", 1, raising=False)
p.install_cron_scheduler_profile_isolation()
@@ -0,0 +1,31 @@
"""Regression checks for per-conversation action menu click stability."""
from pathlib import Path
SESSIONS_JS = (Path(__file__).resolve().parent.parent / "static" / "sessions.js").read_text(encoding="utf-8")
def _function_block(src: str, name: str) -> str:
marker = f"function {name}"
start = src.find(marker)
assert start != -1, f"{name} not found"
brace = src.find("{", start)
assert brace != -1, f"{name} body not found"
depth = 1
i = brace + 1
while i < len(src) and depth:
if src[i] == "{":
depth += 1
elif src[i] == "}":
depth -= 1
i += 1
assert depth == 0, f"{name} body did not close"
return src[start:i]
def test_session_list_refresh_does_not_close_open_conversation_actions():
"""Sidebar refreshes must not eat the three-dot menu before users can click it."""
body = _function_block(SESSIONS_JS, "renderSessionListFromCache")
assert "if(_renamingSid) return;" in body
assert "if(_sessionActionMenu) return;" in body
assert body.index("if(_sessionActionMenu) return;") < body.index("closeSessionActionMenu();")
+51
View File
@@ -0,0 +1,51 @@
from pathlib import Path
ROUTES = Path("api/routes.py").read_text(encoding="utf-8")
SESSION_EVENTS = Path("api/session_events.py").read_text(encoding="utf-8")
PROFILES = Path("api/profiles.py").read_text(encoding="utf-8")
def test_session_events_endpoint_and_bus_are_defined():
assert "_SESSION_EVENTS_SUBSCRIBERS" in SESSION_EVENTS
assert "def publish_session_list_changed" in SESSION_EVENTS
assert "def _handle_session_events_stream" in ROUTES
assert "parsed.path == '/api/sessions/events'" in ROUTES
assert "Content-Type', 'text/event-stream; charset=utf-8'" in ROUTES
def test_session_events_publish_for_minimal_sidebar_mutations():
for reason in (
"session_new",
"session_delete",
"session_duplicate",
"session_import",
"session_import_cli",
"session_archive",
"session_move",
"session_pin",
"session_rename",
):
assert f'publish_session_list_changed("{reason}")' in ROUTES
assert 'if worktree_info:\n publish_session_list_changed("session_new")' in ROUTES
assert "was_hidden_empty_session = _is_hidden_empty_session(s)" in ROUTES
assert 'if was_hidden_empty_session:\n publish_session_list_changed("session_new")' in ROUTES
assert 'publish_session_list_changed("chat_start")' not in ROUTES
assert 'publish_session_list_changed("cron_complete")' in ROUTES
assert 'publish_session_list_changed("cron_complete")' in PROFILES
def test_session_event_queue_is_bounded_and_latest_wins():
from api import session_events
q = session_events.subscribe_session_events()
try:
session_events.publish_session_list_changed("first")
session_events.publish_session_list_changed("second")
payload = q.get_nowait()
assert payload["type"] == "sessions_changed"
assert payload["reason"] == "second"
assert q.empty()
finally:
session_events.unsubscribe_session_events(q)
@@ -0,0 +1,204 @@
"""HTTP-level integration coverage for the /api/sessions/events SSE endpoint.
The pure-bus tests in test_session_events.py cover the in-process publish/subscribe
contract but never open an actual HTTP connection — they don't exercise the SSE
handler's unsubscribe-on-disconnect path, which is the entire safety contract for
the new event bus (without it, server threads leak queues until process exit).
These tests open a real connection to the test server, drive the bus by triggering
side-effects from a separate thread (creating a session via /api/session/new), and
assert the SSE frame arrives. The test-server subprocess has its own subscriber
pool, so we can only test the disconnect-without-crash path here, not the in-pytest
subscriber count. Added in stage-393 follow-up to Opus advisor's blocking concern
on PR #2637.
"""
import json
import threading
import time
import urllib.error
import urllib.request
from tests._pytest_port import BASE
def _read_sse_frames(resp, deadline):
"""Read raw SSE frames from a streaming response until deadline.
Yields ('event_name', payload_str) tuples. Treats `: keepalive` lines as
keepalives (returned as ('keepalive', None)). Returns when the deadline
elapses or the connection closes.
"""
event_type = None
data_lines = []
while time.time() < deadline:
line = resp.readline()
if not line:
time.sleep(0.05)
continue
decoded = line.decode("utf-8", errors="replace").rstrip("\r\n")
if decoded == "":
if data_lines:
yield (event_type or "message", "\n".join(data_lines))
event_type = None
data_lines = []
continue
if decoded.startswith("event: "):
event_type = decoded[7:].strip()
elif decoded.startswith("data: "):
data_lines.append(decoded[6:])
elif decoded.startswith(": "):
yield ("keepalive", None)
def _subscriber_count():
"""Read the live subscriber count from this pytest process's bus module."""
from api import session_events
with session_events._SESSION_EVENTS_LOCK:
return len(session_events._SESSION_EVENTS_SUBSCRIBERS)
def test_session_events_bus_subscribe_unsubscribe_balance():
"""
Pure-bus contract: subscribe → publish → unsubscribe leaves the subscriber
set at the baseline. This is the safety invariant the SSE handler's
finally block depends on.
"""
from api import session_events
baseline = _subscriber_count()
q = session_events.subscribe_session_events()
try:
assert _subscriber_count() == baseline + 1
session_events.publish_session_list_changed("integration_test")
payload = q.get(timeout=2.0)
assert payload["type"] == "sessions_changed"
assert payload["reason"] == "integration_test"
finally:
session_events.unsubscribe_session_events(q)
assert _subscriber_count() == baseline
def test_session_events_sse_endpoint_handshake_succeeds(cleanup_test_sessions):
"""
Open a real HTTP connection to /api/sessions/events on the test server.
Verify the handshake succeeds (200 + text/event-stream Content-Type) and
we can read at least one event — either a sessions_changed event triggered
by a side-effect we cause (POST /api/session/new) or a keepalive after the
heartbeat interval. Then close the socket; a leaking handler would not
crash the server but would tie up a thread + queue indefinitely.
"""
url = BASE + "/api/sessions/events"
req = urllib.request.Request(url, headers={"Accept": "text/event-stream"})
try:
resp = urllib.request.urlopen(req, timeout=10)
except urllib.error.HTTPError as e:
raise AssertionError(f"SSE endpoint returned HTTP {e.code}: {e.read()[:200]!r}")
try:
assert resp.status == 200
ct = resp.headers.get("Content-Type", "")
assert "text/event-stream" in ct, f"unexpected Content-Type: {ct!r}"
# Cause a side-effect that should publish an event: create a session.
# The publish runs in the server process, so the subscribed queue on
# the server side gets the frame. We must give the SSE thread time to
# subscribe before publishing — do the POST on a small delay thread.
def trigger():
time.sleep(0.5)
try:
payload = json.dumps({"title": "sse-test"}).encode()
req2 = urllib.request.Request(
BASE + "/api/session/new",
data=payload,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req2, timeout=5) as r2:
d = json.loads(r2.read())
sid = d.get("session", {}).get("session_id")
if sid:
cleanup_test_sessions.append(sid)
except Exception:
pass
threading.Thread(target=trigger, daemon=True).start()
deadline = time.time() + 8.0 # heartbeat is 5s; we should see an event well before that or a keepalive after
got_real_event = False
got_keepalive = False
for kind, data in _read_sse_frames(resp, deadline):
if kind == "sessions_changed":
got_real_event = True
try:
payload = json.loads(data)
assert payload.get("type") == "sessions_changed"
except (ValueError, AssertionError):
pass
break
elif kind == "keepalive":
got_keepalive = True
break
assert got_real_event or got_keepalive, (
"expected either a sessions_changed event from the triggered POST or "
"a keepalive frame within 8s"
)
finally:
resp.close()
def test_session_events_sse_endpoint_survives_rapid_disconnect():
"""
Open and immediately close several SSE connections back-to-back. The
handler must not leak threads/queues — the server should stay responsive
for a subsequent regular /api/sessions GET.
"""
url = BASE + "/api/sessions/events"
for _ in range(5):
req = urllib.request.Request(url, headers={"Accept": "text/event-stream"})
try:
resp = urllib.request.urlopen(req, timeout=10)
try:
# Read a single byte to confirm the stream opened, then close.
resp.read(1)
finally:
resp.close()
except urllib.error.HTTPError as e:
raise AssertionError(f"connection iteration failed: HTTP {e.code}")
time.sleep(0.1)
# Server still serves a normal GET after the rapid open/close burst.
sentinel_req = urllib.request.Request(
BASE + "/api/sessions", headers={"Accept": "application/json"}
)
try:
with urllib.request.urlopen(sentinel_req, timeout=5) as r:
assert r.status == 200
body = json.loads(r.read())
assert isinstance(body, dict)
except urllib.error.HTTPError as e:
raise AssertionError(f"server unresponsive after SSE burst: HTTP {e.code}")
def test_session_events_handler_uses_disconnect_safe_errors_tuple():
"""
Source-level guard that the handler's except clause references the shared
_CLIENT_DISCONNECT_ERRORS tuple defined in api/routes.py. This is the
safety mechanism that makes the unsubscribe-on-disconnect path actually
fire across the full range of socket-failure modes (not just BrokenPipeError).
"""
from pathlib import Path
routes = Path("api/routes.py").read_text(encoding="utf-8")
handler_start = routes.find("def _handle_session_events_stream")
assert handler_start >= 0, "_handle_session_events_stream not found"
handler_end = routes.find("\ndef ", handler_start + 1)
if handler_end < 0:
handler_end = len(routes)
handler_body = routes[handler_start:handler_end]
assert "_CLIENT_DISCONNECT_ERRORS" in handler_body, (
"handler must catch the shared disconnect tuple to trigger unsubscribe"
)
assert "unsubscribe_session_events" in handler_body, (
"handler must unsubscribe in the cleanup path"
)
assert "finally:" in handler_body, "handler must use a finally block for cleanup"
@@ -0,0 +1,459 @@
"""Regression: lazy-retry run-journal recovery across multiple session reads.
The scenario this test pins down:
1. A WebUI process restarts mid-stream. On the first sidecar repair attempt
the run-journal for the dead stream is NOT visible yet (page-cache loss,
un-fsynced writes, slow network FS, etc.) so
`_append_journaled_partial_output` returns False.
2. Pre-fix the repair path baked a permanent "no agent output was recovered"
marker into the session and never looked at the journal again — even
after the journaled tokens appeared on disk on a later read.
3. With the fix, the repair instead leaves a `_pending_journal_recovery`
flag on the marker; the next `get_session()` call lazily re-runs the
recovery, promotes the marker wording, and threads the journaled
assistant text/tools into the transcript in the correct chronological
position.
"""
from concurrent.futures import ThreadPoolExecutor
import threading
import pytest
import api.models as models
import api.config as config
import api.profiles as profiles
import api.streaming as streaming # noqa: F401 imported for fixture parity
from api.models import (
Session,
_apply_core_sync_or_error_marker,
merge_session_messages_append_only,
)
from api.run_journal import append_run_event
# ── Fixtures (shape mirrors test_session_sidecar_repair.py) ────────────────
@pytest.fixture(autouse=True)
def _isolate_session_dir(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
models.SESSIONS.clear()
yield session_dir, index_file
models.SESSIONS.clear()
@pytest.fixture(autouse=True)
def _isolate_stream_state():
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.STREAM_PARTIAL_TEXT.clear()
yield
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.STREAM_PARTIAL_TEXT.clear()
@pytest.fixture(autouse=True)
def _isolate_agent_locks():
config.SESSION_AGENT_LOCKS.clear()
models._JOURNAL_RETRY_LOCKS.clear()
yield
config.SESSION_AGENT_LOCKS.clear()
models._JOURNAL_RETRY_LOCKS.clear()
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / "hermes_home"
home.mkdir()
(home / "sessions").mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(profiles, "_DEFAULT_HERMES_HOME", home)
return home
def _make_dead_stream_session(
session_id: str,
*,
stream_id: str,
existing_msgs_count: int = 96,
pending_text: str = (
"[IMPORTANT: Background process polling. "
"Continue the user's prior request.]"
),
):
"""Build a session that mirrors the production bug: lots of prior history,
pending_user_message set, an active_stream_id pointing at a dead stream,
and pending_started_at populated."""
messages = []
for i in range(existing_msgs_count // 2):
messages.append({"role": "user", "content": f"q{i}"})
messages.append({"role": "assistant", "content": f"a{i}"})
s = Session(session_id=session_id, title="Lost-response repro", messages=messages)
s.pending_user_message = pending_text
s.pending_started_at = 1779237637 # production-shaped value
s.active_stream_id = stream_id
return s
def _make_pending_retry_session(session_id: str, *, stream_id: str):
s = Session(session_id=session_id, title="Pending retry", messages=[
{"role": "user", "content": "q", "timestamp": 1},
{
"role": "assistant",
"content": models._INTERRUPTED_PENDING_RETRY_WORDING,
"timestamp": 2,
"_error": True,
"type": "interrupted",
"_pending_journal_recovery": True,
"_journal_retry_stream_id": stream_id,
"_journal_retry_attempts": 0,
"_journal_retry_first_seen_ts": int(models.time.time()),
},
])
s.save()
return s
def _assert_retry_meta_removed(marker):
assert "_pending_journal_recovery" not in marker
assert "_journal_retry_stream_id" not in marker
assert "_journal_retry_attempts" not in marker
assert "_journal_retry_first_seen_ts" not in marker
# ── The regression test ────────────────────────────────────────────────────
def test_state_db_prefix_with_float_timestamps_does_not_hide_sidecar_tail():
"""State rows replaying an already-visible prefix must not append after the tail.
Production shape: a compressed/tip sidecar can persist messages with
second-level timestamps while state.db stores the same early rows with
sub-second floats. The merge must preserve the sidecar assistant tail;
otherwise /api/session returns a transcript ending on an old user prompt.
"""
sidecar_messages = [
{"role": "user", "content": "plan", "timestamp": 1779309765},
{"role": "assistant", "content": "loaded plan", "timestamp": 1779309765},
{"role": "tool", "content": "skill output", "timestamp": 1779309765},
{"role": "assistant", "content": "answer before compaction", "timestamp": 1779309765},
{
"role": "user",
"content": (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into "
"the summary below. This is a handoff from a previous context window — "
"treat it as background reference, NOT as active instructions."
),
"timestamp": 1779309765,
},
{"role": "tool", "content": '{"success": true, "message": "Patched SKILL.md"}', "timestamp": 1779309765},
{"role": "user", "content": "noch weitere prs?", "timestamp": 1779309765},
{
"role": "assistant",
"content": "Ja, aber nicht als Sammel-PR",
"timestamp": 1779309765,
},
]
state_prefix = [
{"role": "user", "content": "plan", "timestamp": 1779344917.2780898},
{"role": "assistant", "content": "loaded plan", "timestamp": 1779344917.285758},
{"role": "tool", "content": "skill output", "timestamp": 1779344917.2926793},
{"role": "assistant", "content": "answer before compaction", "timestamp": 1779344917.3077576},
{
"role": "user",
"content": (
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted into "
"the summary below. This is a handoff from a previous context window."
),
"timestamp": 1779344917.299318,
},
{
"role": "tool",
"content": '{"success": true, "message": "Patched SKILL.md"}',
"timestamp": 1779344917.315237,
"tool_call_id": "different-state-tool-id",
},
{
"role": "user",
"content": "[Workspace::v1: /tmp/project-workspace]\nnoch weitere prs?",
"timestamp": 1779344917.3287876,
},
]
merged = merge_session_messages_append_only(sidecar_messages, state_prefix)
assert [m["content"] for m in merged] == [m["content"] for m in sidecar_messages]
assert merged[-1]["role"] == "assistant"
assert "Sammel-PR" in merged[-1]["content"]
def test_state_db_full_replay_does_not_append_after_sidecar_tail():
"""A full state.db replay must not leak the final replayed user after the sidecar tail.
Regression for a display merge where the sidecar already ends on the real
assistant answer, but state.db replays the same visible turn sequence with
newer float timestamps.
"""
sidecar_messages = [
{"role": "user", "content": "initial critique", "timestamp": 100},
{"role": "assistant", "content": "analysis", "timestamp": 100},
{"role": "user", "content": "Erstelle deine Version", "timestamp": 100},
{"role": "assistant", "content": "opened browser preview", "timestamp": 100},
]
state_replay = [
{"role": "user", "content": "initial critique", "timestamp": 100.1},
{"role": "assistant", "content": "analysis", "timestamp": 100.2},
{
"role": "user",
"content": "[Workspace::v1: /tmp/project-workspace]\nErstelle deine Version",
"timestamp": 100.3,
},
{"role": "assistant", "content": "opened browser preview", "timestamp": 100.4},
]
merged = merge_session_messages_append_only(sidecar_messages, state_replay)
assert [m["content"] for m in merged] == [m["content"] for m in sidecar_messages]
assert merged[-1]["role"] == "assistant"
assert merged[-1]["content"] == "opened browser preview"
def test_state_db_middle_segment_replay_does_not_append_after_sidecar_tail():
"""A replayed state.db segment from the middle must not be appended after the tail."""
sidecar_messages = [
{"role": "user", "content": "older setup", "timestamp": 100},
{"role": "assistant", "content": "older answer", "timestamp": 100},
{"role": "assistant", "content": "analysis before request", "timestamp": 100},
{"role": "user", "content": "Erstelle deine Version", "timestamp": 100},
{"role": "assistant", "content": "opened browser preview", "timestamp": 100},
]
state_middle_replay = [
{"role": "assistant", "content": "analysis before request", "timestamp": 100.1},
{
"role": "user",
"content": "[Workspace::v1: /tmp/project-workspace]\nErstelle deine Version",
"timestamp": 100.2,
},
]
merged = merge_session_messages_append_only(sidecar_messages, state_middle_replay)
assert [m["content"] for m in merged] == [m["content"] for m in sidecar_messages]
assert merged[-1]["role"] == "assistant"
assert merged[-1]["content"] == "opened browser preview"
def test_lost_response_recovered_on_second_read(hermes_home):
sid = "9f14583f0e4e4444aaaa111122223333"
stream_id = "7c8b4108d52b4aba9af362d3a54f47ac"
# ── Stage 1: simulate page-cache loss — sidecar repair runs while the
# run-journal for this stream is empty/absent on disk.
s = _make_dead_stream_session(sid, stream_id=stream_id)
s.save()
core_path = hermes_home / "sessions" / f"session_{sid}.json"
result = _apply_core_sync_or_error_marker(
s, core_path, stream_id_for_recheck=stream_id,
)
assert result is True
# Marker should carry the lazy-retry flag and *not* the permanent
# "no agent output was recovered" wording yet.
last = s.messages[-1]
assert last.get("_error") is True
assert last.get("type") == "interrupted"
assert last.get("_pending_journal_recovery") is True, (
"First repair pass should defer the recovery decision via a "
"_pending_journal_recovery flag so a later read can self-heal."
)
assert last.get("_journal_retry_stream_id") == stream_id
assert last.get("_journal_retry_attempts") == 0
assert isinstance(last.get("_journal_retry_first_seen_ts"), int)
assert "no agent output was recovered" not in last["content"]
# pending fields cleared regardless of journal visibility
assert s.pending_user_message is None
assert s.active_stream_id is None
assert s.pending_started_at is None
# ── Stage 2: the journaled events become visible on disk.
append_run_event(sid, stream_id, "token", {"text": "Checking GitHub first."})
append_run_event(
sid,
stream_id,
"tool",
{
"name": "terminal",
"preview": "gh pr list --repo nesquena/hermes-webui",
"args": {"command": "gh pr list --repo nesquena/hermes-webui"},
},
)
append_run_event(
sid,
stream_id,
"tool_complete",
{"name": "terminal", "duration": 1.2, "is_error": False},
)
append_run_event(
sid, stream_id, "token", {"text": " The first PR scan completed."},
)
# Pin session into the LRU cache and call get_session — this is the
# production path that triggers lazy retry.
models.SESSIONS[sid] = s
reloaded = models.get_session(sid)
assert reloaded is s
contents = [m.get("content", "") for m in s.messages]
# The marker self-healed:
assert any("recovered from the run journal" in c for c in contents), (
"After journaled tokens become readable, the marker must promote to "
"the recovered-output wording."
)
assert not any("no agent output was recovered" in c for c in contents)
# The journaled assistant text and tool card landed BEFORE the marker
# so chronological order in the transcript is preserved.
marker_idx = next(
i for i, m in enumerate(s.messages)
if m.get("type") == "interrupted" and m.get("_error")
)
recovered_msgs = [
m for m in s.messages[:marker_idx]
if m.get("_recovered_from_run_journal") is True
]
assert recovered_msgs, "recovered assistant content must sit above the marker"
recovered_text = " ".join(m.get("content", "") for m in recovered_msgs)
assert "Checking GitHub first." in recovered_text
assert "first PR scan completed" in recovered_text
# Tool card lives in session.tool_calls and points at one of the
# recovered assistant indices.
assert s.tool_calls, "journaled tool should be materialized"
assert s.tool_calls[-1]["name"] == "terminal"
assert s.tool_calls[-1]["done"] is True
# Flag and meta cleaned up after promotion.
promoted = s.messages[marker_idx]
assert "_pending_journal_recovery" not in promoted
assert "_journal_retry_stream_id" not in promoted
assert "_journal_retry_attempts" not in promoted
assert "_journal_retry_first_seen_ts" not in promoted
def test_concurrent_get_session_serializes_lazy_journal_retry(hermes_home, monkeypatch):
sid = "retry_lock_sid"
stream_id = "retry_lock_stream"
s = _make_pending_retry_session(sid, stream_id=stream_id)
models.SESSIONS[sid] = s
entered = threading.Event()
release = threading.Event()
counter_lock = threading.Lock()
calls = 0
def slow_retry(session, *, preserve_arriving_budget=False):
nonlocal calls
with counter_lock:
calls += 1
entered.set()
assert release.wait(timeout=2), "test timed out waiting to release retry body"
return False
monkeypatch.setattr(models, "_retry_journal_recovery_in_place", slow_retry)
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(models.get_session, sid)
assert entered.wait(timeout=2), "first caller did not enter retry helper"
second = executor.submit(models.get_session, sid)
assert second.result(timeout=2) is s
release.set()
assert first.result(timeout=2) is s
assert calls == 1
def test_still_arriving_journal_does_not_consume_retry_budget(hermes_home, monkeypatch):
sid = "retry_arriving_sid"
stream_id = "retry_arriving_stream"
s = _make_pending_retry_session(sid, stream_id=stream_id)
models.SESSIONS[sid] = s
monkeypatch.setattr(models, "_append_journaled_partial_output", lambda *a, **kw: False)
monkeypatch.setattr(models, "_journal_is_still_arriving", lambda *a, **kw: True)
for _ in range(20):
assert models.get_session(sid) is s
marker = s.messages[-1]
assert marker["_journal_retry_attempts"] == 0
assert marker["_pending_journal_recovery"] is True
assert marker["content"] == models._INTERRUPTED_PENDING_RETRY_WORDING
def test_sealed_empty_journal_consumes_retry_budget_and_demotes_at_max(hermes_home, monkeypatch):
sid = "retry_sealed_sid"
stream_id = "retry_sealed_stream"
s = _make_pending_retry_session(sid, stream_id=stream_id)
s.messages[-1]["_journal_retry_attempts"] = models._JOURNAL_RETRY_MAX_ATTEMPTS - 1
append_run_event(sid, stream_id, "stream_end", {})
models.SESSIONS[sid] = s
assert models.get_session(sid) is s
marker = s.messages[-1]
assert marker["content"] == models._INTERRUPTED_NEUTRAL_WORDING
_assert_retry_meta_removed(marker)
assert not any(m.get("_recovered_from_run_journal") for m in s.messages)
def test_marker_demotes_after_max_attempts_with_sealed_empty_journal(hermes_home, monkeypatch):
sid = "retry_max_sid"
stream_id = "retry_max_stream"
s = _make_pending_retry_session(sid, stream_id=stream_id)
s.messages[-1]["_journal_retry_attempts"] = models._JOURNAL_RETRY_MAX_ATTEMPTS - 1
append_run_event(sid, stream_id, "stream_end", {})
models.SESSIONS[sid] = s
assert models.get_session(sid) is s
marker = s.messages[-1]
assert marker["content"] == models._INTERRUPTED_NEUTRAL_WORDING
_assert_retry_meta_removed(marker)
assert not any(m.get("_recovered_from_run_journal") for m in s.messages)
def test_marker_demotes_after_giveup_seconds(hermes_home, monkeypatch):
base = 1_779_000_000
monkeypatch.setattr(models.time, "time", lambda: base)
sid = "retry_age_sid"
stream_id = "retry_age_stream"
s = _make_pending_retry_session(sid, stream_id=stream_id)
s.messages[-1]["_journal_retry_first_seen_ts"] = (
base - models._JOURNAL_RETRY_GIVEUP_SECONDS - 1
)
models.SESSIONS[sid] = s
append_calls = 0
def append_should_not_run(*args, **kwargs):
nonlocal append_calls
append_calls += 1
return False
monkeypatch.setattr(models, "_append_journaled_partial_output", append_should_not_run)
assert models.get_session(sid) is s
marker = s.messages[-1]
assert marker["content"] == models._INTERRUPTED_NEUTRAL_WORDING
_assert_retry_meta_removed(marker)
assert append_calls == 0
+40 -3
View File
@@ -60,7 +60,44 @@ def test_deferred_model_resolution_refreshes_context_metadata():
def test_boot_does_not_block_session_restore_on_model_catalog():
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
assert "if(s.default_model) window._defaultModel=s.default_model;" in src
assert "const _modelDropdownReady=populateModelDropdown().then" in src
assert "window._modelDropdownReady=_modelDropdownReady" in src
assert "if(s.default_model){" in src
assert "window._defaultModel=s.default_model;" in src
assert "const _hydrateBootModelDropdown=()=>populateModelDropdown().then" in src
assert "window._modelDropdownReady=null;" in src
assert "window._ensureModelDropdownReady=_startBootModelDropdown;" in src
assert "await populateModelDropdown()" not in src
def test_boot_primes_visible_default_model_without_catalog_fetch():
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
default_block_start = src.index("if(s.default_model){")
default_block = src[default_block_start:src.index("window._sessionJumpButtonsEnabled", default_block_start)]
assert "if(s.default_model_provider) window._activeProvider=s.default_model_provider;" in src
assert "const existingDefaultOpt=Array.from(sel.options).find(o=>o.value===s.default_model);" in default_block
assert "existingDefaultOpt.dataset.provider=window._activeProvider;" in default_block
assert "if(!existingDefaultOpt)" in default_block
assert "opt.dataset.custom='1'" in default_block
assert "opt.dataset.provider=window._activeProvider||''" in default_block
assert "_applyModelToDropdown(s.default_model,sel,window._activeProvider||null)" in default_block
assert "populateModelDropdown()" not in default_block
def test_settings_exposes_default_model_provider_for_lazy_boot_catalog():
src = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
assert 'settings["default_model_provider"]' in src
assert 'model_cfg = get_config().get("model", {})' in src
def test_boot_renders_session_list_before_workspace_and_onboarding_settle():
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
workspace_start = src.index("const _workspaceListReady=loadWorkspaceList();")
onboarding_start = src.index("const _onboardingReady=_bootSettings.onboarding_completed?Promise.resolve(false):loadOnboardingWizard();")
render_pos = src.index("await renderSessionList();", onboarding_start)
workspace_await = src.index("await _workspaceListReady;", render_pos)
onboarding_await = src.index("await _onboardingReady;", render_pos)
assert workspace_start < render_pos < workspace_await
assert onboarding_start < render_pos < onboarding_await
assert "_bootSettings.onboarding_completed" in src
@@ -0,0 +1,46 @@
"""Regression tests for stale session model hydration in the WebUI.
Old sessions can persist provider-shaped model IDs such as ``openai/gpt-5.4-mini``
after the active runtime moved to OpenAI Codex ``gpt-5.5``. The first
``loadSession()`` metadata request must ask the backend for the resolved model so
that the composer state cannot briefly use the stale raw value for display or the
next chat-start payload.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
SESSIONS_JS = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
def _extract_function(src: str, signature: str) -> str:
start = src.find(signature)
assert start >= 0, f"missing function signature: {signature}"
brace = src.find("{", start)
assert brace >= 0, f"missing function body for: {signature}"
depth = 0
for idx in range(brace, len(src)):
ch = src[idx]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
return src[start : idx + 1]
raise AssertionError(f"unterminated function body for: {signature}")
def test_load_session_initial_metadata_request_resolves_model_before_state_assignment():
body = _extract_function(SESSIONS_JS, "async function loadSession(sid")
metadata_fetch = "messages=0&resolve_model=1"
stale_metadata_fetch = "messages=0&resolve_model=0"
assignment = "S.session=data.session"
assert metadata_fetch in body, (
"loadSession() must resolve model metadata on the initial fetch so stale "
"persisted models like openai/gpt-5.4-mini cannot become active composer state"
)
assert stale_metadata_fetch not in body[: body.index(assignment)], (
"loadSession() must not assign S.session from unresolved metadata before the "
"backend has normalized stale model/provider combinations"
)
+576 -2
View File
@@ -48,11 +48,13 @@ def _isolate_stream_state():
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.STREAM_PARTIAL_TEXT.clear()
config.ACTIVE_RUNS.clear()
yield
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.STREAM_PARTIAL_TEXT.clear()
config.ACTIVE_RUNS.clear()
@pytest.fixture(autouse=True)
@@ -111,6 +113,11 @@ def _register_active_stream(stream_id):
config.STREAMS[stream_id] = queue.Queue()
def _register_active_run(stream_id):
"""Register stream_id as an active worker without an attached SSE stream."""
config.register_active_run(stream_id, session_id="stale_sid", phase="running")
class TestRepairStalePendingNoDeadlock:
"""_repair_stale_pending uses non-blocking lock acquire so callers that
already hold the per-session lock (retry_last, undo_last, cancel_stream)
@@ -313,7 +320,15 @@ class TestDraftRecovery:
)
assert "Response interrupted" in content
assert "WebUI process restarted" in content
assert "user message above was preserved" in content
# The marker now arms the lazy-retry hook when a stream id is known
# ("Recovering the partial output… reload to retry."). The legacy
# "user message above was preserved" wording is reserved for the
# no-stream-id repair case; the post-retry-give-up case demotes to
# the neutral "Partial output may have been lost." wording instead.
assert (
"user message above was preserved" in content
or "Recovering the partial output" in content
)
assert error_msgs[0].get("type") == "interrupted"
def test_pending_attachments_recovered(self, hermes_home, monkeypatch):
@@ -705,7 +720,14 @@ class TestNonEmptyMessagesPendingCleared:
assert not any("private scratchpad text" in c for c in contents)
error_msgs = [m for m in s.messages if m.get("_error")]
assert len(error_msgs) == 1
assert "no agent output was recovered" in error_msgs[0]["content"]
# Reasoning-only events do not count as visible output, so the marker
# arms the lazy-retry hook (stream id is known, journal may have more
# events appear on a later read). The legacy "no agent output was
# recovered" wording is now reserved for the no-stream-id case.
assert error_msgs[0].get("_pending_journal_recovery") is True
assert error_msgs[0].get("_journal_retry_stream_id") == "reasoning_only_stream"
assert "no agent output was recovered" not in error_msgs[0]["content"]
assert "Recovering the partial output" in error_msgs[0]["content"]
def test_journal_recovery_keeps_consecutive_tools_on_one_anchor(self, hermes_home, monkeypatch):
"""Consecutive journaled tools without an intervening visible update
@@ -1093,6 +1115,25 @@ class TestRepairStalePendingIntegration:
with config.STREAMS_LOCK:
config.STREAMS.pop("live_stream", None)
def test_skips_when_worker_alive_without_sse_stream(self, hermes_home, monkeypatch):
"""Pre-check: if ACTIVE_RUNS still owns the worker, repair is skipped.
STREAMS is the browser/SSE observation layer and may disappear while the
backend worker is still running. A detached-but-active worker must not be
mistaken for a WebUI restart or crashed turn.
"""
s = _make_stale_session(stream_id="detached_stream")
s.save()
_register_active_run("detached_stream")
assert "detached_stream" in _active_stream_ids()
result = _repair_stale_pending(s)
assert result is False
assert s.pending_user_message == "Hello hermes"
assert s.active_stream_id == "detached_stream"
assert s.messages == []
def test_skips_when_no_pending(self, hermes_home, monkeypatch):
"""Pre-check: if pending_user_message is None, repair is skipped."""
s = _make_session(messages=[])
@@ -1153,3 +1194,536 @@ class TestCoreSyncMetadata:
assert len(user_msgs) == 1
assert user_msgs[0]["content"] == "My question"
assert user_msgs[0].get("_recovered") is True
# ── Lazy run-journal recovery (read-side self-heal) ─────────────────────────
class TestInterruptedRecoveryMarker:
"""Pure-function tests for _interrupted_recovery_marker(pending_retry=…)."""
def test_marker_recovered_output_excludes_pending_retry_flag(self):
marker = models._interrupted_recovery_marker(recovered_output=True)
assert marker["_error"] is True
assert marker["type"] == "interrupted"
assert "_pending_journal_recovery" not in marker
assert "recovered from the run journal" in marker["content"]
def test_marker_pending_retry_sets_flag_and_wording(self):
marker = models._interrupted_recovery_marker(pending_retry=True)
assert marker.get("_pending_journal_recovery") is True
assert "Recovering the partial output" in marker["content"]
assert "no agent output was recovered" not in marker["content"]
def test_marker_recovered_output_beats_pending_retry(self):
marker = models._interrupted_recovery_marker(
recovered_output=True, pending_retry=True,
)
assert "_pending_journal_recovery" not in marker
assert "recovered from the run journal" in marker["content"]
def test_marker_default_wording_unchanged_for_no_output_no_retry(self):
marker = models._interrupted_recovery_marker()
assert "_pending_journal_recovery" not in marker
assert "no agent output was recovered" in marker["content"]
class TestRetryJournalRecoveryInPlace:
"""In-place retry helper: promote / increment / give up."""
def _make_pending_session(self, hermes_home, stream_id="lazy_stream",
attempts=0, first_seen_ts=None):
s = _make_session(messages=[
{"role": "user", "content": "earlier turn"},
{"role": "assistant", "content": "earlier reply"},
{"role": "user", "content": "Stuck draft", "_recovered": True},
])
marker = models._interrupted_recovery_marker(pending_retry=True)
marker["_journal_retry_stream_id"] = stream_id
marker["_journal_retry_attempts"] = attempts
marker["_journal_retry_first_seen_ts"] = (
first_seen_ts if first_seen_ts is not None else int(time.time())
)
s.messages.append(marker)
s.save()
return s
def test_promotes_marker_when_journal_now_available(self, hermes_home, monkeypatch):
stream_id = "lazy_stream_promote"
s = self._make_pending_session(hermes_home, stream_id=stream_id)
marker_before = s.messages[-1]
append_run_event(s.session_id, stream_id, "token", {"text": "Late tokens."})
append_run_event(
s.session_id, stream_id, "tool",
{"name": "terminal", "preview": "echo", "args": {"cmd": "echo"}},
)
ok = models._retry_journal_recovery_in_place(s)
assert ok is True
# Marker promoted in place and meta stripped
marker_idx = next(
i for i, m in enumerate(s.messages)
if m.get("type") == "interrupted" and m.get("_error")
)
promoted = s.messages[marker_idx]
assert promoted is marker_before
assert "recovered from the run journal" in promoted["content"]
assert "_pending_journal_recovery" not in promoted
assert "_journal_retry_stream_id" not in promoted
assert "_journal_retry_attempts" not in promoted
assert "_journal_retry_first_seen_ts" not in promoted
# Journaled rows reordered ABOVE the marker, preserving order
before_marker = s.messages[:marker_idx]
recovered = [m for m in before_marker if m.get("_recovered_from_run_journal")]
assert recovered, "journaled assistant rows must sit above the marker"
assert any("Late tokens." in m.get("content", "") for m in recovered)
# tool_call.assistant_msg_idx still points into the valid range
for tc in s.tool_calls or []:
idx = tc.get("assistant_msg_idx")
if isinstance(idx, int):
assert 0 <= idx < len(s.messages)
def test_increments_attempts_when_journal_still_empty(self, hermes_home, monkeypatch):
stream_id = "lazy_stream_increment"
s = self._make_pending_session(hermes_home, stream_id=stream_id)
# No append_run_event — journal stays empty.
ok = models._retry_journal_recovery_in_place(s)
assert ok is False
marker = s.messages[-1]
assert marker.get("_pending_journal_recovery") is True
assert marker.get("_journal_retry_attempts") == 1
assert "Recovering the partial output" in marker["content"]
def test_demotes_to_neutral_after_max_attempts(self, hermes_home, monkeypatch):
stream_id = "lazy_stream_giveup_attempts"
s = self._make_pending_session(
hermes_home, stream_id=stream_id,
attempts=models._JOURNAL_RETRY_MAX_ATTEMPTS,
)
ok = models._retry_journal_recovery_in_place(s)
assert ok is False
marker = s.messages[-1]
assert "_pending_journal_recovery" not in marker
assert "_journal_retry_stream_id" not in marker
assert "_journal_retry_attempts" not in marker
assert "_journal_retry_first_seen_ts" not in marker
assert "Partial output may have been lost" in marker["content"]
assert "Recovering the partial output" not in marker["content"]
def test_demotes_to_neutral_after_giveup_seconds(self, hermes_home, monkeypatch):
stream_id = "lazy_stream_giveup_age"
first_seen = int(time.time()) - (models._JOURNAL_RETRY_GIVEUP_SECONDS + 60)
s = self._make_pending_session(
hermes_home, stream_id=stream_id, first_seen_ts=first_seen,
)
ok = models._retry_journal_recovery_in_place(s)
assert ok is False
marker = s.messages[-1]
assert "_pending_journal_recovery" not in marker
assert "Partial output may have been lost" in marker["content"]
def test_noop_when_no_pending_marker(self, hermes_home, monkeypatch):
s = _make_session(messages=[
{"role": "user", "content": "x"},
{"role": "assistant", "content": "y"},
])
s.save()
spy = []
original = models._append_journaled_partial_output
def _spy(*a, **kw):
spy.append(1)
return original(*a, **kw)
monkeypatch.setattr(models, "_append_journaled_partial_output", _spy)
ok = models._retry_journal_recovery_in_place(s)
assert ok is False
assert spy == [], "no pending marker → must not call recovery"
def test_short_circuit_helper_detects_pending_marker(self, hermes_home, monkeypatch):
s = _make_session(messages=[
{"role": "user", "content": "x"},
{"role": "assistant", "content": "y"},
])
assert models._session_has_pending_journal_retry(s) is False
marker = models._interrupted_recovery_marker(pending_retry=True)
marker["_journal_retry_stream_id"] = "abc"
marker["_journal_retry_attempts"] = 0
marker["_journal_retry_first_seen_ts"] = int(time.time())
s.messages.append(marker)
assert models._session_has_pending_journal_retry(s) is True
def test_short_circuit_helper_stops_at_normal_assistant(self, hermes_home, monkeypatch):
s = _make_session(messages=[
{"role": "user", "content": "x"},
])
# An old, already-promoted marker followed by a normal assistant turn —
# the pending flag belongs to a prior turn that was healed; helper must
# not loop back into it.
marker = models._interrupted_recovery_marker(pending_retry=True)
marker["_journal_retry_stream_id"] = "abc"
marker["_journal_retry_attempts"] = 0
marker["_journal_retry_first_seen_ts"] = int(time.time())
s.messages.append(marker)
s.messages.append({"role": "user", "content": "later"})
s.messages.append({"role": "assistant", "content": "later reply"})
assert models._session_has_pending_journal_retry(s) is False
class TestGetSessionLazyRetryHook:
"""get_session() must trigger _retry_journal_recovery_in_place on both
cache-hit and cold-load paths when a pending marker exists, and skip
quickly when nothing is pending."""
def _make_session_with_pending_marker(self, sid="lazy_get", stream_id="st"):
s = _make_session(session_id=sid, messages=[
{"role": "user", "content": "u"},
{"role": "assistant", "content": "a"},
{"role": "user", "content": "u2", "_recovered": True},
])
marker = models._interrupted_recovery_marker(pending_retry=True)
marker["_journal_retry_stream_id"] = stream_id
marker["_journal_retry_attempts"] = 0
marker["_journal_retry_first_seen_ts"] = int(time.time())
s.messages.append(marker)
return s
def test_triggers_retry_on_cache_hit(self, hermes_home, monkeypatch):
sid = "lazy_get_cache"
stream_id = "stream_cache"
s = self._make_session_with_pending_marker(sid=sid, stream_id=stream_id)
s.save()
models.SESSIONS[sid] = s
append_run_event(sid, stream_id, "token", {"text": "Late."})
reloaded = models.get_session(sid)
assert reloaded is s
marker = s.messages[-1] if "Recovering" in s.messages[-1]["content"] else next(
m for m in s.messages
if m.get("type") == "interrupted" and m.get("_error")
)
assert "recovered from the run journal" in marker["content"]
assert "_pending_journal_recovery" not in marker
def test_triggers_retry_on_cold_load(self, hermes_home, monkeypatch):
sid = "lazy_get_cold"
stream_id = "stream_cold"
s = self._make_session_with_pending_marker(sid=sid, stream_id=stream_id)
s.save()
models.SESSIONS.pop(sid, None)
append_run_event(sid, stream_id, "token", {"text": "Late."})
reloaded = models.get_session(sid)
marker = next(
m for m in reloaded.messages
if m.get("type") == "interrupted" and m.get("_error")
)
assert "recovered from the run journal" in marker["content"]
assert "_pending_journal_recovery" not in marker
def test_short_circuit_when_no_pending_marker(self, hermes_home, monkeypatch):
sid = "lazy_get_no_pending"
s = _make_session(session_id=sid, messages=[
{"role": "user", "content": "x"},
{"role": "assistant", "content": "y"},
])
s.save()
models.SESSIONS[sid] = s
spy = []
monkeypatch.setattr(
models, "_retry_journal_recovery_in_place",
lambda session: spy.append(1) or False,
)
models.get_session(sid)
assert spy == []
def test_metadata_only_skips_retry(self, hermes_home, monkeypatch):
sid = "lazy_get_meta"
stream_id = "stream_meta"
s = self._make_session_with_pending_marker(sid=sid, stream_id=stream_id)
s.save()
models.SESSIONS[sid] = s
spy = []
monkeypatch.setattr(
models, "_retry_journal_recovery_in_place",
lambda session: spy.append(1) or False,
)
models.get_session(sid, metadata_only=True)
assert spy == [], "metadata_only must skip the lazy-retry helper"
class TestLazyRetryBackwardsCompat:
"""Pre-fix session shapes must continue to work."""
def test_legacy_marker_without_flag_unchanged(self, hermes_home, monkeypatch):
"""An old session whose marker carries the legacy 'no agent output'
wording (no flag) must not be touched by get_session()."""
sid = "legacy_marker_sid"
s = _make_session(session_id=sid, messages=[
{"role": "user", "content": "x"},
{"role": "assistant", "content": "y"},
{"role": "user", "content": "later", "_recovered": True},
])
legacy = models._interrupted_recovery_marker(recovered_output=False)
s.messages.append(legacy)
s.save()
models.SESSIONS.pop(sid, None)
spy = []
original = models._append_journaled_partial_output
monkeypatch.setattr(
models, "_append_journaled_partial_output",
lambda *a, **kw: spy.append(1) or original(*a, **kw),
)
models.get_session(sid)
assert spy == [], "legacy marker (no flag) must not re-trigger recovery"
def test_pending_retry_marker_round_trips_through_session_save_and_load(
self, hermes_home, monkeypatch):
"""All four retry meta keys must survive Session.save() / Session.load()."""
sid = "round_trip_sid"
s = _make_session(session_id=sid, messages=[
{"role": "user", "content": "x"},
])
marker = models._interrupted_recovery_marker(pending_retry=True)
marker["_journal_retry_stream_id"] = "abc"
marker["_journal_retry_attempts"] = 3
marker["_journal_retry_first_seen_ts"] = 1779200000
s.messages.append(marker)
s.save()
models.SESSIONS.pop(sid, None)
reloaded = Session.load(sid)
last = reloaded.messages[-1]
assert last.get("_pending_journal_recovery") is True
assert last.get("_journal_retry_stream_id") == "abc"
assert last.get("_journal_retry_attempts") == 3
assert last.get("_journal_retry_first_seen_ts") == 1779200000
class TestJournalToolDedupeScoping:
"""`_journal_tool_already_present` must only collapse against tool cards
recovered from the same stream — a repeated tool (e.g. ``terminal: ls``)
in a previous turn must NOT pre-empt this turn's recovery."""
def test_repeated_tool_in_earlier_turn_does_not_block_recovery(self, hermes_home, monkeypatch):
sid = "dedupe_scope_sid"
stream_id = "dedupe_scope_stream"
s = _make_session(session_id=sid, messages=[
{"role": "user", "content": "earlier turn"},
{"role": "assistant", "content": "earlier reply"},
{"role": "user", "content": "later turn", "_recovered": True},
])
# Pre-existing tool card from an earlier turn with same (name, preview)
# but a different recovered-stream-id. The retry must NOT see this as a
# hit when dedupe_existing is asked to scope to ``stream_id``.
s.tool_calls = [
{
"name": "terminal",
"preview": "ls",
"snippet": "ls",
"tid": "old-1",
"_recovered_from_run_journal": True,
"_recovered_stream_id": "earlier_stream",
"done": True,
}
]
marker = models._interrupted_recovery_marker(pending_retry=True)
marker["_journal_retry_stream_id"] = stream_id
marker["_journal_retry_attempts"] = 0
marker["_journal_retry_first_seen_ts"] = int(time.time())
s.messages.append(marker)
s.save()
append_run_event(sid, stream_id, "token", {"text": "Listing."})
append_run_event(
sid, stream_id, "tool",
{"name": "terminal", "preview": "ls", "args": {"cmd": "ls"}},
)
ok = models._retry_journal_recovery_in_place(s)
assert ok is True
# Two tool cards now: the old one untouched, plus a new one for this
# stream. If dedupe were session-wide the new one would be dropped.
scoped = [
tc for tc in s.tool_calls
if tc.get("_recovered_stream_id") == stream_id
]
assert len(scoped) == 1
assert scoped[0]["name"] == "terminal"
assert scoped[0]["preview"] == "ls"
# Old tool card is preserved.
assert any(
tc.get("_recovered_stream_id") == "earlier_stream"
for tc in s.tool_calls
)
def test_untagged_tool_still_matches_for_core_transcript_invariant(self, hermes_home, monkeypatch):
"""Tool cards without ``_recovered_stream_id`` (live tools, or tools
carried over from the core transcript) match regardless of the
``stream_id`` argument. This preserves the "core transcript already
contains this tool, don't duplicate it" invariant the original repair
path relies on."""
s = _make_session(messages=[
{"role": "user", "content": "x"},
{"role": "assistant", "content": "y"},
])
s.tool_calls = [
{"name": "terminal", "preview": "ls", "snippet": "ls"},
]
# No stream_id → legacy session-wide check returns True.
assert models._journal_tool_already_present(s, "terminal", "ls") is True
# With a stream_id, untagged tool cards still match (different stream
# ids only override the match decision when the existing card itself
# is tagged with a stream id that disagrees).
assert models._journal_tool_already_present(
s, "terminal", "ls", stream_id="some_stream",
) is True
def test_tagged_tool_with_different_stream_does_not_match(self, hermes_home, monkeypatch):
"""A tool card tagged with a different recovered_stream_id must NOT
be considered a duplicate when the retry is scoped to a different
stream."""
s = _make_session(messages=[
{"role": "user", "content": "x"},
])
s.tool_calls = [
{
"name": "terminal",
"preview": "ls",
"snippet": "ls",
"_recovered_stream_id": "other_stream",
},
]
assert models._journal_tool_already_present(
s, "terminal", "ls", stream_id="this_stream",
) is False
# But scoping to the same stream id matches.
assert models._journal_tool_already_present(
s, "terminal", "ls", stream_id="other_stream",
) is True
class TestWslPageCacheRace:
"""Cover the WSL2 / network-FS shape: read_run_events returns empty / errors
first, recovers on a later call."""
def test_first_read_raises_oserror_second_read_succeeds(self, hermes_home, monkeypatch):
sid = "wsl_race_sid"
stream_id = "wsl_race_stream"
s = _make_session(session_id=sid, messages=[
{"role": "user", "content": "x"},
])
s.pending_user_message = "Keep going"
s.pending_started_at = time.time() - 120
s.active_stream_id = stream_id
# Simulate first read raising IOError, then succeeding.
import api.run_journal as run_journal
real = run_journal.read_run_events
attempts = {"n": 0}
def flaky_read(sid_, run_id, **kw):
attempts["n"] += 1
if attempts["n"] == 1:
raise OSError("EIO simulated by test")
return real(sid_, run_id, **kw)
monkeypatch.setattr(run_journal, "read_run_events", flaky_read)
core_path = hermes_home / "sessions" / f"session_{sid}.json"
result = _apply_core_sync_or_error_marker(
s, core_path, stream_id_for_recheck=stream_id,
)
assert result is True
marker = next(m for m in s.messages if m.get("type") == "interrupted")
assert marker.get("_pending_journal_recovery") is True
# Now write journal events; the next retry call will read them.
append_run_event(sid, stream_id, "token", {"text": "Came back."})
ok = models._retry_journal_recovery_in_place(s)
assert ok is True
marker_after = next(m for m in s.messages if m.get("type") == "interrupted")
assert "recovered from the run journal" in marker_after["content"]
assert "_pending_journal_recovery" not in marker_after
def test_journal_grows_between_reads(self, hermes_home, monkeypatch):
sid = "wsl_grow_sid"
stream_id = "wsl_grow_stream"
s = _make_session(session_id=sid, messages=[
{"role": "user", "content": "x"},
])
s.pending_user_message = "Keep going"
s.pending_started_at = time.time() - 120
s.active_stream_id = stream_id
# First repair pass: nothing visible yet.
core_path = hermes_home / "sessions" / f"session_{sid}.json"
result = _apply_core_sync_or_error_marker(
s, core_path, stream_id_for_recheck=stream_id,
)
assert result is True
marker = next(m for m in s.messages if m.get("type") == "interrupted")
assert marker.get("_pending_journal_recovery") is True
# Journal grows.
append_run_event(sid, stream_id, "token", {"text": "Partial 1."})
append_run_event(sid, stream_id, "token", {"text": " Partial 2."})
append_run_event(
sid, stream_id, "tool",
{"name": "terminal", "preview": "ls", "args": {"cmd": "ls"}},
)
ok = models._retry_journal_recovery_in_place(s)
assert ok is True
marker_after = next(m for m in s.messages if m.get("type") == "interrupted")
assert "recovered from the run journal" in marker_after["content"]
# Both tokens recovered, in order, before the marker.
marker_idx = s.messages.index(marker_after)
recovered_text = " ".join(
m.get("content", "") for m in s.messages[:marker_idx]
if m.get("_recovered_from_run_journal")
)
assert "Partial 1." in recovered_text and "Partial 2." in recovered_text
def test_concurrent_get_session_calls_idempotent(self, hermes_home, monkeypatch):
sid = "wsl_concurrent_sid"
stream_id = "wsl_concurrent_stream"
s = _make_session(session_id=sid, messages=[
{"role": "user", "content": "x"},
{"role": "assistant", "content": "y"},
{"role": "user", "content": "later", "_recovered": True},
])
marker = models._interrupted_recovery_marker(pending_retry=True)
marker["_journal_retry_stream_id"] = stream_id
marker["_journal_retry_attempts"] = 0
marker["_journal_retry_first_seen_ts"] = int(time.time())
s.messages.append(marker)
s.save()
append_run_event(sid, stream_id, "token", {"text": "ConcurrentTokens"})
models.SESSIONS[sid] = s
results = []
def _worker():
try:
models.get_session(sid)
results.append("ok")
except Exception as exc: # noqa: BLE001
results.append(exc)
threads = [threading.Thread(target=_worker, daemon=True) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
assert results.count("ok") == 2, f"both workers must succeed: {results}"
# Exactly one interrupted marker remains, exactly one journal-recovered
# body (deduped by dedupe_existing=True).
interrupted_markers = [m for m in s.messages if m.get("type") == "interrupted"]
assert len(interrupted_markers) == 1
recovered = [m for m in s.messages if m.get("_recovered_from_run_journal")]
assert sum(1 for m in recovered if "ConcurrentTokens" in m.get("content", "")) == 1
+163
View File
@@ -0,0 +1,163 @@
"""Regression tests for sidebar tab visibility feature.
Covers backend validation round-trip, frontend static contracts,
i18n coverage, and the key integration points that have broken before.
"""
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
def test_backend_round_trip_and_validation(monkeypatch, tmp_path):
"""hidden_tabs defaults to [], saves/reloads, rejects non-list, filters empty strings."""
import api.config as config
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
loaded = config.load_settings()
assert loaded["hidden_tabs"] == [], "default must be empty list"
saved = config.save_settings({"hidden_tabs": ["kanban", "insights"]})
assert saved["hidden_tabs"] == ["kanban", "insights"]
assert config.load_settings()["hidden_tabs"] == ["kanban", "insights"]
# Non-list is rejected, default preserved
bad = config.save_settings({"hidden_tabs": "not-a-list"})
assert bad["hidden_tabs"] == ["kanban", "insights"]
# Empty strings filtered, empty list clears
saved = config.save_settings({"hidden_tabs": ["kanban", "", " ", "logs"]})
assert saved["hidden_tabs"] == ["kanban", "logs"]
cleared = config.save_settings({"hidden_tabs": []})
assert cleared["hidden_tabs"] == []
# Must NOT be in bool keys (would corrupt the list)
assert "hidden_tabs" not in config._SETTINGS_BOOL_KEYS
assert "hidden_tabs" in config._SETTINGS_ALLOWED_KEYS
def test_frontend_static_contracts():
"""All required HTML, JS, CSS, and boot elements exist with correct wiring."""
# HTML: container in Appearance pane
assert 'id="tabVisibilityChips"' in INDEX_HTML
assert 'data-i18n="settings_label_tab_visibility"' in INDEX_HTML
assert 'data-i18n="settings_desc_tab_visibility"' in INDEX_HTML
appearance_start = INDEX_HTML.find('id="settingsPaneAppearance"')
prefs_start = INDEX_HTML.find('id="settingsPanePreferences"', appearance_start + 1)
chips_pos = INDEX_HTML.find('id="tabVisibilityChips"')
assert appearance_start < chips_pos < prefs_start, \
"tabVisibilityChips must be inside Appearance pane"
# JS: constants, functions, and wiring
assert "_ALWAYS_VISIBLE_TABS" in PANELS_JS
assert "'chat'" in PANELS_JS.split("_ALWAYS_VISIBLE_TABS")[1][:80]
assert "'settings'" in PANELS_JS.split("_ALWAYS_VISIBLE_TABS")[1][:80]
assert "_HIDDEN_TABS_LS_KEY" in PANELS_JS
assert "hermes-webui-hidden-tabs" in PANELS_JS
for fn in ("_getHiddenTabs", "_setHiddenTabs", "_applyTabVisibility",
"_renderTabVisibilityChips", "_toggleTabVisibilityChip"):
assert f"function {fn}(" in PANELS_JS, f"panels.js must define {fn}()"
# Toggle must autosave and respect always-visible tabs
toggle_block = PANELS_JS[PANELS_JS.find("function _toggleTabVisibilityChip"):]
toggle_body = toggle_block[:toggle_block.find("\nfunction ", 1) or 2000]
assert "_scheduleAppearanceAutosave" in toggle_body
assert "_ALWAYS_VISIBLE_TABS" in toggle_body
# Appearance payload must include hidden_tabs
payload_block = PANELS_JS[PANELS_JS.find("function _appearancePayloadFromUi"):]
payload_body = payload_block[:payload_block.find("\nfunction ", 1) or 2000]
assert "hidden_tabs" in payload_body
assert "_getHiddenTabs" in payload_body
# CSS: hidden class and chip styles
assert ".nav-tab-hidden" in STYLE_CSS
assert "display:none" in STYLE_CSS.split(".nav-tab-hidden")[1][:80].replace(" ", "")
assert ".tab-visibility-chip" in STYLE_CSS
# No flash-prevention script in <head> (DOM elements don't exist at that point)
head_end = INDEX_HTML.find("</head>")
assert "hermes-webui-hidden-tabs" not in INDEX_HTML[:head_end]
def test_boot_restores_visibility_from_localstorage():
"""boot.js must call _applyTabVisibility at boot time so hidden tabs take effect."""
assert "_restoreTabVisibility" in BOOT_JS
block = BOOT_JS[BOOT_JS.find("_restoreTabVisibility"):][:1500]
assert "_applyTabVisibility" in block, \
"boot.js must call _applyTabVisibility so tabs are hidden before first paint"
def test_i18n_coverage():
"""Label and description keys must exist in all locales with matching counts."""
label_count = I18N_JS.count("settings_label_tab_visibility")
desc_count = I18N_JS.count("settings_desc_tab_visibility")
assert label_count >= 11, f"Expected ≥11 locales, found {label_count}"
assert desc_count >= 11, f"Expected ≥11 locales, found {desc_count}"
assert label_count == desc_count, \
f"Label ({label_count}) and desc ({desc_count}) counts must match"
def test_backend_rejects_chat_and_settings_in_hidden_tabs(monkeypatch, tmp_path):
"""Server-side belt-and-suspenders: a malicious POST that tries to hide
`chat` or `settings` (the always-visible nav tabs) must be filtered out
server-side, not just client-side. The client already applies the same
filter at apply time, but the server should not let a tampered payload
persist the forbidden values."""
import api.config as config
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
saved = config.save_settings({"hidden_tabs": ["chat", "kanban", "settings", "logs"]})
assert saved["hidden_tabs"] == ["kanban", "logs"], \
"chat and settings must be stripped server-side"
# Even an all-forbidden payload reduces to empty (not rejected — empty is fine)
saved = config.save_settings({"hidden_tabs": ["chat", "settings"]})
assert saved["hidden_tabs"] == []
def test_profile_switch_reconciles_hidden_tabs():
"""When a user switches profiles, the new profile's hidden_tabs value
must be applied — the per-profile settings.json is the source of truth,
not the previous profile's localStorage value. Stage-394 added a
/api/settings refetch in _refreshProfileSwitchBackground; verify it stays
wired (the API call + the _applyTabVisibility call)."""
bg_start = PANELS_JS.find("function _refreshProfileSwitchBackground")
assert bg_start >= 0, "_refreshProfileSwitchBackground not found"
bg_end = PANELS_JS.find("\nfunction ", bg_start + 1)
if bg_end < 0:
bg_end = bg_start + 4000
bg_body = PANELS_JS[bg_start:bg_end]
assert "/api/settings" in bg_body, \
"profile-switch background refresh must re-fetch settings for the new profile"
assert "_applyTabVisibility" in bg_body, \
"profile-switch background refresh must re-apply tab visibility"
assert "hidden_tabs" in bg_body, \
"profile-switch background refresh must read hidden_tabs from server response"
def test_chip_a11y_uses_switch_role_with_aria_checked():
"""Chips should use role=switch + aria-checked instead of plain
aria-pressed. The pressed/not-pressed wording is confusing for a toggle
that visually represents an on/off switch; role=switch + aria-checked
matches user mental model."""
render_block = PANELS_JS[PANELS_JS.find("function _renderTabVisibilityChips"):]
body = render_block[:render_block.find("\nfunction ", 1) or 3000]
assert "role" in body and "'switch'" in body, \
"chip should declare role='switch' for clearer screen-reader narration"
assert "aria-checked" in body, "chip should use aria-checked to match role=switch"
# Group container also has role=group + aria-labelledby
assert 'role="group"' in INDEX_HTML, "chip container needs role=group"
assert 'aria-labelledby="tabVisibilityLabel"' in INDEX_HTML, \
"chip container needs aria-labelledby pointing at the label"
# Focus-visible style exists
assert ".tab-visibility-chip:focus-visible" in STYLE_CSS, \
"chip needs a :focus-visible style for keyboard nav"
+11 -2
View File
@@ -617,17 +617,26 @@ class TestClarifyCardTimerLogic:
assert any(prop in body for prop in ('box-shadow', 'outline', 'border', 'text-decoration')), \
'urgent countdown styling must include a non-color visual cue'
def test_respond_clarify_calls_hide_with_force(self):
def test_respond_clarify_sends_clarify_id_and_waits_for_ack(self):
src = self._get_js().read_text()
import re
m = re.search(r'async function respondClarify.*?(?=\nasync function|\nfunction |\Z)',
src, re.DOTALL)
assert m, 'respondClarify function not found'
body = m.group(0)
assert 'clarify_id' in body, \
'respondClarify must send clarify_id to the backend for stable matching (issue #2639)'
assert 'hideClarifyCard(true' in body, \
'respondClarify must call hideClarifyCard(true) so card hides immediately after user clicks'
'respondClarify must still hide the card on successful acknowledgement'
assert "'sent'" in body, \
'respondClarify must mark user-submitted hides so drafts are not re-stashed'
assert 'result && result.ok' in body, \
'respondClarify must check ok before hiding the clarify card (issue #2639)'
# The card must NOT be hidden before the API call — it should wait for the response.
hide_idx = body.index('hideClarifyCard(true')
api_idx = body.index('/api/clarify/respond')
assert hide_idx > api_idx, \
'respondClarify must wait for the API response before calling hideClarifyCard (issue #2639)'
def test_clarify_poll_loop_uses_no_force(self):
src = self._get_js().read_text()
+22 -18
View File
@@ -41,14 +41,20 @@ class TestActiveSessionTitleThemeColor(unittest.TestCase):
"""
.session-item.active .session-title must use var(--gold) not a hardcoded hex.
The light-mode override line (:not(.dark)) is allowed to keep its own
hardcoded color; we only check the base/dark rule.
hardcoded color; we only check the base/dark rule. Skin-specific
overrides (e.g. `:root[data-skin="..."]`) are also allowed to use
their own palette values — they bind to a different selector scope.
"""
# Find all lines that match the active session title selector
# Find all lines that match the active session title selector. Exclude
# the :not(.dark) light-mode override and skin-specific overrides like
# `:root[data-skin="geist-contrast"] .session-item.active .session-title`
# which legitimately use their own palette values.
lines = STYLE_CSS.splitlines()
base_rule_lines = [
line for line in lines
if ".session-item.active .session-title" in line
and ':not(.dark)' not in line
and ':root[data-skin=' not in line
]
self.assertTrue(
@@ -213,12 +219,12 @@ class TestWorkspaceChipAfterProfileSwitch(unittest.TestCase):
# Slice from that point to cover the relevant block
block = PANELS_JS[idx:idx + 1000]
# newSession(false) must be called first
self.assertIn('await newSession(false)', block,
"sessionInProgress branch must call await newSession(false)")
# newSession(false, ...) must be called first
self.assertIn('await newSession(false', block,
"sessionInProgress branch must call await newSession(false, ...)")
# The fix: syncTopbar() must be called after newSession(false)
pos_new_session = block.find('await newSession(false)')
# The fix: syncTopbar() must be called after newSession(false, ...)
pos_new_session = block.find('await newSession(false')
pos_sync_topbar = block.find('syncTopbar()')
self.assertGreater(pos_sync_topbar, -1,
"syncTopbar() must be called in the sessionInProgress branch")
@@ -226,27 +232,25 @@ class TestWorkspaceChipAfterProfileSwitch(unittest.TestCase):
"syncTopbar() must be called AFTER newSession(false)")
def test_profile_default_workspace_applied_to_new_session(self):
"""After newSession(false) the code must assign S._profileDefaultWorkspace
to S.session.workspace so the session is correctly tagged."""
"""newSession(false) should apply the pending profile workspace itself."""
idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1)
block = PANELS_JS[idx:idx + 1000]
# The fix block must set S.session.workspace from S._profileDefaultWorkspace
self.assertIn('S.session.workspace = S._profileDefaultWorkspace', block,
"S.session.workspace must be set from S._profileDefaultWorkspace "
"in the sessionInProgress branch after newSession(false)")
self.assertIn('await newSession(false', block)
self.assertNotIn('/api/session/update', block,
"sessionInProgress should not post a duplicate workspace update "
"after newSession(false)")
def test_api_session_update_called_for_new_session_workspace(self):
"""The fix must call /api/session/update to persist the workspace on the server."""
"""The profile switch path should avoid duplicate workspace persistence."""
idx = PANELS_JS.find('if (sessionInProgress)')
self.assertGreater(idx, -1)
block = PANELS_JS[idx:idx + 1000]
# Must patch the session on the backend too
self.assertIn('/api/session/update', block,
"The sessionInProgress branch must call /api/session/update "
"to persist the new workspace after newSession(false)")
self.assertNotIn('/api/session/update', block,
"newSession(false) receives S._profileSwitchWorkspace, so "
"a second /api/session/update is unnecessary")
def test_sync_topbar_before_render_session_list(self):
"""syncTopbar() should be called before renderSessionList()
+2 -2
View File
@@ -751,7 +751,7 @@ def test_streaming_restores_prior_reasoning_metadata_after_followup():
src = (REPO / 'api' / 'streaming.py').read_text()
assert "def _restore_reasoning_metadata(" in src, \
"streaming.py must define a helper to restore prior reasoning metadata"
assert "s.context_messages = _next_context_messages" in src, \
assert "_next_context_messages" in src and "s.context_messages" in src, \
"streaming.py must restore prior reasoning metadata into model context"
assert "s.messages = _merge_display_messages_after_agent_result(" in src, \
"streaming.py must merge restored result messages into the visible transcript"
@@ -764,7 +764,7 @@ def test_routes_restores_prior_reasoning_metadata_after_followup():
src = (REPO / 'api' / 'routes.py').read_text()
assert "_restore_reasoning_metadata" in src, \
"routes.py must import reasoning metadata restoration helper"
assert "s.context_messages = _next_context_messages" in src, \
assert "_next_context_messages" in src and "s.context_messages" in src, \
"routes.py must restore prior reasoning metadata into model context"
assert 's.messages = _merge_display_messages_after_agent_result(' in src, \
"routes.py must merge restored result messages into the visible transcript"
+36
View File
@@ -0,0 +1,36 @@
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def test_live_smd_writes_schedule_incremental_katex_rendering():
"""Live markdown deltas should render math before the terminal done event."""
assert "let _streamingKatexTimer=null" in MESSAGES_JS
assert "function _scheduleStreamingKatex()" in MESSAGES_JS
assert "setTimeout(()=>{" in MESSAGES_JS
assert "renderKatexBlocks(assistantBody)" in MESSAGES_JS
smd_write_idx = MESSAGES_JS.index("function _smdWrite(displayText, fade=false){")
done_idx = MESSAGES_JS.index("source.addEventListener('done'")
smd_write_block = MESSAGES_JS[smd_write_idx:done_idx]
assert "_scheduleStreamingKatex();" in smd_write_block
def test_streaming_katex_timer_is_cleared_when_smd_parser_ends():
"""The final done path should not leave a stale live KaTeX timer around."""
end_idx = MESSAGES_JS.index("function _smdEndParser(){")
write_idx = MESSAGES_JS.index("function _smdWrite(displayText, fade=false){")
end_block = MESSAGES_JS[end_idx:write_idx]
assert "if(_streamingKatexTimer){clearTimeout(_streamingKatexTimer);_streamingKatexTimer=null;}" in end_block
def test_katex_renderer_scans_live_and_settled_unrendered_nodes_under_container():
assert "function renderKatexBlocks(container){" in UI_JS
assert "const root=container||document;" in UI_JS
assert ".katex-block:not([data-rendered]),.katex-inline:not([data-rendered])," in UI_JS
assert "equation-block:not([data-rendered]),equation-inline:not([data-rendered])" in UI_JS
assert "const tagName=(el.tagName||'').toLowerCase();" in UI_JS
assert "const displayMode=el.dataset.katex==='display'||tagName==='equation-block';" in UI_JS
+137
View File
@@ -109,6 +109,143 @@ class TestGenerateTitleRawViaAuxTimeout(unittest.TestCase):
30.0,
)
def test_configured_provider_model_and_base_url_are_passed_to_aux_client(self):
"""Regression for #2235: task config must select the first title model.
If generate_title_raw_via_aux leaves provider/model/base_url as None,
agent.auxiliary_client.call_llm can fall back to the main chat model and
WebUI titles look like local first-message placeholders or unrelated
chat-model output instead of using auxiliary.title_generation.model.
"""
from api.streaming import generate_title_raw_via_aux
mock_resp = types.SimpleNamespace(
choices=[
types.SimpleNamespace(
message=types.SimpleNamespace(content='Configured Model Title'),
finish_reason='stop',
)
]
)
captured = {}
def fake_call_llm(**kwargs):
captured.update(kwargs)
return mock_resp
tg_config = {
'provider': 'openrouter',
'model': 'anthropic/claude-haiku-title',
'base_url': 'https://openrouter.ai/api/v1',
'timeout': 22.0,
}
with _patch_tg_config(tg_config):
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
result, status = generate_title_raw_via_aux(
user_text='Explain why the moon has phases.',
assistant_text='The moon has phases because we see different sunlit portions.',
)
self.assertEqual(result, 'Configured Model Title')
self.assertEqual(status, 'llm_aux')
self.assertEqual(captured.get('task'), 'title_generation')
self.assertEqual(captured.get('provider'), 'openrouter')
self.assertEqual(captured.get('model'), 'anthropic/claude-haiku-title')
self.assertEqual(captured.get('base_url'), 'https://openrouter.ai/api/v1')
self.assertEqual(captured.get('timeout'), 22.0)
def test_configured_api_key_is_passed_to_aux_client(self):
"""Regression: explicit title_generation api_key must be forwarded.
Hermes Agent does not fall back to auxiliary.title_generation.api_key
once WebUI passes explicit provider/model/base_url values, so WebUI
must forward the configured task api_key with the rest of the route.
"""
from api.streaming import generate_title_raw_via_aux
mock_resp = types.SimpleNamespace(
choices=[
types.SimpleNamespace(
message=types.SimpleNamespace(content='Configured Key Title'),
finish_reason='stop',
)
]
)
captured = {}
def fake_call_llm(**kwargs):
captured.update(kwargs)
return mock_resp
tg_config = {
'provider': '',
'model': 'gemma-4-31b-it',
'base_url': 'http://openrouter:4000/v1',
'api_key': 'test-title-api-key',
}
with _patch_tg_config(tg_config):
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
result, status = generate_title_raw_via_aux(
user_text='Summarize this title routing bug.',
assistant_text='The title model route needs its configured key.',
)
self.assertEqual(result, 'Configured Key Title')
self.assertEqual(status, 'llm_aux')
self.assertEqual(captured.get('task'), 'title_generation')
self.assertIsNone(captured.get('provider'))
self.assertEqual(captured.get('model'), 'gemma-4-31b-it')
self.assertEqual(captured.get('base_url'), 'http://openrouter:4000/v1')
self.assertEqual(captured.get('api_key'), 'test-title-api-key')
def test_configured_api_key_is_not_sent_to_caller_supplied_route(self):
"""Regression: title task keys must not leak to explicit fallback routes.
Some callers pass the active agent route into title generation. In that
path WebUI should still use title-generation prompts/timeouts, but it
must not attach an unrelated auxiliary.title_generation api_key to the
caller-supplied provider/model/base_url.
"""
from api.streaming import generate_title_raw_via_aux
mock_resp = types.SimpleNamespace(
choices=[
types.SimpleNamespace(
message=types.SimpleNamespace(content='Agent Route Title'),
finish_reason='stop',
)
]
)
captured = {}
def fake_call_llm(**kwargs):
captured.update(kwargs)
return mock_resp
tg_config = {
'provider': '',
'model': 'gemma-4-31b-it',
'base_url': 'http://openrouter:4000/v1',
'api_key': 'test-title-api-key',
}
with _patch_tg_config(tg_config):
with patch('agent.auxiliary_client.call_llm', side_effect=fake_call_llm, create=True):
result, status = generate_title_raw_via_aux(
user_text='Summarize this title routing bug.',
assistant_text='The caller route should not receive the task key.',
provider='custom:agent-route',
model='agent-title-model',
base_url='https://agent-route.example/v1',
)
self.assertEqual(result, 'Agent Route Title')
self.assertEqual(status, 'llm_aux')
self.assertEqual(captured.get('task'), 'title_generation')
self.assertEqual(captured.get('provider'), 'custom:agent-route')
self.assertEqual(captured.get('model'), 'agent-title-model')
self.assertEqual(captured.get('base_url'), 'https://agent-route.example/v1')
self.assertIsNone(captured.get('api_key'))
def test_integer_timeout_from_config(self):
"""Config timeout as int is coerced to float."""
self._run_with_config(
+14 -18
View File
@@ -273,10 +273,10 @@ class TestToolCallGroupingStatic:
"Compact settled transcript rendering should preserve Thinking cards after switching sessions."
)
assert "_thinkingActivityNode(thinkingText, false)" in render_fn, (
"Settled Thinking cards should render as collapsed timeline entries before related tools."
"Settled Thinking cards should render inside the compact Activity disclosure."
)
assert "anchorParent.insertBefore(thinkingNode, anchorRow)" in render_fn, (
"Settled Thinking cards should appear before their visible assistant process text."
assert "body.appendChild(_thinkingActivityNode(thinkingText, false))" in render_fn, (
"Settled Thinking cards should stay inside the same Activity body as the related tools."
)
assert ".agent-activity-thinking:not([data-live-thinking=\"1\"])" in render_fn, (
"Settled rerenders must remove previously inserted Thinking activity rows before rebuilding."
@@ -285,18 +285,18 @@ class TestToolCallGroupingStatic:
"The non-simplified path should preserve standalone settled thinking cards."
)
def test_live_visible_interim_text_splits_tool_bursts_not_thinking(self):
def test_live_visible_interim_text_keeps_single_activity_group(self):
live_thinking_fn = _function_body(UI_JS, "appendThinking")
live_tool_fn = _function_body(UI_JS, "appendLiveToolCard")
helper = _function_body(UI_JS, "ensureActivityGroup")
assert "isSimplifiedToolCalling()" in live_thinking_fn, (
"Live thinking should branch on the Compact tool activity toggle."
)
assert "body.insertBefore(row, body.firstChild)" not in live_thinking_fn, (
"Live thinking should not be moved into the top Activity dropdown."
)
assert "_thinkingActivityNode(thinkingText, false)" in live_thinking_fn, (
"Compact live thinking should render a collapsed Thinking card in the timeline."
"Compact live thinking should render inside the Activity disclosure."
)
assert "ensureActivityGroup(blocks,{live:true" in live_thinking_fn and "body.appendChild(row)" in live_thinking_fn, (
"Compact live thinking should share the same Activity body as live tool cards."
)
assert "removeAttribute('data-live-activity-current')" not in live_thinking_fn, (
"Reasoning/Thinking updates alone should not split consecutive tools into one-tool Activity rows."
@@ -312,22 +312,18 @@ class TestToolCallGroupingStatic:
)
finalize_fn = _function_body(UI_JS, "finalizeThinkingCard")
assert "turn.querySelector('.agent-activity-thinking[data-thinking-active=\"1\"]')" in finalize_fn, (
"Compact Thinking cards live directly in assistant-turn blocks, so finalization must clear the active marker from the whole turn, not only the tool group."
"Compact Thinking cards live inside the assistant turn, so finalization must clear the active marker from the whole turn."
)
assert "thinkingCards.filter" in live_thinking_fn and "setAttribute('data-thinking-active','1')" in live_thinking_fn, (
assert "body.querySelector('.agent-activity-thinking[data-thinking-active=\"1\"]')" in live_thinking_fn and "setAttribute('data-thinking-active','1')" in live_thinking_fn, (
"Compact live thinking should reactivate the latest existing Thinking card instead of stacking a new card after every tool boundary."
)
close_activity_fn = _function_body(MESSAGES_JS, "_closeCurrentLiveActivityGroup")
assert "data-live-activity-current" in close_activity_fn, (
"Visible interim assistant boundaries should close the previous live Activity burst."
)
reset_fn = _function_body(MESSAGES_JS, "_resetAssistantSegment")
assert "closeActivity" in reset_fn and "_closeCurrentLiveActivityGroup()" in reset_fn, (
"Assistant text reset and Activity burst closing should stay separate."
assert "_closeCurrentLiveActivityGroup" not in MESSAGES_JS and "closeActivity" not in reset_fn, (
"Assistant text resets should not carry a dead Activity-splitting path."
)
interim_match = re.search(r"source\.addEventListener\('interim_assistant',e=>\{(.*?)\n\s*\}\);", MESSAGES_JS, re.S)
assert interim_match and "_resetAssistantSegment({closeActivity:true});" in interim_match.group(1), (
"Visible interim assistant text should split the previous tool burst before the next tool starts."
assert interim_match and "_resetAssistantSegment({closeActivity:true});" not in interim_match.group(1), (
"Visible interim assistant text should not split Compact tool activity into multiple Activity rows."
)
tool_start_segment = MESSAGES_JS.split("source.addEventListener('tool',e=>{", 1)[1].split("source.addEventListener('tool_complete'", 1)[0]
assert "_resetAssistantSegment();" in tool_start_segment, (
+104
View File
@@ -237,6 +237,46 @@ class TestUpdateChecker:
assert result['behind'] == 1
assert result['branch'] == 'v0.51.35'
def test_detect_agent_version_reads_copied_source_tree(self, tmp_path, monkeypatch):
import api.updates as upd
agent_dir = tmp_path / 'hermes-agent'
package_dir = agent_dir / 'hermes_cli'
package_dir.mkdir(parents=True)
(package_dir / '__init__.py').write_text('__version__ = "0.14.0"\n', encoding='utf-8')
monkeypatch.setattr(upd, '_AGENT_DIR', str(agent_dir))
monkeypatch.setattr(upd, '_describe_git_version', lambda path: None)
monkeypatch.setattr(upd, '_detect_agent_version_from_gateway_health', lambda: None)
assert upd._detect_agent_version() == '0.14.0'
def test_detect_agent_version_falls_back_to_gateway_health(self, monkeypatch):
import api.updates as upd
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b'{"status":"ok","platform":"hermes-agent","version":"0.14.1"}'
seen = []
def fake_urlopen(url, timeout=0):
seen.append((url, timeout))
return FakeResponse()
monkeypatch.setattr(upd, '_AGENT_DIR', None)
monkeypatch.setenv('GATEWAY_HEALTH_URL', 'http://hermes-agent:8642/health')
monkeypatch.setattr(upd.urllib.request, 'urlopen', fake_urlopen)
assert upd._detect_agent_version() == '0.14.1'
assert seen == [('http://hermes-agent:8642/health', 0.75)]
class TestConflictError:
"""#813 — conflict error must include flag + recovery command."""
@@ -398,6 +438,8 @@ class TestSuccessfulUpdateReturnsRestartScheduled:
def fake_run(args, cwd, timeout=10):
if args[0] == 'fetch':
return '', True
if args[0] == 'tag':
return '', True
if args[:2] == ['status', '--porcelain']:
return '', True # clean tree
if args[:2] == ['rev-parse', '--abbrev-ref']:
@@ -418,6 +460,68 @@ class TestSuccessfulUpdateReturnsRestartScheduled:
"successful update must set restart_scheduled: True"
)
def test_apply_update_pulls_latest_release_tag_when_updates_are_release_based(
self, tmp_path, monkeypatch
):
import api.updates as upd
(tmp_path / '.git').mkdir()
ran = []
def fake_run(args, cwd, timeout=10):
ran.append(args)
if args[0] == 'fetch':
return '', True
if args[0] == 'tag':
return 'v0.51.106\nv0.51.105\nv0.51.104', True
if args[:2] == ['status', '--porcelain']:
return '', True
if args[0] == 'pull':
return 'Updating release tag', True
return '', True
monkeypatch.setattr(upd, '_run_git', fake_run)
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: None)
result = upd.apply_update('webui')
assert result['ok'] is True
assert ['fetch', 'origin', '--quiet', '--tags', '--force'] in ran
assert ['pull', '--ff-only', 'origin', 'v0.51.106'] in ran
assert ['rev-parse', '--abbrev-ref', '@{upstream}'] not in ran
def test_apply_update_falls_back_to_tracking_branch_without_release_tags(
self, tmp_path, monkeypatch
):
import api.updates as upd
(tmp_path / '.git').mkdir()
ran = []
def fake_run(args, cwd, timeout=10):
ran.append(args)
if args[0] == 'fetch':
return '', True
if args[0] == 'tag':
return '', True
if args[:2] == ['rev-parse', '--abbrev-ref']:
return 'fork/feature-branch', True
if args[:2] == ['status', '--porcelain']:
return '', True
if args[0] == 'pull':
return 'Already up to date.', True
return '', True
monkeypatch.setattr(upd, '_run_git', fake_run)
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: None)
result = upd.apply_update('agent')
assert result['ok'] is True
assert ['pull', '--ff-only', 'fork', 'feature-branch'] in ran
class TestApplyForceUpdate:
"""#813 — apply_force_update must reset hard and return ok."""
+22
View File
@@ -0,0 +1,22 @@
"""Source-level guards for update-check UI status handling."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
PANELS_JS = (REPO_ROOT / "static" / "panels.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
def test_manual_update_check_displays_api_errors_before_up_to_date():
"""Manual checks must not translate API error payloads into green success."""
error_idx = PANELS_JS.index("const errorParts=[]")
up_to_date_idx = PANELS_JS.index("settings_up_to_date")
assert error_idx < up_to_date_idx
assert "data.webui" in PANELS_JS
assert "data.agent" in PANELS_JS
assert "settings_update_check_failed')+': '+errorParts.join(', ')" in PANELS_JS
def test_update_error_formatter_strips_generic_fetch_prefix():
"""The UI should show the actionable git detail, not only 'fetch failed'."""
assert "function _formatUpdateCheckError(label,info)" in UI_JS
assert "replace(/^fetch failed" in UI_JS
+23 -16
View File
@@ -61,9 +61,8 @@ class TestApplyUpdateDiagnostics:
from api import updates
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
# Call sequence: upstream query, fetch
# Call sequence: fetch
mock_run_git.side_effect = [
('origin/master', True), # rev-parse @{upstream}
('', False), # fetch fails
]
result = updates._apply_update_inner('webui')
@@ -80,12 +79,11 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', False), # fetch fails
]
updates._apply_update_inner('webui')
# Only 2 calls: upstream query + fetch. No pull call.
assert mock_run_git.call_count == 2
# Only fetch is called. No target selection or pull call.
assert mock_run_git.call_count == 1
# ------------------------------------------------------------------
# Path 2: pull fails + diverged history
@@ -99,8 +97,9 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch succeeds
('', True), # no release tags
('origin/master', True), # upstream query
('', True), # status --porcelain (clean)
('Not possible to fast-forward, aborting.', False), # pull fails
]
@@ -119,8 +118,9 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/feat/my-feature', True), # upstream query
('', True), # fetch
('', True), # no release tags
('origin/feat/my-feature', True), # upstream query
('', True), # status (clean)
('Your branch and origin have diverged.', False), # pull
]
@@ -137,9 +137,10 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('origin/master', True),
('', True),
('DIVERGED from upstream', False),
]
result = updates._apply_update_inner('webui')
@@ -159,8 +160,9 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch
('', True), # no release tags
('origin/master', True), # upstream query
('', True), # status (clean)
('There is no tracking information for the current branch.', False), # pull
]
@@ -178,9 +180,10 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('origin/master', True),
('', True),
('fatal: The current branch local does not track a remote branch.', False),
]
result = updates._apply_update_inner('webui')
@@ -196,9 +199,10 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/main', True),
('', True),
('', True),
('origin/main', True),
('', True),
('no tracking information', False),
]
result = updates._apply_update_inner('webui')
@@ -219,9 +223,10 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('origin/master', True),
('', True),
(long_error, False),
]
result = updates._apply_update_inner('webui')
@@ -240,9 +245,10 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('origin/master', True),
('', True),
('', False), # pull fails with empty output
]
result = updates._apply_update_inner('webui')
@@ -258,9 +264,10 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}.REPO_ROOT', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', True),
('', True),
('origin/master', True),
('', True),
('Some unrecognized git error', False),
]
result = updates._apply_update_inner('webui')
@@ -285,8 +292,9 @@ class TestApplyUpdateDiagnostics:
patch(f'{_MODULE}._update_cache', fake_cache), \
patch(f'{_MODULE}._cache_lock'):
mock_run_git.side_effect = [
('origin/master', True), # upstream query
('', True), # fetch succeeds
('', True), # no release tags
('origin/master', True), # upstream query
('', True), # status (clean working tree)
('Already up to date.', True), # pull succeeds
]
@@ -306,7 +314,6 @@ class TestApplyUpdateDiagnostics:
with patch(f'{_MODULE}._AGENT_DIR', tmp_path), \
patch(f'{_MODULE}._run_git') as mock_run_git:
mock_run_git.side_effect = [
('origin/master', True),
('', False), # fetch fails
]
result = updates._apply_update_inner('agent')
+209
View File
@@ -4,6 +4,81 @@ from unittest.mock import MagicMock, patch
import api.updates as updates
def _fake_git_for_release_fetch_failure(args, cwd, timeout=10):
if args == ['fetch', 'origin', '--tags', '--force']:
return 'would clobber existing tag v0.50.294', False
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v0.51.106\nv0.51.103', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v0.51.103', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-webui.git', True
raise AssertionError(f'unexpected git args: {args!r}')
def test_check_repo_reports_release_gap_even_when_tag_fetch_fails(tmp_path):
"""A tag fetch error must not collapse the UI state to "up to date"."""
(tmp_path / '.git').mkdir()
with patch.object(updates, '_run_git', side_effect=_fake_git_for_release_fetch_failure):
info = updates._check_repo(tmp_path, 'webui')
assert info is not None
assert info['behind'] == 1
assert info['current_version'] == 'v0.51.103'
assert info['latest_version'] == 'v0.51.106'
assert info['stale_check'] is True
assert 'would clobber existing tag' in info['error']
def test_check_repo_redacts_credentialed_fetch_failure(tmp_path):
"""Update-check errors must not expose credentials from git remotes."""
(tmp_path / '.git').mkdir()
secret = 'ghp_' + 'A' * 36
raw_error = (
"fatal: unable to access "
f"'https://ash:{secret}@github.com/private/repo.git/': "
"Authentication failed"
)
def fake_git(args, cwd, timeout=10):
if args == ['fetch', 'origin', '--tags', '--force']:
return raw_error, False
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
info = updates._check_repo(tmp_path, 'webui')
assert info is not None
assert info['behind'] is None
assert info['stale_check'] is True
assert secret not in info['error']
assert 'ash:' not in info['error']
assert '<redacted>' in info['error']
assert 'Authentication failed' in info['error']
def test_check_repo_fetch_failure_without_tags_is_not_up_to_date(tmp_path):
"""If release tags cannot be read, behind is unknown rather than zero."""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['fetch', 'origin', '--tags', '--force']:
return 'network unavailable', False
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
info = updates._check_repo(tmp_path, 'webui')
assert info is not None
assert info['behind'] is None
assert info['stale_check'] is True
assert info['error'] == 'fetch failed: network unavailable'
def test_run_git_returns_stderr_on_failure(tmp_path):
"""When a git command fails, _run_git should return stderr (not empty string)."""
with patch('subprocess.run') as mock_run:
@@ -51,3 +126,137 @@ def test_split_remote_ref_splits_tracking_ref():
assert updates._split_remote_ref('origin/master') == ('origin', 'master')
assert updates._split_remote_ref('origin/feature/foo') == ('origin', 'feature/foo')
assert updates._split_remote_ref('master') == (None, 'master')
# ---------------------------------------------------------------------------
# #2756 — Update check fails with "would clobber existing tag" when an
# upstream release tag was moved.
#
# All three fetch-tag call sites in api/updates.py must use --force so the
# WebUI (a release-tracking consumer that never pushes tags) always defers
# to whatever the remote says a release tag points to. Without --force,
# any remote re-tag (e.g. squash-merge that re-points a release tag at a
# new SHA) jams the update path indefinitely.
# ---------------------------------------------------------------------------
def test_check_repo_fetches_tags_with_force(tmp_path):
"""_check_repo must pass --force to git fetch --tags (regression for #2756)."""
(tmp_path / '.git').mkdir()
seen_args = []
def fake_git(args, cwd, timeout=10):
seen_args.append(args)
if args[:2] == ['fetch', 'origin']:
# Force a fetch failure path so we don't have to mock the rest of
# the release/branch logic; the assertion is about the args shape.
return '', False
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return '', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
updates._check_repo(tmp_path, 'webui')
fetch_calls = [a for a in seen_args if a[:2] == ['fetch', 'origin']]
assert fetch_calls, 'expected at least one fetch call'
for call in fetch_calls:
assert '--tags' in call, f'fetch should include --tags: {call!r}'
assert '--force' in call, (
f'fetch should include --force to recover from remote re-tags '
f'(see #2756): {call!r}'
)
def test_apply_force_update_fetches_tags_with_force(tmp_path):
"""apply_force_update must pass --force to git fetch --tags (#2756)."""
(tmp_path / '.git').mkdir()
seen_args = []
def fake_git(args, cwd, timeout=10):
seen_args.append(args)
if args[:2] == ['fetch', 'origin']:
return '', False # short-circuit; we just want the args shape.
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git), \
patch.object(updates, 'REPO_ROOT', tmp_path), \
patch.object(updates, '_active_stream_count', return_value=0):
updates.apply_force_update('webui')
fetch_calls = [a for a in seen_args if a[:2] == ['fetch', 'origin']]
assert fetch_calls, 'expected at least one fetch call'
for call in fetch_calls:
assert '--tags' in call and '--force' in call, (
f'apply_force_update fetch should be --tags --force (see #2756): {call!r}'
)
def test_apply_update_fetches_tags_with_force(tmp_path):
"""apply_update must pass --force to git fetch --tags (#2756)."""
(tmp_path / '.git').mkdir()
seen_args = []
def fake_git(args, cwd, timeout=10):
seen_args.append(args)
if args[:2] == ['fetch', 'origin']:
return '', False # short-circuit on fetch failure.
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git), \
patch.object(updates, 'REPO_ROOT', tmp_path), \
patch.object(updates, '_active_stream_count', return_value=0):
updates.apply_update('webui')
fetch_calls = [a for a in seen_args if a[:2] == ['fetch', 'origin']]
assert fetch_calls, 'expected at least one fetch call'
for call in fetch_calls:
assert '--tags' in call and '--force' in call, (
f'apply_update fetch should be --tags --force (see #2756): {call!r}'
)
def test_check_repo_recovers_from_remote_retag(tmp_path):
"""End-to-end: a remote-retag scenario should now succeed (#2756).
Before the fix, `git fetch origin --tags` would return "would clobber
existing tag v0.51.5" indefinitely. With --force the fetch succeeds and
the regular up-to-date / behind path runs.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
# The --force flag makes the fetch succeed even when local tags
# diverge from remote tags. Refuse to honor a plain --tags fetch
# (no --force) so the test fails loudly if the regression returns.
if args == ['fetch', 'origin', '--tags']:
return (
' ! [rejected] v0.51.5 -> v0.51.5 '
'(would clobber existing tag)'
), False
if args == ['fetch', 'origin', '--tags', '--force']:
return '', True
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v0.51.110\nv0.51.109', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v0.51.110', True
if args == ['describe', '--tags', '--always']:
return 'v0.51.110', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-webui.git', True
# Branch-check fallback is fine to no-op for this assertion.
return '', True
with patch.object(updates, '_run_git', side_effect=fake_git):
info = updates._check_repo(tmp_path, 'webui')
assert info is not None
assert info.get('error') is None, (
f'expected clean update check, got error: {info.get("error")!r}'
)
assert info.get('stale_check') is not True, (
'fetch with --force should have succeeded, not marked stale'
)
@@ -2,6 +2,7 @@ from pathlib import Path
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
UI_JS = Path("static/ui.js").read_text(encoding="utf-8")
def test_load_session_supports_force_reload_for_external_refresh():
@@ -27,6 +28,32 @@ def test_active_session_external_refresh_has_focus_and_visibility_hooks():
assert "ensureActiveSessionExternalRefreshPoll();" in SESSIONS_JS
def test_session_list_external_refresh_uses_sse_invalidation_not_polling():
"""New sessions should refresh the sidebar from server invalidation events."""
assert "async function refreshSessionList(reason='manual', opts={})" in SESSIONS_JS
assert "function ensureSessionEventsSSE()" in SESSIONS_JS
assert "new EventSource('api/sessions/events')" in SESSIONS_JS
assert "addEventListener('sessions_changed'" in SESSIONS_JS
assert "function _scheduleSessionEventsRefresh(reason)" in SESSIONS_JS
assert "_sessionEventsNeedsRefreshOnOpen = true" in SESSIONS_JS
assert "void refreshSessionList('reconnect')" in SESSIONS_JS
assert "renderSessionList({deferWhileInteracting:!force})" in SESSIONS_JS
assert "const refreshActive = !!(opts && opts.refreshActive)" in SESSIONS_JS
assert "if(refreshActive) await refreshActiveSessionIfExternallyUpdated(reason||'session-list')" in SESSIONS_JS
assert "_sessionListRefreshPendingReason = reason || 'session-list'" in SESSIONS_JS
assert "if(pendingReason) _scheduleSessionEventsRefresh(pendingReason)" in SESSIONS_JS
assert "ensureSessionEventsSSE();" in SESSIONS_JS
assert "document._hermesSessionEventsVisibilityHook" in SESSIONS_JS
ensure_fn = SESSIONS_JS[SESSIONS_JS.find("function ensureSessionEventsSSE()") :]
assert ensure_fn.find("document._hermesSessionEventsVisibilityHook") < ensure_fn.find("document.hidden) return")
assert "_sessionListExternalRefreshMs" not in SESSIONS_JS
def test_pwa_pull_to_refresh_refreshes_session_list_not_page_when_available():
assert "window.refreshSessionList('pull', {force:true, refreshActive:true})" in UI_JS
assert "Promise.resolve(window.refreshSessionList('pull', {force:true, refreshActive:true})).catch(()=>{}).finally(_ptrReset)" in UI_JS
def test_force_reload_clears_stale_blocking_prompts_immediately():
"""External refresh should not leave old approval/clarify modals blocking the composer.
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more