diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e44f6f6..6a080547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 = {…}` / `= `, 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 ` 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//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 `.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 `
@@ -483,6 +488,7 @@ Clarification needed +
@@ -971,6 +977,11 @@
When enabled, older messages load automatically as you scroll upward. When disabled, use the older-messages button.
+
+ +
+
Choose which tabs appear in the sidebar and rail. Chat and Settings are always visible.
+
@@ -985,6 +996,13 @@
Used for new conversations. Existing conversations keep their selected model.
+
+ +
Hide the three default suggestion buttons on the empty new-chat screen to avoid accidental taps.
+
Controls how much metadata the session list shows in the left sidebar.
+
+ + +
Maximum number of active conversations that can be pinned in the sidebar. Default is 3.
+
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>>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, diff --git a/tests/conftest.py b/tests/conftest.py index 84bc88d1..6864c2fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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), diff --git a/tests/test_chat_panel_sidebar_resync.py b/tests/test_chat_panel_sidebar_resync.py new file mode 100644 index 00000000..1cc7f3aa --- /dev/null +++ b/tests/test_chat_panel_sidebar_resync.py @@ -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 diff --git a/tests/test_configurable_pinned_sessions_limit.py b/tests/test_configurable_pinned_sessions_limit.py new file mode 100644 index 00000000..3dba102c --- /dev/null +++ b/tests/test_configurable_pinned_sessions_limit.py @@ -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}) diff --git a/tests/test_context_message_dedup.py b/tests/test_context_message_dedup.py new file mode 100644 index 00000000..e80920eb --- /dev/null +++ b/tests/test_context_message_dedup.py @@ -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) diff --git a/tests/test_default_personality.py b/tests/test_default_personality.py new file mode 100644 index 00000000..28169a0a --- /dev/null +++ b/tests/test_default_personality.py @@ -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" \ No newline at end of file diff --git a/tests/test_inflight_send_start_race.py b/tests/test_inflight_send_start_race.py new file mode 100644 index 00000000..933e62a4 --- /dev/null +++ b/tests/test_inflight_send_start_race.py @@ -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" diff --git a/tests/test_inflight_storage_quota.py b/tests/test_inflight_storage_quota.py new file mode 100644 index 00000000..deba33d0 --- /dev/null +++ b/tests/test_inflight_storage_quota.py @@ -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 diff --git a/tests/test_issue1743_model_picker_race.py b/tests/test_issue1743_model_picker_race.py index 2bd46da9..33413902 100644 --- a/tests/test_issue1743_model_picker_race.py +++ b/tests/test_issue1743_model_picker_race.py @@ -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