Merge branch 'master' into fix/session-list-touch-actions
This commit is contained in:
commit
cf2eb9214c
68 files changed
+7824
-196
No files matched your search
+152
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
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 |
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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})
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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=[])
|
||||
|
||||
@@ -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"
|
||||
@@ -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", {})
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
@@ -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()`."
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user