Merge branch 'master' into fix/session-list-touch-actions

This commit is contained in:
dobby-d-elf committed 2026-05-23 11:21:26 -06:00
commit cf2eb9214c
68 files changed
+7824 -196

No files matched your search

+152
View File
@@ -3,6 +3,158 @@
## [Unreleased]
## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored)
### Fixed
- **PR #2773** by @nesquena-hermes — fix(chat): rename `_inflightStateLimits()` in `static/ui.js` to `_getInflightStateLimits()` so it no longer collides with the `window._inflightStateLimits` config object set in `static/boot.js`. Closes #2771. The v0.51.117 in-flight-recovery quota fix (#2766) declared a top-level helper with the same name as a window-attached config object; because top-level `function foo(){…}` declarations in classic (non-module) scripts attach to `window`, boot.js's `window._inflightStateLimits = {…}` assignment overwrote the function reference before any session could send. Every new chat broke on first `send()` with `TypeError: _inflightStateLimits is not a function`, leaving v0.51.117 effectively unusable. Renamed the function only (the public-ish window key is unchanged) and updated all 4 call sites. **New regression test `tests/test_window_function_collision.py` scans every static JS file for top-level `function NAME()` declarations whose name is also the target of `window.NAME = {…}` / `= <number>`, the exact shape that broke #2715 (`_pinnedSessionsLimit` in v0.51.106) and #2771 (`_inflightStateLimits` in v0.51.117). The test fails loudly with a precise file:name diagnostic if the bug class returns. Verified end-to-end against the live browser before merge: `_getInflightStateLimits()` returns the limits object and `saveInflightState()` persists to localStorage without throwing.
## [v0.51.117] — 2026-05-22 — Release CO (stage-pr2766 — 1-PR — in-flight recovery storage quota-safe)
### Fixed
- **PR #2766** by @george-andraws — Make in-flight recovery storage quota-safe so a full browser `localStorage` budget no longer blocks chat submission with `QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'hermes-webui-inflight' exceeded the quota.` The fix has four parts: (1) compact recovery snapshots before writing to keep only recent messages, tool calls, uploaded-file metadata, stream id, and updated timestamp; (2) truncate individual large strings per field (`60000` chars default) and prune the serialized payload (`1500000` chars default); (3) catch quota errors specifically on `markInflight()`, drop the larger `hermes-webui-inflight-state` key, then retry the tiny marker write; (4) expose 5 new configurable settings (`inflight_state_max_sessions`, `_max_messages`, `_max_tool_calls`, `_max_string_chars`, `_max_json_chars`) with int-range validation so operators can tune the budget. Self-healing on the very first chat submit after upgrade for users with already-quota-exhausted storage — no manual reload required. Graceful degradation if storage is still too full even after compaction (clears recovery snapshots but never blocks chat submit).
## [v0.51.116] — 2026-05-22 — Release CN (stage-pr2676 — 1-PR — per-skill enable/disable toggle in Skills panel, CLI-parity with `hermes skills config`)
### Added
- **PR #2676** by @lucasrc — Each skill in the Skills panel now has a toggle pill (enabled/disabled) so users can turn individual skills on or off directly from the WebUI without editing `config.yaml`. Achieves parity with the existing `hermes skills config` CLI subcommand (interactive TUI that toggles `skills.disabled` in config). The disabled state is mirrored through to `skills.platform_disabled.webui` when that key is present. Disabled skills remain visible in the panel (muted via `opacity: .45`) instead of being filtered out, so users can re-enable them later. New endpoint: `POST /api/skills/toggle` validates the skill exists in the filesystem before mutating config, wraps the YAML read-modify-write under the existing `_cfg_lock` for thread safety, and calls `reload_config()` so the change takes effect immediately. Toggle pill uses theme variables (`--accent-bg-strong`, `--accent`, `--border`, `--muted`, `--accent-text`) so it adapts automatically to each skin: gold for default, red for ares, blue for poseidon, purple for sisyphus, grey for mono — verified empirically across light + dark variants. i18n keys (`skill_enabled`, `skill_disabled`, `skill_toggle_failed`) translated across all 10 locales. Default-state safety: fresh installs (no `skills.disabled` key in config) return `disabled: False` for every skill — no regression risk for new users.
## [v0.51.115] — 2026-05-22 — Release CM (stage-pr2731 — 1-PR — clarify prompt collapse/expand with chevron-icon polish)
### Added
- **PR #2731** by @Michaelyklam — Clarification prompts now include a compact Collapse/Expand control so users can temporarily shrink a blocking decision card and reread the chat context behind it before responding. The toggle uses Lucide chevron icons (chevron-down expanded → click to collapse, chevron-up collapsed → click to expand) and a small circular pill matching the existing composer-button design language. The collapsed card sits cleanly above the composer at every tested viewport (desktop 1920×1080, mobile iPhone 14 390×844) without edge clipping. New clarification prompts still open expanded so users notice them.
## [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)
+11 -4
View File
@@ -433,10 +433,13 @@ def read_importable_agent_session_rows(
if result_limit == 0:
return []
# The sidebar only needs a small visible window. Bound the expensive
# messages join to a recent-session candidate set instead of
# messages join to a recent-activity candidate set instead of
# aggregating every historical Hermes state.db session before
# slicing in Python. Oversampling preserves room for hidden
# compression segments or other rows filtered after projection.
# 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"""
@@ -444,7 +447,11 @@ def read_importable_agent_session_rows(
SELECT s.id
FROM sessions s
WHERE {' AND '.join(where_clauses)}
ORDER BY s.started_at DESC
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}
+68
View File
@@ -4003,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:
@@ -4311,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
@@ -4323,6 +4354,13 @@ _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
"inflight_state_max_sessions": 8, # max active-stream recovery snapshots kept in browser localStorage
"inflight_state_max_messages": 24, # max recent messages kept per recovery snapshot
"inflight_state_max_tool_calls": 48, # max recent tool-call records kept per recovery snapshot
"inflight_state_max_string_chars": 60000, # max string length kept inside a recovery snapshot field
"inflight_state_max_json_chars": 1500000, # max serialized recovery snapshot payload before pruning
"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"
@@ -4450,10 +4488,19 @@ _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),
"inflight_state_max_sessions": (1, 25),
"inflight_state_max_messages": (1, 100),
"inflight_state_max_tool_calls": (1, 200),
"inflight_state_max_string_chars": (1000, 500000),
"inflight_state_max_json_chars": (100000, 4000000),
}
_SETTINGS_BOOL_KEYS = {
"onboarding_completed",
"show_token_usage",
"show_quota_chip",
"hide_empty_state_suggestions",
"show_tps",
"fade_text_effect",
"show_cli_sessions",
@@ -4509,11 +4556,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)
+138 -7
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:
@@ -1827,6 +1848,19 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
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(
@@ -1835,6 +1869,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None, p
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,
@@ -2983,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 [])
@@ -2994,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)
@@ -3001,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
@@ -3012,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
@@ -3027,6 +3156,8 @@ def merge_session_messages_append_only(sidecar_messages: list, state_messages: l
continue
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
@@ -3095,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(
+12
View File
@@ -1996,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,
+673 -12
View File
@@ -231,7 +231,7 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict
if not skill_matches_platform(frontmatter):
continue
name = frontmatter.get("name", skill_dir.name)[:64]
if name in seen_names or name in disabled:
if name in seen_names:
continue
description = frontmatter.get("description", "")
if not description:
@@ -248,6 +248,7 @@ def _skills_list_from_dir(skills_dir: Path, category: str | None = None) -> dict
"name": name,
"description": description,
"category": _skill_category_from_path(skill_md, search_dirs),
"disabled": name in disabled,
}
)
except (UnicodeDecodeError, PermissionError) as e:
@@ -939,6 +940,11 @@ from api.config import (
get_webui_session_save_mode,
STREAM_GOAL_RELATED,
PENDING_GOAL_CONTINUATION,
_get_config_path,
_load_yaml_config_file,
_save_yaml_config_file,
reload_config,
_cfg_lock,
)
from api.helpers import (
require,
@@ -1512,7 +1518,12 @@ def _resolve_compatible_session_model_state(
# qualifier — qualified strings require the catalog to decide whether
# the qualifier matches the active provider (see slow path below).
bare_model, explicit_provider = _split_provider_qualified_model(model)
if not explicit_provider:
model_prefix = model.split("/", 1)[0].strip().lower() if "/" in model else ""
stale_codex_openai_slash_id = (
requested_provider == "openai-codex"
and model_prefix == "openai"
)
if not explicit_provider and not stale_codex_openai_slash_id:
return model, requested_provider, False
catalog = get_available_models()
@@ -1533,7 +1544,14 @@ def _resolve_compatible_session_model_state(
bare_for_context, explicit_provider = _split_provider_qualified_model(model)
if requested_provider and not explicit_provider:
return model, requested_provider, False
model_prefix = model.split("/", 1)[0].strip().lower() if "/" in model else ""
stale_codex_openai_slash_id = (
raw_active_provider == "openai-codex"
and requested_provider == "openai-codex"
and model_prefix == "openai"
)
if not stale_codex_openai_slash_id:
return model, requested_provider, False
if model.startswith("@") and ":" in model:
provider_raw = explicit_provider or ""
@@ -1643,7 +1661,7 @@ def _resolve_compatible_session_model_state(
if (
raw_active_provider == "openai-codex"
and model_provider == "openai"
and requested_provider is None
and requested_provider in {None, "openai-codex"}
and default_model
):
# Persist provider_context = "openai-codex" unconditionally on this
@@ -2227,6 +2245,7 @@ from api.workspace import (
get_last_workspace,
set_last_workspace,
list_dir,
dir_signature,
list_workspace_suggestions,
read_file_content,
safe_resolve_ws,
@@ -3299,6 +3318,13 @@ def _plugin_visibility_payload(manager=None) -> dict:
four lifecycle hook names called out by the Settings visibility MVP. It
never includes plugin source paths, callback names, callback reprs, or raw
load errors because those can contain private filesystem details.
Exclusive plugins (e.g. memory providers) are activated through their
category's ``<category>.provider`` config, not through ``plugins.enabled``.
Their ``loaded.enabled`` stays False by design and they register hooks
outside the four visibility hooks below. The payload surfaces ``kind``
and ``activation`` so the panel can render them distinctly instead of
mislabeling them as "Disabled" with no hooks (issue #2659).
"""
manager = manager or _get_plugin_manager_for_visibility()
manager.discover_and_load(force=False)
@@ -3316,6 +3342,14 @@ def _plugin_visibility_payload(manager=None) -> dict:
name = _clean_plugin_visibility_text(getattr(manifest, "name", "") or plugin_key, limit=120)
version = _clean_plugin_visibility_text(getattr(manifest, "version", ""), limit=80)
description = _clean_plugin_visibility_text(getattr(manifest, "description", ""), limit=280)
kind = _clean_plugin_visibility_text(getattr(manifest, "kind", "") or "standalone", limit=40)
enabled_flag = bool(getattr(loaded, "enabled", False))
if kind == "exclusive":
activation = "exclusive"
elif kind == "model-provider" and enabled_flag:
activation = "provider"
else:
activation = "enabled" if enabled_flag else "disabled"
registered = []
for hook in list(getattr(manifest, "provides_hooks", []) or []) + list(getattr(loaded, "hooks_registered", []) or []):
hook_name = str(hook or "").strip()
@@ -3327,7 +3361,11 @@ def _plugin_visibility_payload(manager=None) -> dict:
"key": plugin_key or name,
"version": version,
"description": description,
"enabled": bool(getattr(loaded, "enabled", False)),
# `enabled` is preserved for back-compat with older WebUI clients
# that key off it directly. New clients should prefer `activation`.
"enabled": enabled_flag,
"kind": kind,
"activation": activation,
"hooks": registered,
})
@@ -4147,6 +4185,15 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/list":
return _handle_list_dir(handler, parsed)
if parsed.path == "/api/git/status":
return _handle_git_status(handler, parsed)
if parsed.path == "/api/git/branches":
return _handle_git_branches(handler, parsed)
if parsed.path == "/api/git/diff":
return _handle_git_diff(handler, parsed)
if parsed.path == "/api/personalities":
# Read personalities from config.yaml agent.personalities section
# (matches hermes-agent CLI behavior, not filesystem SOUL.md approach)
@@ -4178,9 +4225,22 @@ def handle_get(handler, parsed) -> bool:
s = get_session(sid)
except KeyError:
return bad(handler, "Session not found", 404)
from api.workspace import git_info_for_workspace
from api.workspace_git import GitWorkspaceError, git_status
info = git_info_for_workspace(Path(s.workspace))
try:
status = git_status(Path(s.workspace))
except GitWorkspaceError as e:
return _git_bad(handler, e)
totals = status.get("totals") or {}
info = None if not status.get("is_git") else {
"branch": status.get("branch"),
"dirty": totals.get("changed", 0),
"modified": (totals.get("staged", 0) or 0) + (totals.get("unstaged", 0) or 0),
"untracked": totals.get("untracked", 0),
"ahead": status.get("ahead", 0),
"behind": status.get("behind", 0),
"is_git": True,
}
return j(handler, {"git": info})
if parsed.path == "/api/commands":
@@ -5328,6 +5388,43 @@ def handle_post(handler, parsed) -> bool:
with cron_profile_context():
return _handle_cron_resume(handler, body)
# ── Git workspace ops (POST) ──
if parsed.path == "/api/git/stage":
return _handle_git_stage(handler, body)
if parsed.path == "/api/git/unstage":
return _handle_git_unstage(handler, body)
if parsed.path == "/api/git/discard":
return _handle_git_discard(handler, body)
if parsed.path == "/api/git/commit-message":
return _handle_git_commit_message(handler, body)
if parsed.path == "/api/git/commit-message-selected":
return _handle_git_commit_message_selected(handler, body)
if parsed.path == "/api/git/commit":
return _handle_git_commit(handler, body)
if parsed.path == "/api/git/commit-selected":
return _handle_git_commit_selected(handler, body)
if parsed.path == "/api/git/fetch":
return _handle_git_remote_action(handler, body, "fetch")
if parsed.path == "/api/git/pull":
return _handle_git_remote_action(handler, body, "pull")
if parsed.path == "/api/git/push":
return _handle_git_remote_action(handler, body, "push")
if parsed.path == "/api/git/checkout":
return _handle_git_checkout(handler, body)
if parsed.path == "/api/git/stash-checkout":
return _handle_git_stash_checkout(handler, body)
# ── File ops (POST) ──
if parsed.path == "/api/file/delete":
return _handle_file_delete(handler, body)
@@ -5394,6 +5491,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/skills/delete":
return _handle_skill_delete(handler, body)
if parsed.path == "/api/skills/toggle":
return _handle_skill_toggle(handler, body)
# ── Memory (POST) ──
if parsed.path == "/api/memory/write":
return _handle_memory_write(handler, body)
@@ -5676,8 +5776,9 @@ def handle_post(handler, parsed) -> bool:
if getattr(existing, "pinned", False) and not getattr(existing, "archived", False)
)
pinned_ids.discard(body["session_id"])
if len(pinned_ids) >= 3:
return bad(handler, "Up to 3 sessions can be pinned. Unpin one before pinning another.", 400)
pinned_sessions_limit = int(load_settings().get("pinned_sessions_limit", 3) or 3)
if len(pinned_ids) >= pinned_sessions_limit:
return bad(handler, f"Up to {pinned_sessions_limit} sessions can be pinned. Unpin one before pinning another.", 400)
# Mark in-memory pin state under LOCK so concurrent pin
# requests see the increment immediately, even before
# save() finishes flushing to disk.
@@ -6242,11 +6343,14 @@ def _handle_list_dir(handler, parsed):
except Exception:
return bad(handler, "Session not found", 404)
try:
rel_path = qs.get("path", ["."])[0]
entries = list_dir(Path(workspace), rel_path)
return j(
handler,
{
"entries": list_dir(Path(workspace), qs.get("path", ["."])[0]),
"path": qs.get("path", ["."])[0],
"entries": entries,
"signature": dir_signature(Path(workspace), rel_path, entries),
"path": rel_path,
},
)
except (FileNotFoundError, ValueError) as e:
@@ -8753,6 +8857,488 @@ def _handle_cron_resume(handler, body):
return bad(handler, "Job not found", 404)
def _git_session(handler, session_id: str):
if not session_id:
bad(handler, "session_id required")
return None
try:
return get_session(session_id)
except KeyError:
bad(handler, "Session not found", 404)
return None
def _git_session_workspace(handler, session_id: str):
session = _git_session(handler, session_id)
if session is None:
return None
return Path(session.workspace)
def _git_session_and_workspace(handler, session_id: str):
session = _git_session(handler, session_id)
if session is None:
return None, None
return session, Path(session.workspace)
def _git_locked_by_active_stream(session) -> bool:
stream_id = getattr(session, "active_stream_id", None)
if not stream_id:
return False
try:
from api.config import STREAMS, STREAMS_LOCK
with STREAMS_LOCK:
return stream_id in STREAMS
except Exception:
return False
def _git_reject_destructive_if_unsafe(handler, session) -> bool:
from api.workspace_git import (
GitWorkspaceError,
WORKSPACE_GIT_DESTRUCTIVE_ENV,
workspace_git_destructive_enabled,
)
if not workspace_git_destructive_enabled():
_git_bad(
handler,
GitWorkspaceError(
f"Destructive workspace Git operations are disabled. Set {WORKSPACE_GIT_DESTRUCTIVE_ENV}=1 to enable them.",
"destructive_git_disabled",
),
status=403,
)
return True
if _git_locked_by_active_stream(session):
_git_bad(
handler,
GitWorkspaceError(
"A session run is active. Wait for it to finish before running this Git operation.",
"active_stream",
),
status=409,
)
return True
return False
def _handle_git_status(handler, parsed):
qs = parse_qs(parsed.query)
workspace = _git_session_workspace(handler, qs.get("session_id", [""])[0])
if workspace is None:
return True
try:
from api.workspace_git import GitWorkspaceError, git_status
return j(handler, {"git": git_status(workspace)})
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_branches(handler, parsed):
qs = parse_qs(parsed.query)
workspace = _git_session_workspace(handler, qs.get("session_id", [""])[0])
if workspace is None:
return True
try:
from api.workspace_git import GitWorkspaceError, git_branches
return j(handler, {"branches": git_branches(workspace)})
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_diff(handler, parsed):
qs = parse_qs(parsed.query)
workspace = _git_session_workspace(handler, qs.get("session_id", [""])[0])
if workspace is None:
return True
path = qs.get("path", [""])[0]
kind = qs.get("kind", ["unstaged"])[0]
if not path:
return bad(handler, "path required")
try:
from api.workspace_git import GitWorkspaceError, git_diff
return j(handler, {"diff": git_diff(workspace, path, kind)})
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _git_bad(handler, err, status: int = 400):
return j(
handler,
{
"error": _sanitize_error(err),
"code": getattr(err, "code", "git_failed") or "git_failed",
},
status=status,
)
def _git_paths_from_body(body) -> list[str]:
raw_paths = body.get("paths")
if raw_paths is None and body.get("path"):
raw_paths = [body.get("path")]
if isinstance(raw_paths, str):
raw_paths = [raw_paths]
if not isinstance(raw_paths, list):
raise ValueError("paths must be a list")
return [str(path) for path in raw_paths]
def _handle_git_stage(handler, body):
try:
require(body, "session_id")
paths = _git_paths_from_body(body)
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_stage
return j(handler, {"ok": True, "git": git_stage(workspace, paths)})
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_unstage(handler, body):
try:
require(body, "session_id")
paths = _git_paths_from_body(body)
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_unstage
return j(handler, {"ok": True, "git": git_unstage(workspace, paths)})
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_discard(handler, body):
try:
require(body, "session_id")
paths = _git_paths_from_body(body)
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_discard
return j(
handler,
{
"ok": True,
"git": git_discard(
workspace,
paths,
delete_untracked=bool(body.get("delete_untracked")),
),
},
)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _llm_git_commit_message(system_prompt: str, user_prompt: str, session=None) -> str:
from api import profiles as profiles_api
active_profile = profiles_api.get_active_profile_name() or "default"
with profiles_api.profile_env_for_background_worker(
active_profile,
"git commit message",
logger_override=logger,
):
from api.config import (
get_effective_default_model,
model_with_provider_context,
resolve_custom_provider_connection,
resolve_model_provider,
)
session_model = str(getattr(session, "model", "") or "").strip()
session_provider = str(getattr(session, "model_provider", "") or "").strip() or None
model_for_resolution = (
model_with_provider_context(session_model, session_provider)
if session_model
else get_effective_default_model()
)
_main_model, _main_provider, _main_base_url = resolve_model_provider(model_for_resolution)
_main_api_key = None
try:
from api.oauth import resolve_runtime_provider_with_anthropic_env_lock
from hermes_cli.runtime_provider import resolve_runtime_provider
_rt = resolve_runtime_provider_with_anthropic_env_lock(
resolve_runtime_provider,
requested=_main_provider,
)
_main_api_key = _rt.get("api_key")
if not _main_provider:
_main_provider = _rt.get("provider")
if not _main_base_url:
_main_base_url = _rt.get("base_url")
except Exception as _e:
logger.debug("git commit message runtime provider resolution failed: %s", _e)
if isinstance(_main_provider, str) and _main_provider.startswith("custom:"):
_cp_key, _cp_base = resolve_custom_provider_connection(_main_provider)
if not _main_api_key and _cp_key:
_main_api_key = _cp_key
if not _main_base_url and _cp_base:
_main_base_url = _cp_base
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
main_runtime = {
"provider": _main_provider,
"model": _main_model,
"base_url": _main_base_url,
"api_key": _main_api_key,
}
try:
from agent.auxiliary_client import get_text_auxiliary_client
aux_client, aux_model = get_text_auxiliary_client(
"compression",
main_runtime=main_runtime,
)
if aux_client is not None and aux_model:
response = aux_client.chat.completions.create(
model=aux_model,
messages=messages,
)
return str(response.choices[0].message.content or "").strip()
except Exception as _e:
logger.debug("git commit message auxiliary model failed; falling back to main model: %s", _e)
from run_agent import AIAgent
agent = AIAgent(
model=_main_model,
provider=_main_provider,
base_url=_main_base_url,
api_key=_main_api_key,
platform="webui",
quiet_mode=True,
enabled_toolsets=[],
session_id=f"git-commit-message-{uuid.uuid4().hex[:8]}",
)
result = agent.run_conversation(
user_message=user_prompt,
system_message=system_prompt,
conversation_history=[],
task_id=f"git-commit-message-{uuid.uuid4().hex[:8]}",
)
return str(result.get("final_response") or "").strip()
def _handle_git_commit_message(handler, body):
from api.workspace_git import (
GitWorkspaceError,
clean_generated_commit_message,
staged_commit_message_prompt,
)
try:
require(body, "session_id")
session = get_session(body["session_id"])
workspace = Path(session.workspace)
prompt = staged_commit_message_prompt(workspace)
message = clean_generated_commit_message(
_llm_git_commit_message(prompt["system_prompt"], prompt["user_prompt"], session=session)
)
if not message:
raise GitWorkspaceError("No commit message was generated")
return j(handler, {"ok": True, "message": message, "truncated": bool(prompt.get("truncated"))})
except KeyError:
return bad(handler, "Session not found", 404)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
except Exception as e:
logger.exception("git commit message generation failed")
return bad(handler, _sanitize_error(e), 500)
def _handle_git_commit_message_selected(handler, body):
from api.workspace_git import (
GitWorkspaceError,
clean_generated_commit_message,
selected_commit_message_prompt,
)
try:
require(body, "session_id")
paths = _git_paths_from_body(body)
session = get_session(body["session_id"])
workspace = Path(session.workspace)
prompt = selected_commit_message_prompt(workspace, paths)
message = clean_generated_commit_message(
_llm_git_commit_message(prompt["system_prompt"], prompt["user_prompt"], session=session)
)
if not message:
raise GitWorkspaceError("No commit message was generated")
return j(handler, {"ok": True, "message": message, "truncated": bool(prompt.get("truncated"))})
except KeyError:
return bad(handler, "Session not found", 404)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
except Exception as e:
logger.exception("selected git commit message generation failed")
return bad(handler, _sanitize_error(e), 500)
def _handle_git_commit(handler, body):
try:
require(body, "session_id", "message")
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_commit
return j(handler, git_commit(workspace, body.get("message", "")))
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_commit_selected(handler, body):
try:
require(body, "session_id", "message")
paths = _git_paths_from_body(body)
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_commit_selected
return j(handler, git_commit_selected(workspace, body.get("message", ""), paths))
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_remote_action(handler, body, action: str):
try:
require(body, "session_id")
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if action in {"pull", "push"} and _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_fetch, git_pull, git_push
actions = {
"fetch": git_fetch,
"pull": git_pull,
"push": git_push,
}
return j(handler, actions[action](workspace))
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_checkout(handler, body):
try:
require(body, "session_id", "ref", "mode")
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_checkout
result = git_checkout(
workspace,
str(body.get("ref", "")),
str(body.get("mode", "local")),
new_branch=body.get("new_branch"),
track=bool(body.get("track")),
dirty_mode=str(body.get("dirty_mode", "block")),
)
return j(
handler,
{
"ok": True,
"git": result.get("status"),
"branches": result.get("branches"),
"current_branch": result.get("current_branch"),
"message": result.get("message", ""),
},
)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_git_stash_checkout(handler, body):
try:
require(body, "session_id", "ref", "mode")
session, workspace = _git_session_and_workspace(handler, body["session_id"])
if workspace is None:
return True
if _git_reject_destructive_if_unsafe(handler, session):
return True
from api.workspace_git import GitWorkspaceError, git_stash_and_checkout
result = git_stash_and_checkout(
workspace,
str(body.get("ref", "")),
str(body.get("mode", "local")),
new_branch=body.get("new_branch"),
track=bool(body.get("track")),
)
return j(
handler,
{
"ok": True,
"git": result.get("status"),
"branches": result.get("branches"),
"current_branch": result.get("current_branch"),
"message": result.get("message", ""),
"stash_name": result.get("stash_name", ""),
"stashed": bool(result.get("stashed")),
"restored_stash": result.get("restored_stash"),
"restore_failed": bool(result.get("restore_failed")),
"restore_error": result.get("restore_error", ""),
"restore_stash": result.get("restore_stash"),
},
)
except ValueError as e:
return bad(handler, str(e))
except GitWorkspaceError as e:
return _git_bad(handler, e)
def _handle_file_delete(handler, body):
try:
require(body, "session_id", "path")
@@ -9849,7 +10435,7 @@ def _persist_handoff_summary_to_state_db(sid: str, message: dict) -> bool:
marker_payload = _extract_handoff_summary_payload(message)
try:
with sqlite3.connect(str(db_path)) as conn:
with closing(sqlite3.connect(str(db_path))) as conn:
try:
if marker_payload is not None:
cur = conn.execute(
@@ -10374,6 +10960,81 @@ def _handle_skill_delete(handler, body):
return j(handler, {"ok": True, "name": body["name"]})
def _normalize_names_list(names) -> list[str]:
"""Normalize a config value (None/str/list) into a deduplicated str list."""
if names is None:
return []
if isinstance(names, str):
names = [names]
elif not isinstance(names, list):
names = list(names) if names else []
return list(dict.fromkeys(str(d).strip() for d in names if str(d).strip()))
def _toggle_name_in_list(names, name: str, enabled: bool) -> list[str]:
"""Add or remove *name* from *names*, returning a new list."""
names = _normalize_names_list(names)
if enabled:
return [d for d in names if d != name]
if name not in names:
names.append(name)
return names
def _handle_skill_toggle(handler, body):
"""Toggle a skill's enabled/disabled state in the active profile's config.yaml.
Writes through to ``skills.platform_disabled.webui`` when that key exists
so the toggle takes effect for WebUI sessions (the agent's
``get_disabled_skill_names`` checks platform-specific lists first when
``HERMES_SESSION_PLATFORM`` is set).
"""
try:
require(body, "name", "enabled")
except ValueError as e:
return bad(handler, str(e))
name = body["name"].strip()
enabled = bool(body["enabled"])
# Validate the skill exists in the filesystem
skills_dir = _active_skills_dir()
search_dirs = _active_skill_search_dirs(skills_dir)
skill_dir, skill_md = _find_skill_in_dirs(name, search_dirs)
if not skill_md:
return bad(handler, f"Skill '{name}' not found", 404)
config_path = _get_config_path()
with _cfg_lock:
cfg = _load_yaml_config_file(config_path)
# Ensure skills section exists as a dict
if "skills" not in cfg or not isinstance(cfg["skills"], dict):
cfg["skills"] = {}
skills_cfg = cfg["skills"]
# Always update the global disabled list
skills_cfg["disabled"] = _toggle_name_in_list(
skills_cfg.get("disabled"), name, enabled
)
# Write-through to platform_disabled.webui if it exists so that the
# toggle takes effect for WebUI sessions (the agent checks the
# platform-specific list first when HERMES_SESSION_PLATFORM=webui).
platform_disabled = skills_cfg.get("platform_disabled")
if isinstance(platform_disabled, dict) and "webui" in platform_disabled:
platform_disabled["webui"] = _toggle_name_in_list(
platform_disabled["webui"], name, enabled
)
cfg["skills"] = skills_cfg
_save_yaml_config_file(config_path, cfg)
reload_config() # outside with block — reload_config() acquires the lock itself
return j(handler, {"ok": True, "name": name, "enabled": enabled})
def _handle_memory_write(handler, body):
try:
require(body, "section", "content")
+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
+79 -3
View File
@@ -2058,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 ''
@@ -2091,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 ''
@@ -2102,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.
@@ -2186,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,
@@ -2487,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:
@@ -4216,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,
@@ -4379,7 +4455,7 @@ def _run_agent_streaming(
_previous_context_messages,
_next_context_messages,
)
s.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,
@@ -4526,7 +4602,7 @@ def _run_agent_streaming(
_previous_context_messages,
_next_context_messages,
)
s.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,
@@ -5346,7 +5422,7 @@ def _run_agent_streaming(
_previous_context_messages,
_next_context_messages,
)
s.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
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: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+73 -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:
@@ -99,11 +99,16 @@ adapter-seam work:
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.
- The next implementation gate is a feature-flagged runner backend plus
restart/reattach 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.
- #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
@@ -780,6 +785,12 @@ 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.
@@ -830,6 +841,61 @@ Non-goals for Slice 4c:
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.
+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.
+95 -17
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();
@@ -1405,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;
@@ -1418,6 +1458,14 @@ 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._inflightStateLimits={
maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8,
messages:parseInt(s.inflight_state_max_messages||24,10)||24,
toolCalls:parseInt(s.inflight_state_max_tool_calls||48,10)||48,
stringChars:parseInt(s.inflight_state_max_string_chars||60000,10)||60000,
jsonChars:parseInt(s.inflight_state_max_json_chars||1500000,10)||1500000,
};
window._busyInputMode=(s.busy_input_mode||'queue');
window._sessionEndlessScrollEnabled=!!s.session_endless_scroll;
window._botName=s.bot_name||'Hermes';
@@ -1425,10 +1473,15 @@ function applyBotName(){
if(s.default_model){
window._defaultModel=s.default_model;
const sel=$('modelSelect');
const savedState=(typeof _readPersistedModelState==='function')
? _readPersistedModelState()
: (localStorage.getItem('hermes-webui-model')?{model:localStorage.getItem('hermes-webui-model'),model_provider:null}:null);
if(sel&&!savedState&&typeof _applyModelToDropdown==='function'){
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;
@@ -1494,6 +1547,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;
@@ -1504,6 +1559,7 @@ function applyBotName(){
window._simplifiedToolCalling=true;
window._sessionJumpButtonsEnabled=false;
window._sidebarDensity='compact';
window._pinnedSessionsLimit=3;
window._busyInputMode='queue';
window._sessionEndlessScrollEnabled=false;
window._botName='Hermes';
@@ -1534,19 +1590,35 @@ function applyBotName(){
// options are enough for first paint; the dynamic provider list can settle
// after the saved session is visible.
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();
}
@@ -1596,6 +1668,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.
+204 -2
View File
@@ -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',
@@ -1043,6 +1050,9 @@ const LOCALES = {
clear_conversation_message: 'Clear all messages? This cannot be undone.',
clear_failed: 'Clear failed: ',
skills_no_match: 'No skills match.',
skill_enabled: 'Enabled',
skill_disabled: 'Disabled',
skill_toggle_failed: 'Failed to toggle skill: ',
linked_files: 'Linked Files',
skill_load_failed: 'Could not load skill: ',
skill_file_load_failed: 'Could not load file: ',
@@ -1735,6 +1745,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',
@@ -1754,6 +1767,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',
@@ -1775,6 +1790,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',
@@ -2259,6 +2276,9 @@ const LOCALES = {
clear_conversation_message: 'Cancellare tutti i messaggi? Questa azione non può essere annullata.',
clear_failed: 'Cancellazione fallita: ',
skills_no_match: 'Nessuna skill corrispondente.',
skill_enabled: 'Abilitato',
skill_disabled: 'Disabilitato',
skill_toggle_failed: 'Errore nell\'attivare la skill: ',
linked_files: 'File Collegati',
skill_load_failed: 'Impossibile caricare la skill: ',
skill_file_load_failed: 'Impossibile caricare il file: ',
@@ -2951,6 +2971,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: '外観',
@@ -2970,6 +2993,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: '設定',
@@ -2991,6 +3016,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: '思考表示の切り替え (表示/非表示)、努力レベル設定、現在状態の確認',
@@ -3480,6 +3507,9 @@ const LOCALES = {
clear_conversation_message: 'すべてのメッセージをクリアしますか? この操作は取り消せません。',
clear_failed: 'クリア失敗: ',
skills_no_match: '一致するスキルがありません。',
skill_enabled: '有効',
skill_disabled: '無効',
skill_toggle_failed: 'スキルの切り替えに失敗しました: ',
linked_files: 'リンクされたファイル',
skill_load_failed: 'スキルを読み込めませんでした: ',
skill_file_load_failed: 'ファイルを読み込めませんでした: ',
@@ -4018,6 +4048,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',
@@ -4452,6 +4484,9 @@ const LOCALES = {
clear_conversation_message: 'Очистить все сообщения? Это действие нельзя отменить.',
clear_failed: 'Не удалось очистить: ',
skills_no_match: 'Подходящих навыков не найдено.',
skill_enabled: 'Включено',
skill_disabled: 'Отключено',
skill_toggle_failed: 'Не удалось переключить навык: ',
linked_files: 'Связанные файлы',
skill_load_failed: 'Не удалось загрузить навык: ',
skill_file_load_failed: 'Не удалось загрузить файл: ',
@@ -4688,6 +4723,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',
@@ -4703,6 +4741,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',
@@ -5163,6 +5203,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',
@@ -5612,6 +5654,9 @@ const LOCALES = {
clear_conversation_message: 'Clear all messages? This cannot be undone.',
clear_failed: 'Clear failed: ',
skills_no_match: 'No skills match.',
skill_enabled: 'Habilitado',
skill_disabled: 'Deshabilitado',
skill_toggle_failed: 'Error al cambiar skill: ',
linked_files: 'Linked Files',
skill_load_failed: 'Could not load skill: ',
skill_file_load_failed: 'Could not load file: ',
@@ -5831,6 +5876,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',
@@ -5846,6 +5894,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',
@@ -6301,6 +6351,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',
@@ -6706,6 +6758,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',
@@ -6723,6 +6778,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',
@@ -6952,6 +7009,9 @@ const LOCALES = {
clear_conversation_message: 'Alle Nachrichten werden gelöscht.',
clear_failed: 'Löschen fehlgeschlagen.',
skills_no_match: 'Keine passende Fähigkeit.',
skill_enabled: 'Aktiviert',
skill_disabled: 'Deaktiviert',
skill_toggle_failed: 'Fehler beim Umschalten der Fähigkeit: ',
linked_files: 'Verknüpfte Dateien',
skill_load_failed: 'Fähigkeit konnte nicht geladen werden.',
skill_file_load_failed: 'Datei konnte nicht geladen werden.',
@@ -7386,6 +7446,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: '此工作区为空。',
@@ -7489,6 +7551,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: '切换思维可见性(显示/隐藏)、设置工作强度或查看当前状态',
@@ -7882,7 +7946,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: '这个重复定时任务没有下次运行时间。调度器可能没能计算出下一次运行。',
@@ -7927,6 +7991,9 @@ const LOCALES = {
clear_conversation_message: '要清空所有消息吗?此操作无法撤销。',
clear_failed: '清空失败:',
skills_no_match: '没有匹配的技能。',
skill_enabled: '已启用',
skill_disabled: '已禁用',
skill_toggle_failed: '切换技能失败:',
linked_files: '关联文件',
skill_load_failed: '加载技能失败:',
skill_file_load_failed: '加载文件失败:',
@@ -8147,6 +8214,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: '外观',
@@ -8613,6 +8683,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: '外觀',
@@ -8632,6 +8705,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',
@@ -8653,6 +8728,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: '切換思考區塊可見性(顯示/隱藏)或設定努力等級',
@@ -9234,7 +9311,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',
@@ -9915,6 +9992,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',
@@ -9934,6 +10014,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',
@@ -9955,6 +10037,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)',
@@ -10395,6 +10479,9 @@ const LOCALES = {
clear_conversation_message: 'Limpar todas mensagens? Isso não pode ser desfeito.',
clear_failed: 'Falha ao limpar: ',
skills_no_match: 'Nenhuma skill corresponde.',
skill_enabled: 'Habilitado',
skill_disabled: 'Desabilitado',
skill_toggle_failed: 'Falha ao alternar skill: ',
linked_files: 'Arquivos vinculados',
skill_load_failed: 'Não foi possível carregar skill: ',
skill_file_load_failed: 'Não foi possível carregar arquivo: ',
@@ -11034,6 +11121,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: '외형',
@@ -11053,6 +11143,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: '설정',
@@ -11074,6 +11166,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',
@@ -11515,6 +11609,9 @@ const LOCALES = {
clear_conversation_message: 'Clear all messages? This cannot be undone.',
clear_failed: 'Clear failed: ',
skills_no_match: 'No skills match.',
skill_enabled: '활성화됨',
skill_disabled: '비활성화됨',
skill_toggle_failed: '스킬 전환 실패: ',
linked_files: 'Linked Files',
skill_load_failed: 'Could not load skill: ',
skill_file_load_failed: 'Could not load file: ',
@@ -12168,6 +12265,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',
@@ -12187,6 +12287,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',
@@ -12208,6 +12310,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',
@@ -12653,6 +12757,9 @@ const LOCALES = {
clear_conversation_message: 'Effacer tous les messages ? Cela ne peut pas être annulé.',
clear_failed: 'Échec de la suppression :',
skills_no_match: 'Aucune compétence ne correspond.',
skill_enabled: 'Activé',
skill_disabled: 'Désactivé',
skill_toggle_failed: 'Échec du basculement de compétence: ',
linked_files: 'Fichiers liés',
skill_load_failed: 'Impossible de charger la compétence :',
skill_file_load_failed: 'Impossible de charger le fichier :',
@@ -12816,6 +12923,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',
}
};
+23
View File
@@ -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">
@@ -483,6 +488,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
<span class="clarify-countdown" id="clarifyCountdown"></span>
<button type="button" class="clarify-collapse" id="clarifyCollapse" aria-expanded="true" aria-label="Collapse clarification" aria-controls="clarifyQuestion clarifyChoices clarifyInput clarifyHint" onclick="toggleClarifyCardCollapsed()" title="Collapse clarification"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
@@ -971,6 +977,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 +996,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 +1127,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">
+43 -2
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.
@@ -2487,6 +2502,7 @@ function _ensureClarifyCardDom() {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 17h.01"/><path d="M9.09 9a3 3 0 1 1 5.82 1c0 2-3 2-3 4"/><circle cx="12" cy="12" r="10"/></svg>
<span id="clarifyHeading" data-i18n="clarify_heading">Clarification needed</span>
<span class="clarify-countdown" id="clarifyCountdown"></span>
<button type="button" class="clarify-collapse" id="clarifyCollapse" aria-expanded="true" aria-label="Collapse clarification" aria-controls="clarifyQuestion clarifyChoices clarifyInput clarifyHint" onclick="toggleClarifyCardCollapsed()" title="Collapse clarification"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg></button>
</div>
<div class="clarify-question" id="clarifyQuestion"></div>
<div class="clarify-choices" id="clarifyChoices"></div>
@@ -2500,10 +2516,33 @@ function _ensureClarifyCardDom() {
host.appendChild(card);
const submit = $("clarifySubmit");
if (submit) submit.onclick = () => respondClarify();
const collapse = $("clarifyCollapse");
if (collapse) collapse.onclick = () => toggleClarifyCardCollapsed();
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
return card;
}
function _syncClarifyCollapseButton(card) {
const collapse = $("clarifyCollapse");
if (!collapse || !card) return;
const collapsed = card.classList.contains("collapsed");
collapse.setAttribute("aria-expanded", collapsed ? "false" : "true");
// Icon swap: chevron-down when expanded (click to collapse), chevron-up when collapsed (click to expand)
const polyline = collapse.querySelector("svg polyline");
if (polyline) polyline.setAttribute("points", collapsed ? "18 15 12 9 6 15" : "6 9 12 15 18 9");
const label = collapsed ? "Expand clarification" : "Collapse clarification";
collapse.setAttribute("aria-label", label);
collapse.title = label;
}
function toggleClarifyCardCollapsed(forceCollapsed) {
const card = $("clarifyCard");
if (!card) return;
const collapsed = typeof forceCollapsed === "boolean" ? forceCollapsed : !card.classList.contains("collapsed");
card.classList.toggle("collapsed", collapsed);
_syncClarifyCollapseButton(card);
}
function _clearClarifyHideTimer() {
if (_clarifyHideTimer) {
clearTimeout(_clarifyHideTimer);
@@ -2670,6 +2709,7 @@ function showClarifyCard(pending) {
if (!sameClarify) {
_clarifyVisibleSince = Date.now();
_clearClarifyHideTimer();
card.classList.remove("collapsed");
}
if (questionEl) questionEl.textContent = question;
if (choicesEl) {
@@ -2730,6 +2770,7 @@ function showClarifyCard(pending) {
}
_clarifySetControlsDisabled(false, false);
card.classList.add("visible");
_syncClarifyCollapseButton(card);
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
if (input && !sameClarify) setTimeout(() => input.focus({preventScroll: true}), 50);
}
+235 -10
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);
@@ -3291,9 +3315,23 @@ function renderSkills(skills) {
sec.appendChild(hdr);
for (const skill of items.sort((a,b) => a.name.localeCompare(b.name))) {
const el = document.createElement('div');
el.className = 'skill-item';
el.className = 'skill-item' + (skill.disabled ? ' disabled' : '');
el.style.display = collapsed ? 'none' : '';
el.innerHTML = `<span class="skill-name">${esc(skill.name)}</span><span class="skill-desc">${esc(skill.description||'')}</span>`;
const isDisabled = skill.disabled || false;
const toggle = document.createElement('span');
toggle.className = 'skill-toggle' + (isDisabled ? '' : ' enabled');
toggle.title = isDisabled ? t('skill_disabled') : t('skill_enabled');
toggle.addEventListener('click', (ev) => {
ev.stopPropagation();
toggleSkill(skill.name, !isDisabled);
});
const nameEl = document.createElement('span');
nameEl.className = 'skill-name';
nameEl.textContent = skill.name;
const descEl = document.createElement('span');
descEl.className = 'skill-desc';
descEl.textContent = skill.description || '';
el.append(toggle, nameEl, descEl);
el.onclick = () => openSkill(skill.name, el);
sec.appendChild(el);
}
@@ -3305,6 +3343,28 @@ function filterSkills() {
if (_skillsData) renderSkills(_skillsData);
}
async function toggleSkill(name, currentlyEnabled) {
const newEnabled = !currentlyEnabled;
try {
const result = await api('/api/skills/toggle', {
method: 'POST',
body: JSON.stringify({ name, enabled: newEnabled })
});
if (result && result.ok) {
if (_skillsData) {
const skill = _skillsData.find(s => s.name === name);
if (skill) skill.disabled = !newEnabled;
}
renderSkills(_skillsData || []);
} else {
setStatus((result && result.error) || t('skill_toggle_failed'));
}
} catch(e) {
setStatus(t('skill_toggle_failed') + e.message);
}
}
// Currently selected skill detail — kept across panel switches so re-entering
// the Skills view shows the last-viewed skill.
let _currentSkillDetail = null; // { name, category, content }
@@ -4508,6 +4568,17 @@ function _refreshProfileSwitchBackground(gen){
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() {
@@ -5113,6 +5184,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;
@@ -5240,6 +5391,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(),
};
}
@@ -5317,6 +5469,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');
@@ -5343,6 +5497,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');
@@ -5394,11 +5550,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
@@ -5495,6 +5656,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');
@@ -5571,8 +5744,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');
@@ -5757,20 +5948,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>
@@ -6410,6 +6623,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
@@ -6422,6 +6643,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);
@@ -6456,6 +6679,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';
@@ -6480,6 +6704,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;
+95 -13
View File
@@ -318,6 +318,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.
@@ -482,11 +485,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.
@@ -561,10 +584,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){
@@ -603,6 +630,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();
@@ -1261,10 +1289,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){
@@ -1555,6 +1594,14 @@ function _sessionSnapshotById(sid){
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);
@@ -1904,15 +1951,15 @@ function _openSessionActionMenu(session, anchorEl){
}
));
}
const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=3;
const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();
menu.appendChild(_buildSessionAction(
session.pinned?t('session_unpin'):t('session_pin'),
pinLimitReached?'Only 3 conversations can be pinned':(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('Only 3 conversations can be pinned. Unpin one before pinning another.',3000,'error');
if(typeof showToast==='function') showToast(_pinnedSessionsLimitMessage(),3000,'error');
return;
}
const newPinned=!session.pinned;
@@ -2195,6 +2242,16 @@ 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 = '';
@@ -2316,6 +2373,7 @@ function ensureSessionEventsSSE(){
// 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');
@@ -2327,10 +2385,12 @@ function ensureSessionEventsSSE(){
_sessionEventsNeedsRefreshOnOpen = true;
_closeSessionEventsSSE();
if(_sessionEventsReconnectTimer) return;
const delayMs = _sessionEventsReconnectDelayMs();
_sessionEventsReconnectAttempt = Math.min(_sessionEventsReconnectAttempt + 1, 6);
_sessionEventsReconnectTimer = setTimeout(() => {
_sessionEventsReconnectTimer = 0;
ensureSessionEventsSSE();
}, 5000);
}, delayMs);
};
}catch(e){
_closeSessionEventsSSE();
@@ -2988,9 +3048,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
@@ -3272,6 +3353,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){
+35 -4
View File
@@ -347,7 +347,7 @@
: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-color:var(--border)!important;box-shadow:none!important;}
: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,
@@ -440,10 +440,10 @@
: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,
:root[data-skin="geist-contrast"] .composer-box:focus-within,
: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,
@@ -463,6 +463,12 @@
: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{
@@ -1043,11 +1049,14 @@
/* ── Clarify card ── */
.clarify-card{position:absolute;left:0;right:0;bottom:-24px;max-width:var(--msg-max);margin:0 auto;padding:0 20px;box-sizing:border-box;width:100%;overflow:hidden;pointer-events:none;max-height:min(calc(100vh - 280px),420px);}
.clarify-card.visible{pointer-events:auto;}
.clarify-card .clarify-inner{max-height:min(calc(100vh - 280px),420px);overflow-y:auto;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease;}
.clarify-card .clarify-inner{max-height:min(calc(100vh - 280px),420px);overflow-y:auto;transform:translateY(100%);opacity:0;transition:transform .4s cubic-bezier(.32,.72,.16,1),opacity .25s ease,max-height .2s ease;}
.clarify-card.visible .clarify-inner{transform:translateY(0);opacity:1;}
.clarify-inner{background:var(--surface);backdrop-filter:blur(8px);border:1px solid var(--accent-bg-strong);border-radius:12px;padding:12px 14px 36px;box-shadow:0 1px 0 rgba(255,255,255,.02) inset;}
.clarify-header{display:flex;align-items:center;gap:8px;margin-bottom:10px;font-size:12px;font-weight:700;color:var(--blue);letter-spacing:.01em;}
.clarify-countdown{margin-left:auto;min-width:42px;text-align:right;color:var(--muted);font-variant-numeric:tabular-nums;font-weight:700;}
.clarify-collapse{margin-left:4px;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border:1px solid var(--border2);border-radius:999px;background:var(--surface);color:var(--muted);font:inherit;padding:0;cursor:pointer;}
.clarify-collapse:hover,.clarify-collapse:focus-visible{color:var(--text);border-color:var(--accent-bg-strong);outline:none;}
.clarify-collapse svg{width:14px;height:14px;display:block;}
.clarify-countdown.urgent{color:var(--error);box-shadow:inset 0 -2px 0 var(--error);border-radius:2px;}
.clarify-question{font-size:14px;color:var(--text);line-height:1.7;white-space:pre-wrap;margin-bottom:12px;}
.clarify-choices{display:flex;flex-direction:column;gap:8px;margin-bottom:12px;}
@@ -1068,6 +1077,10 @@
.clarify-submit.loading{opacity:.75;cursor:wait;}
.clarify-hint{margin-top:8px;font-size:11px;line-height:1.45;color:var(--muted);}
.clarify-card.visible .clarify-question{padding-left:1px;}
.clarify-card.collapsed{max-height:56px;bottom:8px;}
.clarify-card.collapsed .clarify-inner{max-height:48px;overflow:hidden;padding:10px 14px;}
.clarify-card.collapsed .clarify-header{margin-bottom:0;}
.clarify-card.collapsed .clarify-question,.clarify-card.collapsed .clarify-choices,.clarify-card.collapsed .clarify-response,.clarify-card.collapsed .clarify-hint{display:none;}
/* Left rail (desktop primary navigation) */
.rail{display:none;width:48px;flex-shrink:0;flex-direction:column;align-items:center;gap:4px;padding:8px 0;background:var(--sidebar);border-right:1px solid var(--border);}
.rail-btn{width:36px;height:36px;border-radius:8px;border:none;background:none;color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;position:relative;transition:color .15s,background .15s;flex-shrink:0;padding:0;}
@@ -1170,6 +1183,12 @@
.skill-item.active{background:var(--accent-bg);color:var(--accent-text);}
.skill-name{font-weight:500;flex-shrink:0;}
.skill-desc{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:11px;opacity:.7;}
.skill-item.disabled{opacity:.5;}
.skill-item.disabled .skill-name{opacity:.45;}
.skill-toggle{flex-shrink:0;width:28px;height:14px;border-radius:7px;border:1px solid var(--border2);background:var(--border);cursor:pointer;position:relative;transition:all .15s;margin-top:3px;}
.skill-toggle.enabled{background:var(--accent-bg-strong);border-color:var(--accent);}
.skill-toggle::after{content:'';position:absolute;top:1px;left:1px;width:10px;height:10px;border-radius:50%;background:var(--muted);transition:transform .15s;}
.skill-toggle.enabled::after{transform:translateX(14px);background:var(--accent-text);}
/* Memory panel */
.memory-panel{flex:1;overflow-y:auto;padding:12px;}
.memory-section{margin-bottom:16px;}
@@ -1363,6 +1382,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;}
@@ -2867,6 +2887,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,
+279 -32
View File
@@ -733,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.
@@ -814,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);
@@ -869,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;
@@ -992,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();
@@ -1476,10 +1564,9 @@ async function toggleModelDropdown(){
if(typeof closeWsDropdown==='function') closeWsDropdown();
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
if(typeof window._ensureModelDropdownReady==='function') window._ensureModelDropdownReady();
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();
@@ -3981,6 +4068,29 @@ function autoReadLastAssistant(){
// ── Reconnect banner (B4/B5: reload resilience) ──
const INFLIGHT_KEY = 'hermes-webui-inflight'; // localStorage key for in-flight session tracking
const INFLIGHT_STATE_KEY = 'hermes-webui-inflight-state'; // localStorage snapshots for mid-stream reload recovery
const INFLIGHT_STATE_DEFAULT_LIMITS = {
maxSessions:8,
messages:24,
toolCalls:48,
stringChars:60000,
jsonChars:1500000,
};
function _boundedInflightInt(value, fallback, min, max){
const n=parseInt(value,10);
if(!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
function _getInflightStateLimits(){
const configured=(typeof window!=='undefined'&&window._inflightStateLimits&&typeof window._inflightStateLimits==='object')?window._inflightStateLimits:{};
return {
maxSessions:_boundedInflightInt(configured.maxSessions, INFLIGHT_STATE_DEFAULT_LIMITS.maxSessions, 1, 25),
messages:_boundedInflightInt(configured.messages, INFLIGHT_STATE_DEFAULT_LIMITS.messages, 1, 100),
toolCalls:_boundedInflightInt(configured.toolCalls, INFLIGHT_STATE_DEFAULT_LIMITS.toolCalls, 1, 200),
stringChars:_boundedInflightInt(configured.stringChars, INFLIGHT_STATE_DEFAULT_LIMITS.stringChars, 1000, 500000),
jsonChars:_boundedInflightInt(configured.jsonChars, INFLIGHT_STATE_DEFAULT_LIMITS.jsonChars, 100000, 4000000),
};
}
function _readInflightStateMap(){
try{
@@ -3991,13 +4101,75 @@ function _readInflightStateMap(){
return {};
}
}
function _isStorageQuotaError(err){
return !!err && (
err.name==='QuotaExceededError' ||
err.name==='NS_ERROR_DOM_QUOTA_REACHED' ||
err.code===22 ||
err.code===1014
);
}
function _truncateInflightValue(value, maxChars){
const limits=_getInflightStateLimits();
const stringLimit=_boundedInflightInt(maxChars, limits.stringChars, 1000, 500000);
if(typeof value==='string'){
if(value.length<=stringLimit) return value;
return value.slice(0,stringLimit)+'\n\n[truncated for browser recovery storage]';
}
if(Array.isArray(value)) return value.map(v=>_truncateInflightValue(v, Math.max(2000, Math.floor(stringLimit/2))));
if(value&&typeof value==='object'){
const out={};
for(const [k,v] of Object.entries(value)) out[k]=_truncateInflightValue(v, stringLimit);
return out;
}
return value;
}
function _compactInflightState(state){
const limits=_getInflightStateLimits();
const messages=Array.isArray(state.messages)?state.messages.slice(-limits.messages):[];
const toolCalls=Array.isArray(state.toolCalls)?state.toolCalls.slice(-limits.toolCalls):[];
return _truncateInflightValue({
streamId:state.streamId||null,
messages,
uploaded:Array.isArray(state.uploaded)?state.uploaded.slice(-20):[],
toolCalls,
}, limits.stringChars);
}
function _writeInflightStateMap(all){
const limits=_getInflightStateLimits();
const entries=Object.entries(all||{})
.sort((a,b)=>Number(b[1]&&b[1].updated_at||0)-Number(a[1]&&a[1].updated_at||0))
.slice(0,limits.maxSessions);
const compact={};
for(const [sid,entry] of entries) compact[sid]=entry;
let json=JSON.stringify(compact);
if(json.length>limits.jsonChars){
const current=entries[0];
json=JSON.stringify(current?{[current[0]]:current[1]}:{});
}
if(json.length>limits.jsonChars){
localStorage.removeItem(INFLIGHT_STATE_KEY);
return false;
}
localStorage.setItem(INFLIGHT_STATE_KEY,json);
return true;
}
function saveInflightState(sid, state){
if(!sid||!state) return;
const entry={..._compactInflightState(state),updated_at:Date.now()};
try{
const all=_readInflightStateMap();
all[sid]={...state,updated_at:Date.now()};
localStorage.setItem(INFLIGHT_STATE_KEY, JSON.stringify(all));
}catch(_){ }
all[sid]=entry;
_writeInflightStateMap(all);
}catch(err){
if(!_isStorageQuotaError(err)) return;
try{
localStorage.removeItem(INFLIGHT_STATE_KEY);
_writeInflightStateMap({[sid]:entry});
}catch(_){
try{localStorage.removeItem(INFLIGHT_STATE_KEY);}catch(__){}
}
}
}
function loadInflightState(sid, streamId){
if(!sid) return null;
@@ -4084,7 +4256,16 @@ function restoreLiveTurnHtmlForSession(sid){
}
function markInflight(sid, streamId) {
localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()}));
const payload=JSON.stringify({sid, streamId, ts: Date.now()});
try{
localStorage.setItem(INFLIGHT_KEY, payload);
}catch(err){
if(!_isStorageQuotaError(err)) return;
try{
localStorage.removeItem(INFLIGHT_STATE_KEY);
localStorage.setItem(INFLIGHT_KEY, payload);
}catch(_){}
}
}
function clearInflight() {
localStorage.removeItem(INFLIGHT_KEY);
@@ -4295,6 +4476,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{
@@ -4806,8 +4992,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);
@@ -4820,14 +5007,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);
}
}
}
}
@@ -5537,14 +5735,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(){
@@ -5552,6 +5745,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;
@@ -5698,6 +5940,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))
@@ -5713,7 +5956,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();
@@ -6324,7 +6567,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);}
}
}
@@ -7183,7 +7426,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){
@@ -7205,7 +7451,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,
+1
View File
@@ -545,6 +545,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),
+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
@@ -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)
+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"
+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"
+87
View File
@@ -0,0 +1,87 @@
"""Regression coverage for browser in-flight localStorage quota handling."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CONFIG_PY = (REPO_ROOT / "api" / "config.py").read_text(encoding="utf-8")
BOOT_JS = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
UI_JS = (REPO_ROOT / "static" / "ui.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_inflight_state_is_compacted_before_localstorage_write():
"""Persisted recovery state must stay bounded instead of storing full long sessions."""
save_body = _function_body(UI_JS, "saveInflightState")
compact_body = _function_body(UI_JS, "_compactInflightState")
assert "const entry={..._compactInflightState(state),updated_at:Date.now()};" in save_body
assert "const limits=_getInflightStateLimits();" in compact_body
assert ".slice(-limits.messages)" in compact_body
assert ".slice(-limits.toolCalls)" in compact_body
assert "limits.jsonChars" in UI_JS
def test_inflight_state_limits_are_configurable_from_settings():
"""Recovery snapshots should be bounded by settings, not hardcoded at 3 sessions / 8 messages."""
assert '\"inflight_state_max_sessions\": 8' in CONFIG_PY
assert '\"inflight_state_max_messages\": 24' in CONFIG_PY
assert '\"inflight_state_max_tool_calls\": 48' in CONFIG_PY
assert '\"inflight_state_max_string_chars\": 60000' in CONFIG_PY
assert '\"inflight_state_max_json_chars\": 1500000' in CONFIG_PY
assert '\"inflight_state_max_sessions\": (1, 25)' in CONFIG_PY
assert '\"inflight_state_max_messages\": (1, 100)' in CONFIG_PY
assert '\"inflight_state_max_tool_calls\": (1, 200)' in CONFIG_PY
assert '\"inflight_state_max_string_chars\": (1000, 500000)' in CONFIG_PY
assert '\"inflight_state_max_json_chars\": (100000, 4000000)' in CONFIG_PY
assert "window._inflightStateLimits={" in BOOT_JS
assert "maxSessions:parseInt(s.inflight_state_max_sessions||8,10)||8" in BOOT_JS
assert "messages:parseInt(s.inflight_state_max_messages||24,10)||24" in BOOT_JS
# The reader function MUST use a different name than the window-attached
# config object — top-level `function foo(){}` in non-module scripts
# attaches to `window`, so a collision causes boot.js to overwrite the
# function with the config object and every later call throws
# `_inflightStateLimits is not a function`. See #2771.
assert "function _getInflightStateLimits()" in UI_JS
assert "function _inflightStateLimits()" not in UI_JS, (
"Function name must not collide with window._inflightStateLimits "
"config object (#2771)."
)
assert "window._inflightStateLimits" in UI_JS
assert "INFLIGHT_STATE_MAX_SESSIONS = 3" not in UI_JS
assert "INFLIGHT_STATE_MAX_MESSAGES = 8" not in UI_JS
def test_inflight_marker_write_handles_quota_by_dropping_recovery_snapshots():
"""The tiny active-stream marker must not crash submit when recovery snapshots fill quota."""
mark_body = _function_body(UI_JS, "markInflight")
assert "try{" in mark_body
assert "localStorage.setItem(INFLIGHT_KEY, payload);" in mark_body
assert "_isStorageQuotaError(err)" in mark_body
assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in mark_body
assert mark_body.index("localStorage.removeItem(INFLIGHT_STATE_KEY);") < mark_body.rindex(
"localStorage.setItem(INFLIGHT_KEY, payload);"
)
def test_save_inflight_state_clears_snapshots_when_quota_retry_fails():
"""Quota failures should degrade recovery, not preserve a storage-filling blob."""
save_body = _function_body(UI_JS, "saveInflightState")
assert "catch(err)" in save_body
assert "if(!_isStorageQuotaError(err)) return;" in save_body
assert "localStorage.removeItem(INFLIGHT_STATE_KEY);" in save_body
assert "_writeInflightStateMap({[sid]:entry});" in save_body
+7 -5
View File
@@ -12,14 +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 "window._ensureModelDropdownReady" in body
assert "await" in body
assert body.index("await") < body.index("renderModelDropdown()")
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"
)
+7 -4
View File
@@ -70,12 +70,15 @@ def test_session_pin_endpoint_caps_pinned_sessions_at_three():
def test_session_pin_cap_has_backend_and_frontend_guards():
assert 'pinned_ids = {' in ROUTES_PY
assert 'pinned_ids.update(' in ROUTES_PY
assert 'if len(pinned_ids) >= 3:' in ROUTES_PY
assert 'Up to 3 sessions can be pinned' 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 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=3;' in SESSIONS_JS
assert "Only 3 conversations can be pinned" 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
@@ -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
+34
View File
@@ -71,9 +71,43 @@ def test_importable_agent_rows_push_sidebar_limit_into_sql(tmp_path):
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)
+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"
+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
+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.
+73
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 = []
@@ -414,6 +470,7 @@ def test_rfc_defines_slice4c_runner_backend_harness_gate():
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
@@ -423,6 +480,22 @@ def test_rfc_defines_slice4c_runner_backend_harness_gate():
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 = []
@@ -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();")
@@ -27,6 +27,7 @@ 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
@@ -131,6 +132,125 @@ def _assert_retry_meta_removed(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"
@@ -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"
)
+26
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)
@@ -1108,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=[])
+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"
+199
View File
@@ -0,0 +1,199 @@
"""Tests for skill toggle (enable/disable) API and frontend."""
from pathlib import Path
PANELS_JS = (Path(__file__).resolve().parent.parent / "static" / "panels.js").read_text("utf-8")
I18N_JS = (Path(__file__).resolve().parent.parent / "static" / "i18n.js").read_text("utf-8")
STYLE_CSS = (Path(__file__).resolve().parent.parent / "static" / "style.css").read_text("utf-8")
def test_toggle_endpoint_signature_in_routes():
"""Verify the toggle endpoint code exists in routes.py."""
from api.routes import _handle_skill_toggle
assert callable(_handle_skill_toggle)
def test_toggle_path_registered():
"""Verify /api/skills/toggle path is registered in POST routing."""
routes_source = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text("utf-8")
assert '/api/skills/toggle' in routes_source
def test_skills_list_includes_disabled_flag():
"""Each skill dict in the API response must have a 'disabled' boolean field."""
routes_source = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text("utf-8")
assert '"disabled": name in disabled' in routes_source
def test_i18n_keys_added():
"""The three new i18n keys must exist in the English locale."""
assert "skill_enabled" in I18N_JS
assert "skill_disabled" in I18N_JS
assert "skill_toggle_failed" in I18N_JS
def test_toggle_css_classes_exist():
"""Toggle switch CSS classes must be in style.css."""
assert ".skill-toggle" in STYLE_CSS
assert ".skill-toggle.enabled" in STYLE_CSS
assert ".skill-item.disabled" in STYLE_CSS
def test_render_skills_produces_toggle_buttons():
"""renderSkills() must include toggleSkill and skill-toggle."""
assert "toggleSkill(" in PANELS_JS
assert "skill-toggle" in PANELS_JS
def test_toggle_skill_function_defined():
"""toggleSkill() async function must be defined."""
assert "async function toggleSkill(" in PANELS_JS
def test_disabled_list_round_trip(tmp_path):
"""Verify that writing and reading the disabled list through the config
module's YAML functions preserves values correctly, including normalization
of None/str/list shapes."""
from api.config import _load_yaml_config_file, _save_yaml_config_file
config_path = tmp_path / "config.yaml"
# Write initial config
_save_yaml_config_file(config_path, {"skills": {"disabled": []}})
# Read, add skill, write
cfg = _load_yaml_config_file(config_path)
cfg.setdefault("skills", {})
disabled = cfg["skills"].get("disabled", [])
disabled.append("skill-a")
disabled.append("skill-b")
cfg["skills"]["disabled"] = disabled
_save_yaml_config_file(config_path, cfg)
# Read back and verify
cfg2 = _load_yaml_config_file(config_path)
assert cfg2["skills"]["disabled"] == ["skill-a", "skill-b"]
# Remove one skill, write, verify
cfg2["skills"]["disabled"] = [d for d in cfg2["skills"]["disabled"] if d != "skill-a"]
_save_yaml_config_file(config_path, cfg2)
cfg3 = _load_yaml_config_file(config_path)
assert cfg3["skills"]["disabled"] == ["skill-b"]
def test_normalize_names_list():
"""_normalize_names_list handles None, str, list, and deduplicates."""
from api.routes import _normalize_names_list
assert _normalize_names_list(None) == []
assert _normalize_names_list("foo") == ["foo"]
assert _normalize_names_list(["a", "b"]) == ["a", "b"]
assert _normalize_names_list(["a", "a"]) == ["a"]
assert _normalize_names_list([" a ", "b"]) == ["a", "b"]
assert _normalize_names_list("") == []
assert _normalize_names_list([]) == []
def test_toggle_name_in_list():
"""_toggle_name_in_list adds when enabled=False, removes when enabled=True."""
from api.routes import _toggle_name_in_list
# Add to empty list
assert _toggle_name_in_list([], "foo", enabled=False) == ["foo"]
# Add to existing list
assert _toggle_name_in_list(["bar"], "foo", enabled=False) == ["bar", "foo"]
# Idempotent add
assert _toggle_name_in_list(["foo"], "foo", enabled=False) == ["foo"]
# Remove from list
assert _toggle_name_in_list(["foo", "bar"], "foo", enabled=True) == ["bar"]
# Remove non-existent is a no-op
assert _toggle_name_in_list(["bar"], "foo", enabled=True) == ["bar"]
# Remove from empty is a no-op
assert _toggle_name_in_list([], "foo", enabled=True) == []
# Handles str input (normalization)
assert _toggle_name_in_list("foo", "bar", enabled=False) == ["foo", "bar"]
assert _toggle_name_in_list("foo", "foo", enabled=True) == []
def test_platform_disabled_write_through(tmp_path, monkeypatch):
"""Toggle writes through to platform_disabled.webui when that key exists."""
from unittest.mock import MagicMock
from api.routes import _handle_skill_toggle
from api.config import _load_yaml_config_file
config_path = tmp_path / "config.yaml"
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
import yaml
config = {
"skills": {
"disabled": ["skill-a"],
"platform_disabled": {
"webui": ["skill-a", "skill-b"],
"telegram": ["skill-c"],
},
}
}
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(yaml.dump(config), encoding="utf-8")
# Mock _find_skill_in_dirs to avoid agent.skill_utils import in CI
fake_dir = tmp_path / "skills" / "skill-a"
fake_dir.mkdir(parents=True, exist_ok=True)
fake_md = fake_dir / "SKILL.md"
fake_md.write_text("---\nname: skill-a\n---\nA skill", encoding="utf-8")
monkeypatch.setattr(
"api.routes._find_skill_in_dirs",
lambda name, dirs: (fake_dir, fake_md),
)
handler = MagicMock()
# Toggle skill-a OFF — it's already disabled, verify idempotency
_handle_skill_toggle(handler, {"name": "skill-a", "enabled": False})
cfg_after = _load_yaml_config_file(config_path)
assert "skill-a" in cfg_after["skills"]["disabled"]
assert "skill-a" in cfg_after["skills"]["platform_disabled"]["webui"]
# Toggle skill-a ON
_handle_skill_toggle(handler, {"name": "skill-a", "enabled": True})
cfg_after2 = _load_yaml_config_file(config_path)
assert "skill-a" not in cfg_after2["skills"]["disabled"]
assert "skill-a" not in cfg_after2["skills"]["platform_disabled"]["webui"]
# Other platform keys are untouched
assert cfg_after2["skills"]["platform_disabled"]["telegram"] == ["skill-c"]
def test_platform_disabled_no_write_through_when_key_absent(tmp_path, monkeypatch):
"""Toggle does NOT create platform_disabled.webui when it doesn't exist."""
from unittest.mock import MagicMock
from api.routes import _handle_skill_toggle
from api.config import _load_yaml_config_file
config_path = tmp_path / "config.yaml"
monkeypatch.setattr("api.routes._get_config_path", lambda: config_path)
import yaml
config = {"skills": {"disabled": []}}
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(yaml.dump(config), encoding="utf-8")
# Mock _find_skill_in_dirs to avoid agent.skill_utils import in CI
fake_dir = tmp_path / "skills" / "skill-b"
fake_dir.mkdir(parents=True, exist_ok=True)
fake_md = fake_dir / "SKILL.md"
fake_md.write_text("---\nname: skill-b\n---\nB skill", encoding="utf-8")
monkeypatch.setattr(
"api.routes._find_skill_in_dirs",
lambda name, dirs: (fake_dir, fake_md),
)
handler = MagicMock()
_handle_skill_toggle(handler, {"name": "skill-b", "enabled": False})
cfg_after = _load_yaml_config_file(config_path)
assert "skill-b" in cfg_after["skills"]["disabled"]
# platform_disabled was never created
assert "platform_disabled" not in cfg_after.get("skills", {})
+10
View File
@@ -98,6 +98,7 @@ class TestClarifyCardHTML:
assert 'id="clarifyChoices"' in html, "clarify choices container missing"
assert 'id="clarifyInput"' in html, "clarify input missing"
assert 'id="clarifySubmit"' in html, "clarify submit button missing"
assert 'id="clarifyCollapse"' in html, "clarify collapse button missing"
def test_clarify_card_has_data_i18n(self):
html = read(REPO / "static/index.html")
@@ -167,7 +168,9 @@ class TestClarifyCardCSS:
".clarify-response",
".clarify-input",
".clarify-submit",
".clarify-collapse",
".clarify-hint",
".clarify-card.collapsed",
):
assert cls in css, f"CSS class '{cls}' missing"
@@ -317,6 +320,13 @@ class TestClarifyMessagesJS:
for token in ("startClarifyPolling", "stopClarifyPolling", "hideClarifyCard", "_clarifySessionId"):
assert token in src, f"{token} missing from messages.js"
def test_clarify_card_can_collapse_without_hiding_prompt(self):
src = read(REPO / "static/messages.js")
assert "function toggleClarifyCardCollapsed" in src, "clarify collapse toggle missing"
assert "aria-expanded" in src, "clarify collapse button should expose expanded state"
assert "card.classList.remove(\"collapsed\")" in src, \
"new clarification prompts should reopen rather than stay collapsed"
# ── boot.js keyboard shortcut ────────────────────────────────────────────────
+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
+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'
)
+151
View File
@@ -0,0 +1,151 @@
"""Regression coverage for the function-name × window-attached-config collision class.
This test guards against a specific failure mode that has caused two
brick-class regressions (v0.51.106 #2715 `_pinnedSessionsLimit`, v0.51.117
#2771 `_inflightStateLimits`):
- Some module declares `function foo(){...}` at top level. Since the
WebUI ships classic (non-module) scripts via `<script defer>`, top-
level function declarations attach to `window` as `window.foo`.
- Another module later does `window.foo = {...}` (or `= 8`, etc).
- Boot order makes the assignment win, so by the time anyone tries
`foo()` they're calling an Object/Number → `TypeError: foo is not a
function`.
This is hard to spot in code review because the function and the config
object live in different files and the name choice is locally innocuous.
The test below scans static JS for any top-level `function foo()` decl
whose name also appears as the target of `window.foo = <non-function-
non-identifier>`. False-positive shape (which we deliberately exclude):
re-binding a function reference onto `window` (`window.foo = foo;` or
`window.foo = function(){...};`) — this is the normal "expose to global"
pattern.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
STATIC_JS = sorted(
p for p in (REPO_ROOT / "static").glob("*.js")
if not p.name.endswith(".min.js")
)
# Top-of-line: `function NAME(`
TOP_LEVEL_FN_RE = re.compile(r"^function\s+([A-Za-z_\$][A-Za-z_\$0-9]*)\s*\(", re.MULTILINE)
# `window.NAME = <rhs>` where rhs is the next non-space chunk.
# We only care about the *value shape* of rhs. Lookahead must be long enough
# to see `function(` (8 chars) even after some whitespace; we use 32 chars to
# be safe against newlines and odd indenting.
WINDOW_ASSIGN_RE = re.compile(
r"window\.([A-Za-z_\$][A-Za-z_\$0-9]*)\s*=\s*([^=].{0,32})",
re.DOTALL,
)
def _classify_rhs(rhs: str) -> str:
"""Classify the right-hand side of `window.X = rhs`.
Returns one of:
- 'function' — explicit `function(` literal (named or anonymous)
- 'identifier' — bare identifier (almost certainly a function reference)
- 'object' — `{...}` object literal (THE BUG SHAPE — function got
replaced by config object)
- 'number' — numeric literal (ALSO BUG SHAPE — #2715 was this)
- 'arrow' — `() =>` / `x =>` arrow function (benign re-bind)
- 'other' — anything else; treat as suspicious to be safe
"""
rhs = rhs.lstrip()
if rhs.startswith("function"):
return "function"
if rhs.startswith("{"):
return "object"
# Arrow function: `(args) =>` or `x =>`
if rhs.startswith("(") and "=>" in rhs:
return "arrow"
if re.match(r"[A-Za-z_\$][A-Za-z_\$0-9]*\s*=>", rhs):
return "arrow"
# Numeric literal: int or decimal
if re.match(r"-?\d", rhs):
return "number"
# Bare identifier reference: looks like `_foo;` or `_foo,` or `_foo ` etc.
# Allow ||, &&, ? chains too (e.g. `window.X = window.X || false;`).
if re.match(r"[A-Za-z_\$][A-Za-z_\$0-9]*[\s;,)|&?.]", rhs):
return "identifier"
return "other"
def test_no_top_level_function_shadowed_by_window_object_assignment():
"""Catch the v0.51.106 / v0.51.117 collision class before it ships.
See #2715 (`_pinnedSessionsLimit` function shadowed by `window._pinnedSessionsLimit = <int>`)
and #2771 (`_inflightStateLimits` function shadowed by
`window._inflightStateLimits = {...}`). Both broke entire user
workflows for everyone on the affected version.
"""
# Collect all top-level function names across every static JS file.
fn_names: dict[str, list[str]] = {}
for js_file in STATIC_JS:
src = js_file.read_text(encoding="utf-8")
for m in TOP_LEVEL_FN_RE.finditer(src):
fn_names.setdefault(m.group(1), []).append(js_file.name)
# Find every window.NAME = <rhs> assignment, classify the rhs.
collisions: list[str] = []
for js_file in STATIC_JS:
src = js_file.read_text(encoding="utf-8")
for m in WINDOW_ASSIGN_RE.finditer(src):
name, rhs_snippet = m.group(1), m.group(2)
if name not in fn_names:
continue
kind = _classify_rhs(rhs_snippet)
if kind in {"function", "identifier", "arrow"}:
continue # benign exposure of a function to global scope.
# 'object', 'number', and 'other' are the BUG shapes.
collisions.append(
f"In {js_file.name}: `window.{name} = ...` (rhs={kind!r}) "
f"shadows the top-level `function {name}()` declared in "
f"{', '.join(fn_names[name])}. This will cause "
f"`TypeError: {name} is not a function` once boot.js's "
f"assignment overwrites the function. See #2715, #2771."
)
assert not collisions, (
"Function-name × window-config collision detected — this is the "
"brick-class regression shape from #2715 and #2771:\n - "
+ "\n - ".join(collisions)
)
def test_inflight_state_limits_no_longer_collides_with_window_config():
"""Issue-pinned regression for #2771 specifically.
Confirms the function rename landed and the old colliding name is gone.
"""
ui_js = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8")
boot_js = (REPO_ROOT / "static" / "boot.js").read_text(encoding="utf-8")
# The window-attached config still exists (we deliberately kept this name).
assert "window._inflightStateLimits={" in boot_js, (
"boot.js should still expose the config under the documented name."
)
# The function must use the renamed identifier.
assert "function _getInflightStateLimits()" in ui_js, (
"ui.js should declare the limit-reader as `_getInflightStateLimits()` "
"to avoid the #2771 collision."
)
# The old colliding name must not appear as a function declaration anywhere.
assert "function _inflightStateLimits(" not in ui_js, (
"`function _inflightStateLimits()` is the colliding name from #2771 "
"and must not be reintroduced."
)
# Every call site uses the new name.
assert "_inflightStateLimits()" not in ui_js, (
"Stale call sites to the old function name `_inflightStateLimits()` "
"remain in ui.js (#2771). Update them to `_getInflightStateLimits()`."
)
+26
View File
@@ -0,0 +1,26 @@
from api.workspace import dir_signature, list_dir
def test_directory_signature_is_metadata_only_and_changes_with_entries(tmp_path):
(tmp_path / "alpha.txt").write_text("one", encoding="utf-8")
entries = list_dir(tmp_path, ".")
sig1 = dir_signature(tmp_path, ".", entries)
assert isinstance(sig1, str)
assert len(sig1) == 64
assert all("mtime_ns" in entry for entry in entries)
(tmp_path / "beta.txt").write_text("two", encoding="utf-8")
entries2 = list_dir(tmp_path, ".")
sig2 = dir_signature(tmp_path, ".", entries2)
assert sig2 != sig1
def test_directory_signature_can_be_computed_from_supplied_entries(tmp_path):
(tmp_path / "alpha.txt").write_text("one", encoding="utf-8")
entries = list_dir(tmp_path, ".")
assert dir_signature(tmp_path, ".", entries) == dir_signature(tmp_path, ".", entries)
+928
View File
@@ -0,0 +1,928 @@
import json
import pathlib
import subprocess
import types
import uuid
import urllib.error
import urllib.parse
import urllib.request
from io import BytesIO
import pytest
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).parent.parent
def _git(cwd, *args):
result = subprocess.run(
["git", *args],
cwd=str(cwd),
shell=False,
text=True,
capture_output=True,
timeout=20,
)
assert result.returncode == 0, result.stderr or result.stdout
return result.stdout
def _init_repo(path):
path.mkdir(parents=True, exist_ok=True)
_git(path, "init")
_git(path, "config", "user.email", "hermes-tests@example.invalid")
_git(path, "config", "user.name", "Hermes Tests")
return path
def _commit_all(path, message="initial"):
_git(path, "add", ".")
_git(path, "commit", "-m", message)
def _get(path):
try:
with urllib.request.urlopen(BASE + path, 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 _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_list, ws=None):
body = {}
if ws:
body["workspace"] = str(ws)
data, status = _post("/api/session/new", body)
assert status == 200
sid = data["session"]["session_id"]
created_list.append(sid)
return sid, pathlib.Path(data["session"]["workspace"])
class _CaptureHandler:
def __init__(self):
self.status = None
self.headers = {}
self.response_headers = []
self.wfile = BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.response_headers.append((key, value))
def end_headers(self):
pass
def payload(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
def test_git_status_non_git_workspace(tmp_path):
from api.workspace_git import git_status
ws = tmp_path / "plain"
ws.mkdir()
assert git_status(ws) == {"is_git": False}
def test_git_status_handles_staged_unstaged_untracked_deleted_and_renamed(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
(repo / "delete-me.txt").write_text("bye\n", encoding="utf-8")
(repo / "old name.txt").write_text("move\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "staged.txt").write_text("staged\n", encoding="utf-8")
_git(repo, "add", "staged.txt")
(repo / "delete-me.txt").unlink()
_git(repo, "mv", "old name.txt", "new name.txt")
(repo / "untracked space.txt").write_text("new\nfile\n", encoding="utf-8")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert status["is_git"] is True
assert by_path["tracked.txt"]["unstaged"] is True
assert by_path["staged.txt"]["staged"] is True
assert by_path["delete-me.txt"]["status"] == "D"
assert by_path["new name.txt"]["old_path"] == "old name.txt"
assert by_path["untracked space.txt"]["untracked"] is True
assert by_path["untracked space.txt"]["additions"] == 2
assert status["totals"]["changed"] >= 5
def test_git_status_reports_ignored_files_without_counting_them_as_changes(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / ".gitignore").write_text("*.log\nbuild/\n", encoding="utf-8")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "debug.log").write_text("ignored log\n", encoding="utf-8")
build = repo / "build"
build.mkdir()
(build / "artifact.txt").write_text("ignored artifact\n", encoding="utf-8")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert by_path["tracked.txt"]["unstaged"] is True
assert by_path["debug.log"]["ignored"] is True
assert by_path["debug.log"]["status"] == "Ignored"
assert by_path["build/"]["ignored"] is True
assert by_path["build/"]["staged"] is False
assert by_path["build/"]["untracked"] is False
assert status["totals"]["changed"] == 1
assert status["totals"]["untracked"] == 0
def test_git_status_ignores_crlf_only_worktree_noise(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8", newline="\n")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\r\ntwo\r\n", encoding="utf-8", newline="")
raw = _git(repo, "status", "--porcelain", "--", "tracked.txt")
assert raw.startswith(" M")
status = git_status(repo)
assert status["totals"]["changed"] == 0
assert status["files"] == []
assert status["noise_filtering"]["active"] is True
assert status["noise_filtering"]["crlf_only"] == 1
def test_git_status_keeps_real_edit_with_crlf_endings(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8", newline="\n")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\r\ntwo\r\nthree\r\n", encoding="utf-8", newline="")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert status["totals"]["changed"] == 1
assert by_path["tracked.txt"]["unstaged"] is True
assert by_path["tracked.txt"]["additions"] == 1
assert by_path["tracked.txt"]["deletions"] == 0
def test_git_status_ignores_filemode_only_noise(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
script = repo / "script.sh"
script.write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "update-index", "--chmod=+x", "script.sh")
raw = _git(repo, "status", "--porcelain", "--", "script.sh")
assert "script.sh" in raw
status = git_status(repo)
assert status["totals"]["changed"] == 0
assert status["files"] == []
assert status["noise_filtering"]["active"] is True
def test_git_status_scopes_nested_workspace_to_that_directory(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
nested = repo / "app"
nested.mkdir()
(nested / "inside.txt").write_text("inside\n", encoding="utf-8")
(repo / "outside.txt").write_text("outside\n", encoding="utf-8")
_commit_all(repo)
(nested / "inside.txt").write_text("inside\nchanged\n", encoding="utf-8")
(repo / "outside.txt").write_text("outside\nchanged\n", encoding="utf-8")
status = git_status(nested)
paths = {item["path"] for item in status["files"]}
assert paths == {"inside.txt"}
def test_git_diff_generates_untracked_text_diff_and_blocks_escape(tmp_path):
from api.workspace_git import GitWorkspaceError, git_diff
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "new file.txt").write_text("hello\nworld\n", encoding="utf-8")
diff = git_diff(repo, "new file.txt", "unstaged")
assert diff["binary"] is False
assert "+++ b/new file.txt" in diff["diff"]
assert "+hello" in diff["diff"]
with pytest.raises(GitWorkspaceError):
git_diff(repo, "../outside.txt", "unstaged")
def test_git_status_reports_untracked_files_inside_directories(tmp_path):
from api.workspace_git import git_discard, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
nested = repo / "newdir"
nested.mkdir()
(nested / "a.txt").write_text("hello\n", encoding="utf-8")
status = git_status(repo)
paths = {item["path"] for item in status["files"]}
assert "newdir/a.txt" in paths
assert "newdir/" not in paths
git_discard(repo, ["newdir/a.txt"], delete_untracked=True)
assert not (nested / "a.txt").exists()
def test_git_status_reports_ignored_files_without_counting_them_as_changed(tmp_path):
from api.workspace_git import git_status
repo = _init_repo(tmp_path / "repo")
(repo / ".gitignore").write_text("*.log\nbuild/\n", encoding="utf-8")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "debug.log").write_text("ignored log\n", encoding="utf-8")
build = repo / "build"
build.mkdir()
(build / "artifact.txt").write_text("ignored artifact\n", encoding="utf-8")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert by_path["tracked.txt"]["unstaged"] is True
assert by_path["debug.log"]["ignored"] is True
assert by_path["debug.log"]["status"] == "Ignored"
assert by_path["debug.log"]["staged"] is False
assert by_path["debug.log"]["unstaged"] is False
assert by_path["debug.log"]["untracked"] is False
assert any(item["ignored"] and item["path"].startswith("build") for item in status["files"])
assert status["totals"]["changed"] == 1
assert status["totals"]["untracked"] == 0
def test_git_diff_large_untracked_file_is_bounded(tmp_path):
from api.workspace_git import DIFF_SIZE_LIMIT, git_diff, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
large = repo / "large.txt"
large.write_text("x" * (DIFF_SIZE_LIMIT + 1), encoding="utf-8")
status = git_status(repo)
by_path = {item["path"]: item for item in status["files"]}
assert by_path["large.txt"]["untracked"] is True
assert by_path["large.txt"]["additions"] == 0
diff = git_diff(repo, "large.txt", "unstaged")
assert diff["too_large"] is True
assert diff["diff"] == ""
def test_git_stage_unstage_discard_and_commit(tmp_path):
from api.workspace_git import git_commit, git_discard, git_stage, git_status, git_unstage
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
staged = git_stage(repo, ["tracked.txt"])
assert staged["totals"]["staged"] == 1
unstaged = git_unstage(repo, ["tracked.txt"])
assert unstaged["totals"]["staged"] == 0
assert unstaged["totals"]["unstaged"] == 1
git_discard(repo, ["tracked.txt"])
assert git_status(repo)["totals"]["changed"] == 0
(repo / "tracked.txt").write_text("one\nthree\n", encoding="utf-8")
git_stage(repo, ["tracked.txt"])
committed = git_commit(repo, "Update tracked file")
assert committed["ok"] is True
assert committed["commit"]
assert committed["status"]["totals"]["changed"] == 0
def test_git_commit_selected_ignores_unrelated_real_index(tmp_path):
from api.workspace_git import git_commit_selected, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "selected.txt").write_text("one\n", encoding="utf-8")
(repo / "staged.txt").write_text("alpha\n", encoding="utf-8")
_commit_all(repo)
(repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "staged.txt").write_text("alpha\nbeta\n", encoding="utf-8")
_git(repo, "add", "staged.txt")
committed = git_commit_selected(repo, "Commit selected only", ["selected.txt"])
assert committed["ok"] is True
assert committed["paths"] == ["selected.txt"]
assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"]
by_path = {item["path"]: item for item in git_status(repo)["files"]}
assert "selected.txt" not in by_path
assert by_path["staged.txt"]["staged"] is True
def test_git_commit_selected_supports_initial_commit(tmp_path):
from api.workspace_git import git_commit_selected, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "first.txt").write_text("first\n", encoding="utf-8")
committed = git_commit_selected(repo, "Initial selected commit", ["first.txt"])
assert committed["ok"] is True
assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["first.txt"]
assert git_status(repo)["totals"]["changed"] == 0
def test_git_commit_selected_preserves_rename_semantics(tmp_path):
from api.workspace_git import git_commit_selected, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "old.txt").write_text("old\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "mv", "old.txt", "new.txt")
committed = git_commit_selected(repo, "Rename selected file", ["new.txt"])
assert committed["ok"] is True
assert _git(repo, "ls-tree", "--name-only", "HEAD").splitlines() == ["new.txt"]
assert "old.txt" not in _git(repo, "status", "--porcelain=v2")
assert git_status(repo)["totals"]["changed"] == 0
def test_git_commit_selected_handles_untracked_and_mixed_paths(tmp_path):
from api.workspace_git import git_commit_selected
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "new.txt").write_text("new\n", encoding="utf-8")
committed = git_commit_selected(repo, "Commit mixed selected files", ["tracked.txt", "new.txt"])
assert committed["ok"] is True
assert set(_git(repo, "show", "--name-only", "--format=", "HEAD").splitlines()) == {
"tracked.txt",
"new.txt",
}
def test_git_commit_selected_respects_nested_workspace_scope(tmp_path):
from api.workspace_git import GitWorkspaceError, git_commit_selected
repo = _init_repo(tmp_path / "repo")
nested = repo / "app"
nested.mkdir()
(nested / "inside.txt").write_text("inside\n", encoding="utf-8")
(repo / "outside.txt").write_text("outside\n", encoding="utf-8")
_commit_all(repo)
(nested / "inside.txt").write_text("inside\nchanged\n", encoding="utf-8")
(repo / "outside.txt").write_text("outside\nchanged\n", encoding="utf-8")
committed = git_commit_selected(nested, "Nested selected commit", ["inside.txt"])
assert committed["paths"] == ["inside.txt"]
assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["app/inside.txt"]
with pytest.raises(GitWorkspaceError) as outside:
git_commit_selected(nested, "Outside", ["../outside.txt"])
assert outside.value.code == "path_outside_workspace"
def test_git_commit_selected_rejects_conflicts_and_path_traversal(tmp_path):
from api.workspace_git import GitWorkspaceError, git_commit_selected
repo = _init_repo(tmp_path / "repo")
(repo / "conflict.txt").write_text("base\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "-b", "side")
(repo / "conflict.txt").write_text("side\n", encoding="utf-8")
_commit_all(repo, "side")
_git(repo, "checkout", "master")
(repo / "conflict.txt").write_text("main\n", encoding="utf-8")
_commit_all(repo, "main")
subprocess.run(["git", "merge", "side"], cwd=repo, shell=False, text=True, capture_output=True, timeout=20)
with pytest.raises(GitWorkspaceError) as conflict:
git_commit_selected(repo, "Nope", ["conflict.txt"])
assert conflict.value.code == "conflict"
with pytest.raises(GitWorkspaceError) as traversal:
git_commit_selected(repo, "Nope", ["../outside.txt"])
assert traversal.value.code == "path_outside_workspace"
def test_selected_commit_message_prompt_uses_selected_diff(tmp_path):
from api.workspace_git import selected_commit_message_prompt
repo = _init_repo(tmp_path / "repo")
(repo / "selected.txt").write_text("one\n", encoding="utf-8")
(repo / "other.txt").write_text("alpha\n", encoding="utf-8")
_commit_all(repo)
(repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "other.txt").write_text("alpha\nbeta\n", encoding="utf-8")
prompt = selected_commit_message_prompt(repo, ["selected.txt"])
assert "selected.txt" in prompt["user_prompt"]
assert "+two" in prompt["user_prompt"]
assert "other.txt" not in prompt["user_prompt"]
assert "beta" not in prompt["user_prompt"]
def test_staged_commit_message_prompt_uses_only_staged_diff(tmp_path):
from api.workspace_git import (
GitWorkspaceError,
clean_generated_commit_message,
staged_commit_message_prompt,
)
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("one\nstaged\n", encoding="utf-8")
_git(repo, "add", "tracked.txt")
(repo / "tracked.txt").write_text("one\nstaged\nunstaged\n", encoding="utf-8")
prompt = staged_commit_message_prompt(repo)
assert prompt["truncated"] is False
assert "tracked.txt" in prompt["user_prompt"]
assert "+staged" in prompt["user_prompt"]
assert "unstaged" not in prompt["user_prompt"]
assert "Never mention AI, Cursor, Zed, agents" in prompt["system_prompt"]
_git(repo, "restore", "--staged", "tracked.txt")
with pytest.raises(GitWorkspaceError):
staged_commit_message_prompt(repo)
assert clean_generated_commit_message("```text\nSubject\n\n- Body\n```") == "Subject\n\n- Body"
def test_git_fetch_pull_and_push_with_upstream(tmp_path):
from api.workspace_git import git_fetch, git_pull, git_push, git_status
remote = tmp_path / "remote.git"
_git(tmp_path, "init", "--bare", str(remote))
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(origin)
_git(origin, "remote", "add", "origin", str(remote))
_git(origin, "push", "-u", "origin", "HEAD")
clone = tmp_path / "clone"
_git(tmp_path, "clone", str(remote), str(clone))
_git(clone, "config", "user.email", "hermes-tests@example.invalid")
_git(clone, "config", "user.name", "Hermes Tests")
(origin / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
_commit_all(origin, "Remote update")
_git(origin, "push")
fetched = git_fetch(clone)
assert fetched["status"]["behind"] == 1
pulled = git_pull(clone)
assert pulled["status"]["behind"] == 0
assert (clone / "tracked.txt").read_text(encoding="utf-8") == "one\ntwo\n"
(clone / "tracked.txt").write_text("one\ntwo\nthree\n", encoding="utf-8")
_git(clone, "add", "tracked.txt")
_git(clone, "commit", "-m", "Local update")
assert git_status(clone)["ahead"] == 1
pushed = git_push(clone)
assert pushed["status"]["ahead"] == 0
def test_git_branches_lists_local_remote_and_upstream(tmp_path):
from api.workspace_git import git_branches
remote = tmp_path / "remote.git"
_git(tmp_path, "init", "--bare", str(remote))
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(origin)
_git(origin, "branch", "-M", "main")
_git(origin, "remote", "add", "origin", str(remote))
_git(origin, "push", "-u", "origin", "main")
_git(remote, "symbolic-ref", "HEAD", "refs/heads/main")
clone = tmp_path / "clone"
_git(tmp_path, "clone", str(remote), str(clone))
branches = git_branches(clone)
assert branches["current"] == "main"
assert branches["detached"] is False
assert any(item["name"] == "main" and item["upstream"] == "origin/main" for item in branches["local"])
main = next(item for item in branches["local"] if item["name"] == "main")
assert "updated_relative" in main and "author" in main and "subject" in main
assert any(item["name"] == "origin/main" for item in branches["remote"])
assert not any(item["name"] == "origin" for item in branches["remote"])
def test_git_checkout_local_new_remote_dirty_and_invalid_refs(tmp_path):
from api.workspace_git import GitWorkspaceError, git_branches, git_checkout
remote = tmp_path / "remote.git"
_git(tmp_path, "init", "--bare", str(remote))
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(origin)
_git(origin, "branch", "-M", "main")
_git(origin, "remote", "add", "origin", str(remote))
_git(origin, "push", "-u", "origin", "main")
_git(remote, "symbolic-ref", "HEAD", "refs/heads/main")
_git(origin, "checkout", "-b", "remote-feature")
(origin / "remote.txt").write_text("remote\n", encoding="utf-8")
_commit_all(origin, "remote feature")
_git(origin, "push", "-u", "origin", "remote-feature")
clone = tmp_path / "clone"
_git(tmp_path, "clone", str(remote), str(clone))
_git(clone, "config", "user.email", "hermes-tests@example.invalid")
_git(clone, "config", "user.name", "Hermes Tests")
created = git_checkout(clone, "main", "new", new_branch="local-work")
assert created["current_branch"] == "local-work"
assert git_branches(clone)["current"] == "local-work"
switched = git_checkout(clone, "main", "local")
assert switched["current_branch"] == "main"
tracked = git_checkout(clone, "origin/remote-feature", "remote", new_branch="remote-feature", track=True)
assert tracked["current_branch"] == "remote-feature"
assert git_branches(clone)["upstream"] == "origin/remote-feature"
(clone / "tracked.txt").write_text("dirty\n", encoding="utf-8")
with pytest.raises(GitWorkspaceError) as dirty:
git_checkout(clone, "main", "local")
assert dirty.value.code == "dirty_worktree"
_git(clone, "restore", "tracked.txt")
with pytest.raises(GitWorkspaceError) as invalid:
git_checkout(clone, "does-not-exist", "local")
assert invalid.value.code in {"invalid_ref", "git_failed"}
def test_git_checkout_detached_requires_explicit_mode(tmp_path):
from api.workspace_git import git_branches, git_checkout
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
sha = _git(repo, "rev-parse", "--short", "HEAD").strip()
result = git_checkout(repo, sha, "detached")
assert result["ok"] is True
branches = git_branches(repo)
assert branches["detached"] is True
assert branches["current"] == sha
def test_git_stash_and_checkout_is_explicit(tmp_path):
from api.workspace_git import git_stash_and_checkout, git_status
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "-b", "target")
_git(repo, "checkout", "master")
(repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
result = git_stash_and_checkout(repo, "target", "local")
assert result["ok"] is True
assert result["stashed"] is True
assert result["stash_name"].startswith("hermes-webui branch switch")
assert result["current_branch"] == "target"
assert git_status(repo)["totals"]["changed"] == 0
assert "hermes-webui branch switch to target" in _git(repo, "stash", "list")
def test_git_stash_and_checkout_restores_branch_changes_when_returning(tmp_path):
from api.workspace_git import git_stash_and_checkout, git_status
repo = _init_repo(tmp_path / "repo")
_git(repo, "branch", "-M", "main")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "-b", "feature")
_git(repo, "checkout", "main")
(repo / "tracked.txt").write_text("main dirty\n", encoding="utf-8")
(repo / "main-only.txt").write_text("untracked on main\n", encoding="utf-8")
to_feature = git_stash_and_checkout(repo, "feature", "local")
assert to_feature["ok"] is True
assert to_feature["stashed"] is True
assert to_feature["current_branch"] == "feature"
assert git_status(repo)["totals"]["changed"] == 0
assert not (repo / "main-only.txt").exists()
(repo / "feature-only.txt").write_text("untracked on feature\n", encoding="utf-8")
to_main = git_stash_and_checkout(repo, "main", "local")
assert to_main["ok"] is True
assert to_main["stashed"] is True
assert to_main["current_branch"] == "main"
assert to_main["restored_stash"]["branch"] == "main"
assert (repo / "tracked.txt").read_text(encoding="utf-8") == "main dirty\n"
assert (repo / "main-only.txt").read_text(encoding="utf-8") == "untracked on main\n"
assert not (repo / "feature-only.txt").exists()
stash_list = _git(repo, "stash", "list")
assert "On main: hermes-webui branch switch" not in stash_list
assert "On feature: hermes-webui branch switch" in stash_list
def test_git_stash_and_checkout_reports_restore_conflicts_without_dropping_stash(tmp_path):
from api.workspace_git import git_stash_and_checkout
repo = _init_repo(tmp_path / "repo")
_git(repo, "branch", "-M", "main")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "checkout", "-b", "feature")
_git(repo, "checkout", "main")
(repo / "tracked.txt").write_text("main dirty\n", encoding="utf-8")
git_stash_and_checkout(repo, "feature", "local")
_git(repo, "checkout", "main")
(repo / "tracked.txt").write_text("main changed while parked\n", encoding="utf-8")
_commit_all(repo, "advance main")
_git(repo, "checkout", "feature")
result = git_stash_and_checkout(repo, "main", "local")
assert result["ok"] is True
assert result["current_branch"] == "main"
assert result["restore_failed"] is True
assert result["restore_stash"]["branch"] == "main"
assert "On main: hermes-webui branch switch" in _git(repo, "stash", "list")
def test_git_stash_checkout_validates_before_stashing(tmp_path):
from api.workspace_git import GitWorkspaceError, git_stash_and_checkout
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
(repo / "tracked.txt").write_text("dirty\n", encoding="utf-8")
with pytest.raises(GitWorkspaceError) as invalid:
git_stash_and_checkout(repo, "missing-branch", "local")
assert invalid.value.code == "invalid_ref"
assert "M tracked.txt" in _git(repo, "status", "--porcelain")
assert _git(repo, "stash", "list") == ""
def test_git_routes_status_diff_stage_unstage_discard_commit(cleanup_test_sessions):
sid, base_ws = _make_session(cleanup_test_sessions)
repo = base_ws / f"git-route-{uuid.uuid4().hex[:8]}"
_init_repo(repo)
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
status, code = _get(f"/api/git/status?session_id={sid}")
assert code == 200
assert status["git"]["totals"]["unstaged"] == 1
diff, code = _get(
f"/api/git/diff?session_id={sid}&path={urllib.parse.quote('tracked.txt')}&kind=unstaged"
)
assert code == 200
assert "+two" in diff["diff"]["diff"]
staged, code = _post("/api/git/stage", {"session_id": sid, "paths": ["tracked.txt"]})
assert code == 200 and staged["git"]["totals"]["staged"] == 1
unstaged, code = _post("/api/git/unstage", {"session_id": sid, "paths": ["tracked.txt"]})
assert code == 200 and unstaged["git"]["totals"]["unstaged"] == 1
discarded, code = _post("/api/git/discard", {"session_id": sid, "paths": ["tracked.txt"]})
assert code == 200 and discarded["git"]["totals"]["changed"] == 0
(repo / "tracked.txt").write_text("one\nthree\n", encoding="utf-8")
_post("/api/git/stage", {"session_id": sid, "paths": ["tracked.txt"]})
committed, code = _post("/api/git/commit", {"session_id": sid, "message": "Route commit"})
assert code == 200
assert committed["ok"] is True
assert committed["status"]["totals"]["changed"] == 0
def test_git_routes_branches_and_checkout(cleanup_test_sessions):
sid, base_ws = _make_session(cleanup_test_sessions)
repo = base_ws / f"git-branch-route-{uuid.uuid4().hex[:8]}"
_init_repo(repo)
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "branch", "-M", "main")
_git(repo, "checkout", "-b", "feature")
_git(repo, "checkout", "main")
_post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
branches, code = _get(f"/api/git/branches?session_id={sid}")
assert code == 200
assert branches["branches"]["current"] == "main"
assert any(item["name"] == "feature" for item in branches["branches"]["local"])
checked, code = _post(
"/api/git/checkout",
{"session_id": sid, "ref": "feature", "mode": "local", "dirty_mode": "block"},
)
assert code == 200
assert checked["ok"] is True
assert checked["current_branch"] == "feature"
assert checked["git"]["branch"] == "feature"
def test_git_routes_selected_commit_and_structured_error(cleanup_test_sessions):
sid, base_ws = _make_session(cleanup_test_sessions)
repo = base_ws / f"git-selected-route-{uuid.uuid4().hex[:8]}"
_init_repo(repo)
(repo / "selected.txt").write_text("one\n", encoding="utf-8")
(repo / "other.txt").write_text("alpha\n", encoding="utf-8")
_commit_all(repo)
_post("/api/session/update", {"session_id": sid, "workspace": str(repo), "model": "openai/gpt-5.4-mini"})
(repo / "selected.txt").write_text("one\ntwo\n", encoding="utf-8")
(repo / "other.txt").write_text("alpha\nbeta\n", encoding="utf-8")
_git(repo, "add", "other.txt")
bad, code = _post("/api/git/commit-selected", {"session_id": sid, "message": "Bad", "paths": ["../x"]})
assert code == 400
assert bad["code"] == "path_outside_workspace"
committed, code = _post(
"/api/git/commit-selected",
{"session_id": sid, "message": "Selected route commit", "paths": ["selected.txt"]},
)
assert code == 200
assert committed["ok"] is True
assert committed["paths"] == ["selected.txt"]
assert _git(repo, "show", "--name-only", "--format=", "HEAD").splitlines() == ["selected.txt"]
def test_git_env_scrub_removes_redirecting_vars_and_preserves_temp_index(monkeypatch):
from api.workspace_git import _clean_git_env
monkeypatch.setenv("GIT_DIR", "/tmp/evil-git-dir")
monkeypatch.setenv("GIT_WORK_TREE", "/tmp/evil-work-tree")
monkeypatch.setenv("GIT_CONFIG_GLOBAL", "/tmp/evil-config")
monkeypatch.setenv("GIT_CONFIG_SYSTEM", "/tmp/evil-system-config")
monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.sshCommand")
monkeypatch.setenv("GIT_CONFIG_VALUE_0", "ssh -i /tmp/evil-key")
monkeypatch.setenv("GIT_CONFIG_PARAMETERS", "'core.sshCommand=ssh -i /tmp/evil-key'")
env = _clean_git_env({"GIT_INDEX_FILE": "/tmp/hermes-index"})
assert "GIT_DIR" not in env
assert "GIT_WORK_TREE" not in env
assert "GIT_CONFIG_GLOBAL" not in env
assert "GIT_CONFIG_SYSTEM" not in env
assert "GIT_CONFIG_COUNT" not in env
assert "GIT_CONFIG_KEY_0" not in env
assert "GIT_CONFIG_VALUE_0" not in env
assert "GIT_CONFIG_PARAMETERS" not in env
assert env["GIT_INDEX_FILE"] == "/tmp/hermes-index"
def test_git_error_classifier_identifies_non_fast_forward_push():
from api.workspace_git import _classify_git_error
assert _classify_git_error("Updates were rejected", ["push"]) == "non_fast_forward"
assert _classify_git_error("non-fast-forward", ["push"]) == "non_fast_forward"
assert _classify_git_error("fetch first", ["push"]) == "non_fast_forward"
def test_git_commit_hook_failure_returns_hook_failed_code(tmp_path):
from api.workspace_git import GitWorkspaceError, git_commit, git_stage
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
hook = repo / ".git" / "hooks" / "pre-commit"
hook.write_text("#!/bin/sh\necho hook blocked >&2\nexit 1\n", encoding="utf-8")
hook.chmod(0o755)
(repo / "tracked.txt").write_text("one\ntwo\n", encoding="utf-8")
git_stage(repo, ["tracked.txt"])
with pytest.raises(GitWorkspaceError) as exc:
git_commit(repo, "Hook should fail")
assert exc.value.code == "hook_failed"
def test_destructive_workspace_git_flag_defaults_off_and_accepts_truthy(monkeypatch):
from api.workspace_git import WORKSPACE_GIT_DESTRUCTIVE_ENV, workspace_git_destructive_enabled
monkeypatch.delenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, raising=False)
assert workspace_git_destructive_enabled() is False
monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "1")
assert workspace_git_destructive_enabled() is True
monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "true")
assert workspace_git_destructive_enabled() is True
def test_git_active_stream_lock_detection(monkeypatch):
from api import routes
from api.config import STREAMS, STREAMS_LOCK
session = types.SimpleNamespace(active_stream_id="stream-git-lock-test")
with STREAMS_LOCK:
STREAMS[session.active_stream_id] = object()
try:
assert routes._git_locked_by_active_stream(session) is True
finally:
with STREAMS_LOCK:
STREAMS.pop(session.active_stream_id, None)
assert routes._git_locked_by_active_stream(session) is False
def test_git_commit_route_rejects_active_stream(monkeypatch, tmp_path):
from api import routes
from api.config import STREAMS, STREAMS_LOCK
from api.workspace_git import WORKSPACE_GIT_DESTRUCTIVE_ENV
# Enable destructive ops for this in-process test — conftest.py sets the env
# var on the test_server subprocess env block, but this test calls
# _handle_git_commit() directly in the pytest process, which inherits
# the default-OFF setting. Without this monkeypatch, the destructive-mode
# gate fires first (403) before the active-stream check (409) can run.
monkeypatch.setenv(WORKSPACE_GIT_DESTRUCTIVE_ENV, "1")
repo = _init_repo(tmp_path / "repo")
(repo / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(repo)
_git(repo, "add", "tracked.txt")
session = types.SimpleNamespace(
session_id="sid-active-git",
workspace=str(repo),
active_stream_id="stream-active-git",
)
monkeypatch.setattr(routes, "get_session", lambda sid: session)
handler = _CaptureHandler()
with STREAMS_LOCK:
STREAMS[session.active_stream_id] = object()
try:
assert routes._handle_git_commit(
handler,
{"session_id": session.session_id, "message": "Should be blocked"},
) is True
finally:
with STREAMS_LOCK:
STREAMS.pop(session.active_stream_id, None)
assert handler.status == 409
payload = handler.payload()
assert payload["code"] == "active_stream"
assert "active" in payload["error"].lower()