Merge remote-tracking branch 'origin/master' into maint/pr-2527

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
AJV20
2026-05-24 17:48:09 -04:00
80 changed files with 7995 additions and 436 deletions
@@ -0,0 +1,132 @@
name: Native Windows startup
# Runs on PRs that touch start.ps1 (or this workflow). Validates the
# native-Windows launch script catches the bug classes the recent
# Windows-only batch caught manually (#2805 WOW64 ProgramFiles redirect,
# #2806 venv-portability claim, #2807 port-parse + finally-cleanup).
#
# Scope (per nesquena-hermes comment on #2811 — option 1, mock-only):
# hermes-agent is not published to PyPI, so we cannot pip-install it on
# the runner. Instead we stub a hermes_cli/ directory next to a sibling
# hermes-agent/ folder — just enough for start.ps1's existence guard to
# pass. The workflow then runs start.ps1 for a few seconds and asserts
# that none of start.ps1's own Write-Error guards fired. Server-boot
# regressions remain covered by the Linux jobs and docker-smoke.yml.
on:
pull_request:
paths:
- 'start.ps1'
- '.github/workflows/native-windows-startup.yml'
workflow_dispatch:
jobs:
native-windows-startup:
name: start.ps1 path discovery (mock hermes-agent)
runs-on: windows-latest
timeout-minutes: 8
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python 3.11
uses: actions/setup-python@v5
with:
python-version: '3.11'
# Create the WebUI venv. start.ps1 prefers $AgentDir\venv if it
# exists, then falls back to the python on PATH. We create a
# WebUI-local venv to mirror the README's documented native path
# and to give start.ps1 a real python.exe to invoke.
- name: Create venv (README path)
shell: pwsh
run: |
python -m venv venv
if (-not (Test-Path venv\Scripts\python.exe)) {
throw "venv\Scripts\python.exe missing after venv create"
}
# Mock-only hermes-agent provisioning. We can't pip-install
# hermes-agent (not on PyPI), so we stub the minimum that
# start.ps1's `Test-Path hermes_cli -PathType Container` guard
# needs to pass. server.py would crash on this stub at import
# time — we deliberately do NOT probe /health below.
- name: Stub hermes-agent (mock hermes_cli only)
shell: pwsh
run: |
$agentDir = Join-Path (Split-Path -Parent $PWD) 'hermes-agent'
$cliDir = Join-Path $agentDir 'hermes_cli'
New-Item -ItemType Directory -Force -Path $cliDir | Out-Null
Set-Content -Path (Join-Path $cliDir '__init__.py') -Value '# stub for CI path-discovery test only'
"HERMES_WEBUI_AGENT_DIR=$agentDir" >> $env:GITHUB_ENV
Write-Host "Stub hermes-agent provisioned at $agentDir"
# Run start.ps1 and verify it passes its own discovery guards
# without erroring out. server.py will exit non-zero on the stub
# (no real CLI code) — that's expected and not asserted against.
# We only fail if start.ps1's own Write-Error guards fire.
- name: Run start.ps1 + verify path discovery
shell: pwsh
run: |
$stdout = Join-Path $env:RUNNER_TEMP 'start-ps1.out'
$stderr = Join-Path $env:RUNNER_TEMP 'start-ps1.err'
$proc = Start-Process -FilePath 'pwsh' `
-ArgumentList '-NoLogo','-File','.\start.ps1' `
-WorkingDirectory $PWD `
-PassThru `
-RedirectStandardOutput $stdout `
-RedirectStandardError $stderr
"SERVER_PID=$($proc.Id)" >> $env:GITHUB_ENV
Write-Host "Spawned start.ps1 wrapper PID $($proc.Id)"
# Path discovery is sub-second; the 8s buffer lets the python
# launch land in the logs (and immediately exit on the stub).
Start-Sleep -Seconds 8
Write-Host "===== start.ps1 stdout ====="
$stdoutContent = if (Test-Path $stdout) { Get-Content $stdout -Raw } else { '<empty>' }
Write-Host $stdoutContent
Write-Host "===== start.ps1 stderr ====="
$stderrContent = if (Test-Path $stderr) { Get-Content $stderr -Raw } else { '<empty>' }
Write-Host $stderrContent
# Pattern set: every Write-Error message start.ps1 can emit on
# its own discovery path. If any of these appear in stderr,
# path discovery regressed and the job must fail.
$guardErrors = @(
'Python 3 is required',
'hermes-agent not found',
'HERMES_WEBUI_AGENT_DIR is set to',
'is not a valid integer port',
'is out of TCP-port range',
'server.py not found'
)
foreach ($msg in $guardErrors) {
if ($stderrContent -and $stderrContent -match [regex]::Escape($msg)) {
throw "REGRESSION: start.ps1 errored on guard '$msg' - path discovery failed."
}
}
Write-Host "OK: start.ps1 path discovery - all guards passed."
# taskkill /T walks the process tree, /F forces. taskkill returns
# 128 ("process not found") if the PID is already gone — that's
# the expected steady state for this mock-only workflow because
# server.py exits immediately on the stub hermes_cli. Reset
# $LASTEXITCODE so the step never fails on the cleanup itself.
- name: Stop background server (tree-kill)
if: always()
shell: pwsh
run: |
if ($env:SERVER_PID) {
& taskkill /PID $env:SERVER_PID /T /F 2>&1 | Out-Host
$global:LASTEXITCODE = 0
}
# Belt-and-suspenders: kill anything still bound to 8787.
$hanging = Get-NetTCPConnection -LocalPort 8787 -State Listen -ErrorAction SilentlyContinue
if ($hanging) {
foreach ($c in $hanging) {
try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop } catch {}
}
}
exit 0
+223 -2
View File
@@ -7,6 +7,224 @@
- Add a default-off, read-only Third-party notes drawer to the Memory panel that lists configured note/knowledge MCP sources such as Joplin, Obsidian, Notion, and llm-wiki when explicitly enabled with `webui_external_notes_sources` or `HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=1`, while leaving automatic session recall unchanged.
## [v0.51.130] — 2026-05-24 — Release DB (stage-batch12 — 3-PR profile-isolation + boot-precedence + workspace Artifacts tab)
### Fixed
- **PR #2827** by @Koraji95-coder — Profile state-sync TLS-vs-thread fix (closes #2762). When switching profiles via the WebUI cookie selector, session token-usage and title were being written to the *previously-active* profile's `state.db` instead of the cookie-switched one (sidecar messages + workspace files were already routed correctly; only the `state.db` sidecar sync leaked). Root cause: the cookie middleware sets `_tls.profile = '<cookie>'` on the HTTP request thread, but the daemon thread spawned in `_run_agent_streaming` doesn't inherit that TLS. When the streaming worker calls `sync_session_usage`, `_get_state_db → get_active_hermes_home → get_active_profile_name` finds no TLS profile, falls through to `_active_profile` (the process default), and opens the wrong DB. Fix plumbs the session's own `profile` field through `sync_session_usage(..., profile=...)` and `_get_state_db(profile=...)` rather than leaning on TLS that doesn't exist on the worker thread. Keeps the existing TLS path for callers that don't pass `profile=` explicitly, so external integrations don't regress. Also adds defensive `_validate_profile_name` rejecting `../etc`, leading-dash, whitespace, and over-long names (prevents path traversal via cookie tampering). Adds 11 regression tests covering explicit-profile honors, multi-thread profile preservation, unknown-profile-name fallback path, invalid-name refusal, and legacy-call-shape compatibility.
- **PR #2726** by @starship-s — Boot model-default precedence follow-up (refines #2709). The original v0.51.105 fix correctly preferred the profile/server default on fresh boot, but the implementation had two over-broad side effects flagged in post-merge review: (1) boot unconditionally cleared the persisted browser model state, even on restored sessions where that state should remain authoritative, and (2) `populateModelDropdown()` reapplied the default on every repopulate when no session model was present, which clobbered the in-page selection during ordinary dropdown refreshes. Fix is to gate the default-reapply behind an opt-in `{preferProfileDefaultOnFreshBoot: true}` parameter so boot keeps profile-default precedence, restored sessions keep their session model, and non-boot dropdown refreshes preserve the loaded session's model or the current in-page selection. Browser model state is no longer deleted just because the profile default wins this boot. Expanded the regression test coverage with a Node `select` / DOM shim that exercises the real `populateModelDropdown()` path for boot-default, restored-session, current-selection, and removed-model scenarios (+306 LOC tests).
### Added
- **PR #2673** by @AJV20 — Workspace Artifacts tab (closes #2655). New tab in the workspace panel that lists likely files mentioned, edited, or created during the active session. Prioritizes structured tool-call paths (file_write, edit, patch, etc.), filters dependency/build noise (node_modules, `__pycache__`, `.git`, lock files), and refreshes while live tool calls arrive. Artifact entries open through the existing workspace file preview flow. The MVP is frontend-scoped — backend ingestion uses the existing tool-call event stream rather than a new persistence path — so the maintainer can evaluate the UX before deciding whether artifact tracking should grow into a backend-backed feature. Refreshes alongside the file tree in `loadDir()` via a `typeof renderSessionArtifacts==='function'` guard so it composes cleanly with #2716's session stale-guard pattern. Adds `tests/test_issue2655_frontend.py`.
### Notes
- **In-stage cherry-pick mechanics**: All 3 PRs were on stale-base merge-bases (master had advanced through 3 releases). Used `git apply --3way` of each PR's net delta vs its merge-base onto current stage HEAD, then resolved 2 small JS conflicts manually:
- `static/boot.js` (#2726 vs post-#2716 master): kept PR's parameterized `populateModelDropdown({preferProfileDefaultOnFreshBoot:true})` call (the whole point of #2726) on top of master's #2716 hydration flow.
- `static/workspace.js` (#2673 vs post-#2716 master): kept master's `sessionId`-capture stale-session guard (closure-scoped sessionId check after `await`) AND added PR's `renderSessionArtifacts()` call to refresh the new Artifacts tab when the file tree updates. Wrapped in `typeof === 'function'` guard for defense-in-depth.
- **In-stage test fixes**: Patched 3 brittle source-string assertions to accept both pre-#2716 and post-#2716 JS shapes (variable names changed during the cherry-pick, semantics preserved). Patched 1 schema mismatch in `tests/test_issue2762_state_sync_profile_kwarg.py::_read_session` helper — it queried `sessions.session_id` but the real `state.db` schema has `sessions.id` as primary key. Fix is mechanical: `SELECT id AS session_id` + `WHERE id = ?` so the helper queries the actual schema.
- Full pytest: pending re-run on this finalized stage. Touched-tests gate: 41 passed (covering #2827 + #2726 + #2673 surface areas).
- Agent self-verified: profile= kwarg threading on `_get_state_db` + `sync_session_usage`, production call site in `api/streaming.py:5078` passes `profile=getattr(s, 'profile', None)`, `populateModelDropdown` opt-in parameterization present, boot.js calls with `preferProfileDefaultOnFreshBoot:true`, workspace `renderSessionArtifacts()` defined + called.
## [v0.51.129] — 2026-05-24 — Release DA (stage-batch11 — 4-PR feature + perf batch)
### Performance
- **PR #2836** by @v2psv — HTTP/1.1 keep-alive for WebUI responses. Bumps `Handler.protocol_version` from the HTTP/1.0 default to `HTTP/1.1` so browsers can reuse TCP connections across normal API and static-file requests. Adds explicit `Content-Length` headers to hand-written responses that weren't already using shared `j()` / `t()` helpers. Adds `Content-Length: 0` to empty redirect / range-error responses. Switches SSE-style streaming endpoints from `Connection: keep-alive` to `Connection: close` (keep-alive is only safe when the response body is framed; SSE bodies have no fixed length). Significant first-paint / session-open improvements on high-RTT / VPN / proxied paths — author reports ~47% faster first paint and ~30-40% improvements on panel-load flows on a typical remote-host setup.
**Opus pre-release advisor caught one missing framing site** in the on-the-fly folder ZIP download path (`/api/folder/download`): the body has no known length, doesn't use chunked encoding, and was relying on HTTP/1.0 connection-close-equals-EOF. Under HTTP/1.1 this would have left clients hanging waiting for the next response after the central-directory bytes finished. Patched inline before tag: add `Connection: close` header to mirror the SSE-endpoint pattern. Opus verified this was the ONLY remaining streaming response in the codebase that needed the header — all 12 hand-written response paths + 8 SSE streams + j()/t() helpers + auth flow were already correctly framed by the PR.
### Added
- **PR #2680** by @mccxj — Auxiliary Models settings card in Settings → Preferences. Lets users configure per-task model routing for 9 canonical side-task slots: vision, web extract, compression, session search, approval, MCP tool reasoning, title generation, skills hub, curator. Each slot exposes a provider dropdown + model dropdown plus an "auto (use main model)" / "auto (use provider default)" pair so users can keep aux routing implicit when they don't care. New endpoints: `GET /api/model/auxiliary` returns current assignments; `POST /api/model/set` writes assignments (`scope=auxiliary` for aux slots, `scope=main` for the default chat model) and supports `task="__reset__"` to reset all slots back to auto. 16 new i18n keys added across all 12 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr — Turkish translations added in-stage to cover the sibling-PR collision with v0.51.127's Turkish locale baseline). 24 source-level test assertions covering HTML structure, JS logic, i18n parity, and route registration.
- **PR #2842** by @AJV20 — PWA polish for installed launches. New `static/pwa-startup.js` is loaded synchronously in `<head>` before the main UI bundle, so the page knows whether it's running standalone / in-browser / on iOS / offline before first paint. Marks `pwa-standalone`, `pwa-browser`, `pwa-ios`, `pwa-offline`, and short-lived `pwa-resumed` classes on `<html>`. Exposes `window.HermesPWA.{isStandalone, syncMode, launchAction, promptInstall}` helpers and captures `beforeinstallprompt` / `appinstalled` early enough that any future install-prompt UI can chain off them. Manifest gains app identity / scope / `display_override` (`window-controls-overlay``standalone``minimal-ui`) and a "New conversation" PWA App Shortcut. Service worker pre-caches the startup helper, switches navigation and shell-asset fetches to `cache: 'no-store'` before falling back to CacheStorage. Boot path wires `?source=pwa&action=new-chat` to start a fresh chat instead of reopening the last saved session. The viewport meta now sets `maximum-scale=1, user-scalable=no` for native-feel — acknowledged trade-off against WCAG 2.1 1.4.4 (Resize text), intentionally kept for the PWA-installed feel of this user base.
- **PR #2794** by @Michaelyklam — Runtime adapter route selection harness. Routes explicit adapter-mode chat starts through `build_runtime_adapter(...)` and keeps `legacy-direct` as the default `/api/chat/start` path. Continues the #1925 RFC slice progression: this is slice 4e, the default-off chat-start route-selection seam. Returns a bounded `501 Not Configured` response when `runner-local` is explicitly selected before a supervised runner client exists, instead of silently starting a legacy WebUI-owned run. New `_chat_start_response_from_run_start(...)` helper whitelists legacy-compatible chat-start response fields and keeps adapter-internal `run_id`, `status`, and `active_controls` out of public responses. Updates `docs/rfcs/hermes-run-adapter-contract.md` to mark #2744 shipped and define slice 4e.
### Notes
- Full pytest: **6,467 passed / 6 skipped / 3 xpassed / 8 subtests passed**.
- Opus pre-release advisor reviewed all 7 risk areas (HTTP framing surface completeness, PWA startup ordering, sibling-PR `api/routes.py` interaction, service worker cache invalidation, viewport-meta trade-off, runtime adapter response shape, locale-counter brittleness). Verdict: **1 MUST-FIX patched inline** (folder ZIP `Connection: close` header), **0 inline SHOULD-FIX**, 1 follow-up suggested (`set_auxiliary_model` could validate `task` against `AUX_TASK_SLOTS` whitelist — auth-gated, low severity, filing as follow-up).
- Agent self-verified: protocol_version bumped, SSE Connection-close + Content-Length plumbing, Auxiliary Models API surface (config + endpoints + frontend), PWA helpers + manifest shortcuts + display_override, Runtime adapter wiring + whitelisting, i18n parity for all 12 locales on the 16 new aux keys.
- Browser-verified at 1920×1080: Auxiliary Models card renders correctly under Settings → Preferences, 9 task slots with provider/model dropdowns, "Reset all to auto" button, layout consistent with surrounding Settings cards, no clutter or clipping. PWA classes populate on `<html>` and HermesPWA namespace populates with 4 helpers as expected.
- In-stage commits added Turkish translations for #2680's 16 `settings_aux_*` / `settings_label_auxiliary_models` / `settings_desc_auxiliary_models` keys to close the sibling-collision gap with v0.51.127's Turkish locale (#2772). Bumped `test_auxiliary_models_settings.py::test_all_locales_have_auxiliary_keys` from `count == 11` to `count == 12` (the locale set grew when Turkish landed).
## [v0.51.128] — 2026-05-24 — Release CZ (stage-batch10 — 2-PR perf + correctness batch)
### Fixed
- **PR #2830** by @franksong2702 — Pin state synchronization between persisted index and in-memory sessions (closes #2821). Three coupled bugs:
- **Bug A (load-bearing):** `/api/session/pin` pre-snapshot used `getattr(session, "pinned", False)` which always returned `False` for dict-backed index rows from `all_sessions()`. With ~55-session profiles and LRU eviction churn, pinned counts routinely under-counted because the persisted snapshot was effectively empty. New `_session_field(session, field, default)` helper resolves both dict-backed and Session-object snapshots correctly.
- **Bug B:** Removed stale client-side `pinLimitReached` short-circuit in the sidebar action menu that could block pin clicks before the server saw them, based on `_allSessions` data that was stale mid-render. Server now enforces the cap; the toast surfaces the 400 response.
- **Bug C recovery:** Pin/unpin failure path (4xx response from `/api/session/pin`) now triggers `renderSessionList()` to refresh `_allSessions` from the server, so the sidebar never gets stuck on stale optimistic state.
Adds `tests/test_issue2821_session_pin_state_sync.py` (70 LOC) covering the `_session_field` helper, the persisted-pinned snapshot, the removed `pinLimitReached` reference, and the failure-catch refresh path. Companion fix to #2782 (server-side 404→200 transition for missing CLI-synced sessions) which remains out of scope.
### Performance
- **PR #2716** by @dobby-d-elf — Six independent perf nudges plus one correctness fix. nesquena-APPROVED on 2026-05-22 after a deep-review iteration; cherry-picked onto post-v0.51.127 master via 3-way apply with sibling-PR composition resolution.
- **Metadata-only `/api/session` correctness fix.** Refactors the prior inline reconciliation into `_metadata_only_message_summary(sid, profile=None)` helper that runs the full `merge_session_messages_append_only()` path. Pre-fix shortcut could over-count stale state.db replay rows that the merge intentionally filters out, producing false "transcript newer than loaded conversation" signals (same bug class as #2705 / #2686). The new helper threads `profile=` through to `get_state_db_session_messages` to preserve #2827's TLS-vs-thread profile fix on background-thread reads.
- **Batched persisted-session checks in sidebar indexing.** One `SESSION_DIR.glob('*.json')` snapshot per call replaces per-row `_index_entry_exists()` filesystem lookups during `all_sessions()` pruning. Fallback to the per-row helper preserved when the glob raises.
- **Deferred render-cache signature.** `cachedRenderSignature` closes over the lookup-time signature so the cache STORE path reuses it without recomputing. `_messageRenderCacheSignature()` continues to include the content hash per #2692, preserving the cache-invalidation invariant.
- **Hoisted assistant tool-activity index.** Footer-rendering loop now uses an `O(1)` Set lookup instead of `S.toolCalls.some(...)` per message — ~30× fewer comparisons for a 100-message conversation with 30 tool calls.
- **Workspace stale-session guards.** `loadDir` and `_refreshGitBadge` in `static/workspace.js` capture `sessionId` at call time and check it after each `await` (including the catch path of `_refreshGitBadge` — without it, a late 404 from the previous session would hide the git badge on the current session).
- **Background model-catalog prime.** `_startBootModelDropdown` fires fire-and-forget on boot via `setTimeout(0)` so the live catalog hydrates without blocking. The existing `await` on the saved-session restore path is preserved (re-applies the saved session's model after hydration so the chip never shows the stale static default).
- **Failed hydration retryable.** `window._modelDropdownReady = null; throw e;` lets the next caller refetch instead of being stuck on a permanent failure.
Adds 76 LOC of new tests across `test_session_metadata_fast_path.py`, `test_webui_state_db_reconciliation.py`, `test_session_index.py`, `test_issue1539_provider_removal_dropdown_invalidation.py`, `test_issue1785_workspace_preview_breadcrumb.py`, `test_parallel_session_switch.py`.
### Notes
- PR #2716 had been pending merge since 2026-05-22 due to a rebase blocker against the rapidly-advancing master (10+ intervening releases). Cherry-picked via `git apply --3way` of the PR's net delta vs its original merge-base (`f9302601`); 12 of 14 files applied cleanly. Two files had genuine conflicts requiring resolution: `api/routes.py` (took the PR's helper extraction AND added `profile=` threading to preserve #2827's fix), and `tests/test_webui_state_db_reconciliation.py` (kept BOTH master's pre-existing `test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant` AND the PR's new `test_metadata_fast_path_matches_reconciliation_for_restamped_replays` — they pin different invariants).
- Opus pre-release advisor reviewed all 6 risk areas (helper extraction correctness, sibling-PR composition, `Session.load` profile-safety, test coverage, deferred Bug D, stale-line-number cleanup nit). Verdict: **SHIP AS-IS** — no MUST-FIX, no inline SHOULD-FIX. Two follow-up issues to file post-tag (Bug D startup index rebuild perf; multi-profile state.db test for the `profile=` threading invariant).
- Full pytest: **6,434 passed / 6 skipped / 3 xpassed / 8 subtests passed** in 2m43s.
- Agent self-verified the producer→consumer channel for `_metadata_only_message_summary` with unmocked invocation against a real session-load path (per skill rule Trigger A + E for mocked-consumer test patterns).
- Closes: #2821 (pin state sync), and `get_state_db_session_summary` dead-code removed (#2716).
## [v0.51.127] — 2026-05-24 — Release CY (stage-batch9 — 7-PR low-risk batch — brick-class Linux + brick-class update apply + composer wide-screen + Turkish locale + MCP toggle + SSE settlement + Windows CI)
### Fixed
- **PR #2854** by @nesquena-hermes — Embedded terminal opens then immediately closes with `[terminal closed]` on every Linux install past `71d8a8fb`. Root cause: `_terminal_shell_preexec_fn` set `PR_SET_PDEATHSIG=SIGTERM` on the PTY shell so orphans would die when WebUI crashed, but `PR_SET_PDEATHSIG` is **per-thread**, not per-process. WebUI uses `ThreadingHTTPServer`, so each HTTP request runs in its own short-lived worker thread; when the request handler returns and the worker thread exits, the kernel sees the pdeathsig-parent thread has died and SIGTERMs the PTY shell within ~10ms. macOS users were unaffected because `libc.prctl` doesn't exist there. Fix: drop the `preexec_fn` entirely; rely on `atexit.register(close_all_terminals)` for graceful shutdown and explicit `close_terminal` for user-driven close. Adds `tests/test_terminal_process_cleanup.py::test_pty_shell_survives_when_spawning_thread_exits` (real PTY shell spawned via worker thread, asserts shell alive after 500ms grace) plus static-check that `preexec_fn` cannot be re-introduced. Closes #2853.
- **PR #2855** by @nesquena-hermes — "Update Now" loops for every user past the latest tag (#2846). After #2758 the update check correctly fell through to branch comparison when `HEAD` had moved past the latest `v*` tag, but `_select_apply_compare_ref` still returned `tags[0]` — so `git pull --ff-only v2026.5.16` no-op'd, the server bounced, and the banner reappeared unchanged. `apply_force_update` had the same bug except worse (would `git reset --hard v2026.5.16` and rewind the checkout 254 commits). Fix: extract `_head_is_past_latest_tag(path, current_tag)` and have both check and apply paths consult it. Opus pre-release review caught a "case D" parameter-asymmetry drift (HEAD on older tag + commits + newer tag exists → predicate flipped between the two callsites) and patched the apply-side predicate to use `current_tag` + a `behind == 0` gate, exactly mirroring the check-side rule. Adds `test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists`. Closes #2846.
- **PR #2852** by @ai-ag2026 — Chat `stream_end` handler now settles from the persisted session when `done` was not received or replayed, instead of leaving the active pane with live `Thinking` / assistant DOM and inflight state projected indefinitely. Reconnect / journal / replay paths can deliver `stream_end` without preceding `done`; the prior code treated `stream_end` as transport-only close. Duplicate / replayed `done` events are also made idempotent before completion sound / final render side effects. Opus pre-release review added a post-await race guard inside `_restoreSettledSession` to catch the case where a late `done` event runs the finalize path while the settlement is awaiting the `/api/session` roundtrip. Adds 4 new regression tests across `tests/test_1694_terminal_cleanup_ownership.py` covering both `stream_end`-without-`done` and duplicate-`done` paths.
- **PR #2811** by @Koraji95-coder — Native-Windows startup E2E workflow now self-tests on PR push (closes the post-#2783 gap where Windows-only regressions like the WOW64 ProgramFiles redirect could only be caught after release). Reworked per maintainer feedback to use a stub `hermes_cli/__init__.py` next to a sibling `hermes-agent/` folder rather than `pip install hermes-agent` (which is not on PyPI). Workflow runs `start.ps1` for 8s and asserts none of its `Write-Error` guards fired (no Python, no agent dir, bad port, missing `hermes_cli`, missing `server.py`). PowerShell syntax + path discovery is the testable surface; the server can't actually boot on a stub. `taskkill` exit-128 swallowed when the stub process is already gone.
### Changed
- **PR #2812** by @Koraji95-coder — Composer max-width is now responsive on wide displays. Pre-change `.composer-box` had a fixed `max-width: 780px` that pinched footer chips (workspace name, model picker, reasoning chip, context ring) against each other on 1440p+ monitors. Switched to `max-width: clamp(780px, 60vw, 1100px)` — the 780px floor preserves byte-identical layout at 1280px (Aron's laptop reference width); 1440px viewports gain ~84px (864px composer); 1920px viewports gain ~320px (1100px composer cap). Mobile responsive logic untouched. Single-line CSS change in `static/style.css`.
### Added
- **PR #2772** by @vaur94 — Complete Turkish (`tr`) locale across `static/i18n.js` (~1,182 keys matching existing locale coverage). Adds Turkish login page strings in `api/routes.py` `_LOGIN_LOCALE`. Settings → Language now offers **Türkçe**; speech recognition uses `tr-TR`. Stage build absorbed a sibling-PR i18n collision with #2776 below (9 missing keys: `mcp_enable_server`, `mcp_disable_server`, `mcp_enabled_toast`, `mcp_disabled_toast`, `mcp_toggle_failed`, `open_in_vscode`, `open_in_vscode_failed`, `settings_label_ignore_agent_updates`, `settings_desc_ignore_agent_updates`) — Turkish translations added in-stage so locale-parity test passes. Closes #2537 as superseded (byzuzayli's earlier Turkish PR with narrower scope).
- **PR #2776** by @roryford — New `PATCH /api/mcp/servers/{name}` endpoint accepts `{"enabled": bool}`, writes `mcp_servers.<name>.enabled` to `config.yaml`, calls `reload_config()`, returns `{"ok": true, "name": "<name>", "enabled": <bool>}`. Each MCP server row in the panel now shows a clickable Enabled/Disabled toggle. Also fixes a pre-existing bug: `_handle_mcp_server_delete` and `_handle_mcp_server_update` were defined at line ~11656 but never wired into the HTTP router — DELETE wired into `handle_delete`, PUT wired via new `handle_put` / `do_PUT` in `server.py`. CORS preflight `Access-Control-Allow-Methods` updated to include `PUT` (Opus pre-release review nit). Adds 5 i18n keys to all 11 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr via in-stage parity fix). 7 new tests covering enable, disable, 404, empty-name, missing-field, response payload, URL-decoded names.
### Notes
- Two PRs (#2854, #2855) are brick-class fixes — every Linux install was unable to use the embedded terminal, and every install past the latest agent tag was stuck in an Update Now loop. They land in the same low-risk batch as cosmetic / locale / CI changes because both fixes are mechanical, well-tested, and the brick-class severity made deferring impossible.
- Opus pre-release advisor reviewed all 5 risk areas (PR_SET_PDEATHSIG removal, update apply path symmetry, MCP toggle wiring, composer clamp, stream_end settlement). 1 MUST-FIX + 3 SHOULD-FIX all addressed inline before tag. Net: +69/-9 across 5 files for the Opus fixes.
- Full pytest: 6,424 passed / 6 skipped / 3 xpassed / 8 subtests passed.
- UX evidence for #2812 captured at 1280/1440/1920/mobile (iPhone 14 emulation); Telegram-approved.
- File a follow-up issue for pdeathsig-on-supervisor-thread hardening (#2854 deferred Option B) and French-locale `open_in_vscode` parity gap (predates this batch, Opus advisor flagged).
## [v0.51.126] — 2026-05-24 — Release CX (stage-batch8 — 2-PR low-risk batch — kanban markdown + live activity timeline)
### Added
- **PR #2819** by @humayunak — Kanban task descriptions and comments now render as full GFM Markdown instead of plain-text. `_kanbanRenderMarkdown()` in `static/panels.js` rewrote the line-per-`<p>` wrapper as a block-parsing pipeline supporting headings, code blocks (fenced + indented), ordered/unordered lists, task lists with checkboxes, tables, blockquotes, horizontal rules, and strikethrough. `_kanbanRenderMarkdownInline()` gains `~~strikethrough~~` and tightens the italic regex to avoid mid-identifier `*` matches. CSS adds table borders, code-block background, checkbox styling, blockquote accent, and heading sizing scoped to `.hermes-kanban-md`. Frontend-only, scoped to the kanban panel. 95 existing kanban tests pass.
### Changed
- **PR #2847** by @AJV20 — Live chat Activity disclosure now shows observable run telemetry instead of an empty `Thinking…` placeholder when no reasoning text is available (squashed from 2 author commits). New baseline rows surface run-start metadata (model, profile), `Waiting on model` / `Waiting on tool result` / `Working for …` status, tool start/finish in the timeline alongside the existing compact tool cards, and a `No recent activity for …` state after quiet periods. Frontend-only telemetry derived from existing stream events — no new backend event types. Adds `tests/test_live_activity_timeline.py` (4 tests). The compact/calm default Activity disclosure is preserved; it only becomes informative when expanded.
## [v0.51.125] — 2026-05-24 — Release CW (stage-batch7 — 10-PR low-risk batch — UI/UX polish + bug fixes + diagnostics)
### Fixed
- **PR #2839** by @tn801534 — Kanban worker log endpoint constructed URLs with a double query string (`?board=<slug>?tail=65536`) when a non-default board was active. The frontend was appending `?tail=65536` directly to a URL that already had `?board=...` from `_kanbanBoardQuery()`. Fix: pass `{tail: 65536}` as the `extra` argument to `_kanbanBoardQuery()` so it composes both params into a single valid query string. One-line, narrow scope.
- **PR #2832** by @franksong2702 — Malformed HTTP request logging in `server.py` falls back to `"-"` for missing `command` or `path` instead of raising `AttributeError`. Defensive `getattr(self, 'command', None) or '-'` matches the pattern already used for `_req_t0` elsewhere in the handler. Adds `tests/test_issue2775_log_request.py` covering the malformed-request-before-path-assigned case.
- **PR #2818** by @humayunak — Approval and clarify cards no longer steal focus from the composer textarea (`#msg`) when the user is mid-type. `showApprovalCard()` and `showClarifyCard()` now guard the `focus()` call on `document.activeElement !== $('msg')`, matching the pattern already used elsewhere for focus-sensitive paths. The clarify card also moves the focus call out of `setTimeout` for snappier UX. Silently dropped keystrokes during streaming are eliminated.
- **PR #2826** by @Koraji95-coder — Composer footer chip wraps no longer overlap at narrow widths (closes #2740). The five chip wraps (`.composer-profile-wrap`, `.composer-ws-wrap`, `.composer-model-wrap`, `.composer-reasoning-wrap`, `.composer-toolsets-wrap`) had `flex: 0 1 auto` + `min-width: 0` so they would compress past their content's natural width when the composer narrowed, causing visual overlap of the profile / workspace / model / reasoning chips. Switched to `flex: 0 0 auto` via a single grouped selector. Each chip now keeps its natural width and the existing `overflow-x: auto` on `.composer-left` handles overflow via horizontal scroll. Default-width layout unchanged; only affects the overflow regime. Mobile-specific rules (already `flex: 0 0 auto`) untouched.
- **PR #2829** by @franksong2702 — Workspace Markdown previews fall back to plain text for very large files (>64 KB or >1500 lines) instead of synchronously running the full rich Markdown renderer on the browser main thread, which could lock up the tab for several seconds on multi-megabyte `.md` files. Plain-text preview shows file size + line count in the status line so users know why rich rendering was bypassed; Edit mode still shows raw content as before. Closes #2823. Supersedes #2828 (same scope, less polished).
- **PR #2837** by @franksong2702 — CSRF rejections now distinguish origin/proxy mismatches from expired session tokens, so provider-key removal and other protected requests show actionable diagnostics instead of the generic "Cross-origin request rejected" error. Adds `tests/test_issue2572_csrf_diagnostics.py` covering both failure modes.
- **PR #2834** by @franksong2702 — Workspace Markdown `mailto:` and `tel:` links now render as clickable links, and sandboxed HTML preview links open outside the iframe (via injected `<base target="_blank">`) instead of navigating the preview into a browser-blocked page. Adds `tests/test_issue2768_workspace_links.py`.
- **PR #2838** by @franksong2702 — Tasks panel surfaces a warning when the Hermes gateway is not configured or not running, so Docker users know scheduled jobs need the gateway daemon to tick while away. The single-container Docker boundary is also clarified in `docs/docker.md`. Adds `tests/test_issue2785_gateway_cron_guidance.py`.
### Added
- **PR #2820** by @tangerine-fan — Clarify user choice is now echoed as a visible message in the conversation transcript. After the user responds to a clarify prompt, a synthetic user message with the chosen value is inserted into `S.messages` (marked `_clarify_response: true` so downstream consumers can filter if needed). Previously the choice was only visible in the transient clarify card; now the chat history preserves the decision.
- **PR #2843** by @AJV20 — New Settings preference "Ignore Agent updates" keeps WebUI update notices, banners, and update actions enabled while suppressing Hermes Agent update checks. Default `False` (current behavior). Useful when running an unreleased agent build or pinning to a specific agent commit.
## [v0.51.124] — 2026-05-24 — Release CV (stage-batch6 — 3-PR Windows-only stack — agent paths / docs / port hardening)
### Added
- **PR #2805** by @Koraji95-coder — `start.ps1`: expand hermes-agent candidate paths for Windows installers. The launcher now searches `$env:USERPROFILE\.hermes\hermes-agent`, the dev-checkout sibling, and the Windows installer roots (`$env:LOCALAPPDATA\hermes\hermes-agent`, `${env:ProgramW6432}\hermes\hermes-agent`, `${env:ProgramFiles}\hermes\hermes-agent`, `${env:ProgramFiles(x86)}\hermes\hermes-agent`) with `Select-Object -Unique` to collapse WOW64 ProgramFiles redirection collisions on 32-bit PowerShell processes. Adds `-PathType Container` to the `HERMES_WEBUI_AGENT_DIR` guard so a file named `hermes_cli` doesn't false-positive. Null-guards `${env:ProgramFiles(x86)}` for constrained environments where it's missing. Zero impact on Linux/macOS — file is `start.ps1`, never loaded by `start.sh` or `bootstrap.py`.
### Documentation
- **PR #2806** by @Koraji95-coder — Native Windows venv path corrected in `start.ps1` doc-comment and `README.md`. The previous text suggested "run bootstrap.py inside WSL2 once to create the venv, then this script can use that venv" — but a WSL2-created venv is `venv/bin/python` (ELF) and cannot be invoked by native Windows Python. The corrected guidance is to create a Windows venv natively (`python -m venv venv` from PowerShell), then `start.ps1` auto-discovers `venv\Scripts\python.exe`. WSL2 remains useful as a parallel install for the full `bootstrap.py` + Linux runtime path.
### Hardened
- **PR #2807** by @Koraji95-coder — `start.ps1`: `HERMES_WEBUI_PORT` env-var parsing uses `[int]::TryParse` + range guard (1-65535) instead of a bare `[int]` cast that threw `InvalidCastException` with no context on typos or accidental shell expansion. Server-process exit code is captured into `$script:serverExitCode` and emitted via `exit` AFTER the `try/finally` cleanup, so `Pop-Location` always runs (avoids leaving the caller stuck at `$RepoRoot` in interactive or dot-sourced sessions). Also drops a non-functional `@args` splat that PowerShell doesn't populate under `[CmdletBinding()]` — the launcher's existing use case is env-var-driven, no pass-through args needed.
## [v0.51.123] — 2026-05-24 — Release CU (stage-batch5 — 2-PR low-risk batch — gzip+ETag static caching / Open in VS Code)
### Performance
- **PR #2779** by @v2psv — Static asset serving negotiates gzip, emits ETags, and uses `immutable` cache headers for fingerprinted URLs. `_serve_static()` in `api/routes.py` previously sent every `/static/*` response with `Cache-Control: no-store` and no `Content-Encoding`, so a page reload over a slow link re-downloaded the full ~2.4 MB JS+CSS shell on every visit. The fix layers three changes inside the same function: (1) gzip the body when the client opts in via `Accept-Encoding`, gated to compressible MIME types and files >1 KB; (2) emit a weak ETag derived from `(size, mtime_ns)` and short-circuit conditional GETs to `304 Not Modified`; (3) send `Cache-Control: public, max-age=31536000, immutable` when the URL carries a non-empty `?v=…` fingerprint (the `__WEBUI_VERSION__` token already substituted by the index template and referenced from `static/sw.js`'s `SHELL_ASSETS`), falling back to `public, max-age=300` otherwise. Raw bytes, compressed bytes, and ETags are cached in-process keyed by `(size, mtime_ns)` so a redeploy is picked up without a restart, while missing/random paths never enter the cache and image/font types skip gzip to avoid wasted CPU on already-compressed payloads. Measured against an asyncio TCP proxy that injects RTT + bandwidth caps for representative VPN scenarios: cold loads improve 2.7-3.1× (e.g. 80 ms RTT / 10 Mbps WireGuard goes from 4.0 s to 1.3 s), warm reloads improve 3.3-4.0× via 304 responses, and bytes-on-the-wire drop 74% on cold loads. Loopback (already fast) still benefits 2.4×. Scope is strictly `/static/*`: `/api/*`, `/stream`, `/`, `/index.html`, `/session/*`, and login/auth routes are served by independent handlers and continue to send `no-store` exactly as before — no change to CSRF, session payloads, SSE buffering, or login flows. 11 regression tests pin gzip negotiation, ETag/304 round-trip including `Vary: Accept-Encoding`, fingerprint-driven cache policy including empty `?v=`, image/tiny-file skip rules, redeploy invalidation, and the existing path-traversal sandbox.
### Added
- **PR #2787** by @munim — "Open in VS Code" action in workspace file browser (resolves #2735). Right-clicking any file, folder, or the workspace root now shows an **Open in VS Code** menu item alongside the existing Reveal in File Manager action. The action calls a new `POST /api/file/open-vscode` endpoint which resolves the workspace-relative path via the existing `safe_resolve` traversal guard, then launches VS Code via `subprocess.Popen` (fire-and-forget, consistent with `_handle_file_reveal`). The endpoint resolves the executable via `shutil.which()` first, then falls back to a hardcoded list of common install locations (macOS: `/usr/local/bin/code` and the app-bundle CLI; Linux: `/usr/bin/code`, `/snap/bin/code`; Windows: `%LOCALAPPDATA%\Programs\Microsoft VS Code\bin\code.cmd` and the `%PROGRAMFILES%` variants) so the action works even when the server process inherits a minimal PATH. Configurable via a new optional `vscode` block in `config.yaml`: `command` overrides the default `code` executable; `host_path_prefix` + `container_path_prefix` enable Docker/container host-path translation. If the command cannot be found anywhere, a descriptive error is returned instead of a bare OS error. i18n keys `open_in_vscode` and `open_in_vscode_failed` added with full translations in all 10 locales. 26 new tests in `tests/test_2735_open_in_vscode.py` pin source wiring, command-resolution logic, i18n completeness, translated strings, and live endpoint error paths.
## [v0.51.122] — 2026-05-24 — Release CT (stage-batch4 — 4-PR low-risk batch — stale cache tail / inflight UI / segment flush / reasoning accumulator)
### Fixed
- **PR #2802** by @ai-ag2026 — Drop stale inactive cached user tails when `/api/session` reloads a conversation whose saved sidecar already ends on an assistant answer. Supersedes #2733 (held due to async-compression interaction): the new guard adds a `len(cached_messages) <= len(disk_messages)` filter so it never fires when the cache has genuine new concurrent edits beyond the disk state — only when the cache has an unsaved user row past the saved assistant tail. Adds `api/models._inactive_cache_tail_needs_disk_check()` + `_cache_has_stale_unsaved_user_tail()` helpers and 5 new tests in `tests/test_webui_state_db_reconciliation.py`. Previously-held test `test_session_compress_async_reports_stale_session_guard` now passes (verified). Closes umbrella #2361 partially.
- **PR #2796** by @ai-ag2026 — Clear stale inflight UI state before starting a new send so blocked composer busy-state from failed/incomplete prior turns doesn't divert new turns into the invisible queue. Five-commit squashed fix: (1) drop stale optimistic sidebar rows once canonical session data arrives, (2) clear stale busy state before send via `_clearStaleBusyStateBeforeSend()`, (3) preserve server idle rows over stale optimistic local rows, (4) let `/api/chat/start` survive non-fatal pre-start UI errors via `_runOptionalPreStartUiStep()`, (5) keep those warnings console-only instead of throwing. Adds `_shouldKeepLocalOnlyOptimisticSessionRow()` in `static/sessions.js` and 8 new tests in `tests/test_inflight_send_start_race.py`. Closes #2795. Authorship preserved via `--author`.
- **PR #2777** by @b3nw — Flush pending render before segment reset at tool/interim_assistant boundaries so live tokens that arrived in the 66ms rAF throttle window don't get lost from the DOM when `_resetAssistantSegment()` clears `assistantBody`. New `_flushPendingSegmentRender()` helper writes via `smd`, `renderMd`, or `esc` fallback (same paths as `_doRender`) only when `_renderPending` is true. Completed transcripts were never affected — `renderMessages` rebuilds from the full `assistantText` accumulator on `done`. Adds `tests/test_issue2713_streaming_segment_flush.py`. Closes #2713.
- **PR #2778** by @b3nw — Reset reasoning accumulator per turn and prefer `reasoning_content` over `reasoning` on read. Two related bugs: (1) `reasoningText` was initialized once when the SSE stream opened and never reset between turns, so the `done` event would assign the union of every turn's reasoning to the last assistant message in multi-turn agent sessions; now reset at both turn boundaries (`tool` + `interim_assistant`). (2) `static/ui.js renderMessages` preferred `m.reasoning` (potentially corrupted by bug 1) over `m.reasoning_content` (the clean per-turn backend value); the fallback now reads `m.reasoning_content || m.reasoning`. Updates `tests/test_streaming_race_fix.py` to scope the reconnect-accumulator guard to the `_wireSSE` preamble only (turn-boundary resets inside event listeners are intentional). Adds `tests/test_issue2565_reasoning_accumulation.py`. Closes #2565.
## [v0.51.121] — 2026-05-24 — Release CS (stage-batch3 — 4-PR low-risk batch — state.db merge / display counts / compression marker / Windows launcher)
### Fixed
- **PR #2788** by @Carry00 — Prevent `state.db` messages being silently dropped during sidecar merge. Two related bugs were combining to discard historical messages: (1) `get_state_db_session_messages()` was selecting `role, content, timestamp` but NOT `id`, so every row was assigned a `("legacy", ...)` merge key instead of `("message_id", ...)`; (2) when a WebUI-origin session was continued via another Hermes surface (Gateway, CLI), the reader was always hitting the *active* profile's `state.db` rather than the session's own profile. Symptom: a 189-message session showed only 50 in the WebUI. Fix: include `id` in the SELECT when the column exists, and accept an optional `profile=` arg so cross-profile reads use the right database. Both callers in `api/routes.py handle_get` now thread `profile=getattr(s, 'profile', None)` through.
- **PR #2797** by @ai-ag2026 — Align messaging session display counts with deduped display messages. The `message_count` returned by `/api/session` is the display coordinate space used for pagination and the header badge. Messaging-thread `state.db` metadata can carry raw duplicate transport rows (blank assistant separators between Discord/Slack thread turns) that `_merged_session_messages_for_display()` intentionally dedupes for rendering. The advertised count was the raw row count, so the frontend expected phantom messages after dedupe — `len(display_msgs) < message_count` triggered "load older" UI states that immediately returned nothing. Fix: `raw["message_count"] = _merged_message_count` for messaging sessions, computed from the same merge that produced the displayed messages. Adds `tests/test_gateway_sync.py::test_messaging_session_message_count_matches_deduped_display_messages` covering the regression.
- **PR #2803** by @simjak — Compression-summary cards no longer use ordinary tool output that merely mentions context compression. The streaming auto-compression path was using a local broad substring matcher that fired on any message containing the strings "context compaction" / "context compression" / "context was auto-compressed" / "active task list was preserved across context compression", including skill/tool JSON output and ordinary user discussion about compaction. The strict predicate at `api/compression_anchor._is_context_compression_marker()` was already correctly scoped to synthetic marker prefixes on non-tool messages. Fix: expose the strict predicate as `is_context_compression_marker()` (public name) and route `api/streaming._is_context_compression_marker` through it as a backward-compatible alias. Tool/skill output that mentions compression no longer seeds `compression_anchor_summary` cards.
### Added
- **PR #2783** by @Koraji95-coder — Native Windows launcher and community-guide README link (squashed from 3 commits). `start.ps1` is a PowerShell equivalent of `start.sh` that bypasses `bootstrap.py`'s `ensure_supported_platform()` refusal and invokes `server.py` directly on native Windows. It mirrors `start.sh`'s discovery (load optional `.env` with the same readonly-var filter for `UID`/`GID`/`EUID`/`EGID`/`PPID`, find Python via `HERMES_WEBUI_PYTHON` env → `python3``python``py`, validate `HERMES_WEBUI_AGENT_DIR` on disk before use, prefer the agent's `venv\Scripts\python.exe`, set `HERMES_WEBUI_HOST` / `HERMES_WEBUI_PORT` / `HERMES_WEBUI_STATE_DIR` / `HERMES_HOME` defaults). The README adds a community-maintained native Windows setup section pointing to @markwang2658's `hermes-windows-native-guide` and `hermes-windows-native` repos with the documented memory delta (~330 MB native vs ~1080 MB WSL2+Docker). Closes both halves of #1952. Assumes Python + agent venv are already set up — first-time setup still needs WSL2 once to create the venv (`bootstrap.py` still refuses on native Windows).
## [v0.51.120] — 2026-05-24 — Release CR (stage-batch2 — 3-PR low-risk batch — Bedrock provider / update check past-tag / CORS preflight)
### Added
- **PR #2786** by @munim — Surface AWS Bedrock as a configurable provider in the WebUI model picker. `api/config.py` registers `"bedrock": "AWS Bedrock"` in `PROVIDER_LABELS`, adds 6 default Bedrock model IDs (Claude Opus 4.7 / 4.6 / 4.5, Sonnet 4.6 / 4.5, Haiku 4.5) to `DEFAULT_MODELS["bedrock"]`, and teaches `_build_configured_model_badges()` to detect Bedrock when both `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` are present (IAM-style auth, not single-API-key). Static fallback list is overridden at runtime by `hermes_cli.models.provider_model_ids("bedrock")` when the live AWS model list is reachable. Adds `tests/test_issue2720_bedrock_model_picker.py` with 11 test cases covering registry, defaults, env-detection, and runtime override. Resolves #2720.
### Fixed
- **PR #2789** by @munim — Update check no longer falsely reports "Up to date" when HEAD has moved hundreds of commits past the latest tag. The hermes-agent repository keeps committing to master between tagged releases, and the old `_check_repo_release()` returned `behind=0` (since `current_tag == latest_tag`) and stopped — so the user saw "Up to date" while the working tree was hundreds of commits behind. The fix: when `behind == 0`, run `git describe --tags --always`; if the result contains the `-N-gSHA` suffix (HEAD past tag), return `None` so `_check_repo_branch()` runs and reports the real commit gap. Adds 8 new test cases in `tests/test_updates.py` covering past-tag detection, equal-tag-and-HEAD pass-through, untagged-repo behavior, and the agent-cadence #2653 scenario. Resolves #2653.
- **PR #2790** by @weidzhou — Add `do_OPTIONS()` handler in `server.py` so CORS preflight requests return `200 OK` with appropriate `Access-Control-Allow-*` headers instead of `501 Not Implemented`. Browsers sending a preflight OPTIONS for cross-origin API calls previously hit the BaseHTTPRequestHandler default and the entire CORS exchange was blocked. The handler narrowly responds only to OPTIONS — no broader CORS posture change to other endpoints. Resubmit of closed #2750 (which bundled unrelated session-index changes); this PR is the minimal preflight-only split that @nesquena-hermes and @AJV20 requested.
## [v0.51.119] — 2026-05-24 — Release CQ (stage-batch1 — 3-PR low-risk batch — tool cards / 404 recovery / Hepburn skin)
### Fixed
- **PR #2801** by @ai-ag2026 — Preserve settled tool cards across stream completion. The streaming `done` handler now derives anchored settled tool cards from message-level tool metadata (`message.tool_calls`, `message._partial_tool_calls`, or `content[].type === 'tool_use'`) when present, instead of unconditionally falling back to session-level `d.session.tool_calls`. The fallback could overwrite the per-message anchors after pagination/windowing because session-level coordinates may not line up with the active message array, causing tool cards to disappear on the final `done` render. Fixes #2613, complements #2777 (which covers pending-segment flushes at tool/interim boundaries). Adds `tests/test_streaming_markdown.py::test_done_handler_prefers_message_tool_metadata_for_settled_render` to lock the precedence.
- **PR #2808** by @chouzz — Recover deterministically from boot-time `/session/{id}` 404s (Option A for #2798). When `loadSession()` hits a 404 during boot-time restore (`!currentSid`), `static/sessions.js` now always clears `localStorage['hermes-webui-session']`, strips the stale URL with `history.replaceState(null, '', '/')`, and rethrows so boot falls through to empty-state recovery. The previous condition required the stale id to match `localStorage`, so a stale `/session/{id}` URL with empty `localStorage` (post state-reset) could leave the UI stuck on "Session not available in web UI." Fixes #2798.
### Added
- **PR #2799** by @gavinssr — Add Hepburn skin (magenta-rose palette derived from the Hepburn TUI theme). Full light + dark palette under `:root[data-skin="hepburn"]` / `:root.dark[data-skin="hepburn"]`, registered in `static/boot.js` `_SKINS` and whitelisted in `static/index.html`'s inline skin gate. As part of this PR `loadSettingsPanel()` in `static/panels.js` now prefers `localStorage.getItem('hermes-skin')` over `settings.skin` when populating the skin picker (DOM truth → settings fallback), so the picker matches what the user actually sees after the inline gate has already resolved legacy aliases.
## [v0.51.118] — 2026-05-22 — Release CP (stage-pr2773 — 1-PR hotfix — v0.51.117 brick fix: chat input restored)
### Fixed
@@ -31,7 +249,6 @@
- **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
@@ -237,7 +454,6 @@ PR #2636 went through the full multi-viewport screenshot gate (390 mobile, 1280
- **PR #2521** by @intellectronica — Add the Geist Contrast skin to the appearance picker. New light + dark variant pair with a high-contrast yellow-on-black accent and Geist editorial typography. Default unchanged — opt-in via Settings → Appearance → Skin → Geist Contrast. Slash command `/theme geist-contrast` now resolves correctly because the lookup matches against `skin.value` rather than `skin.name`. Documented in `THEMES.md` with a forward-compatible skin count (no hard-coded value).
- **PR #2524** by @AJV20 — Add non-sensitive SSE stream runtime diagnostics to deep health checks (`/health?deep=1`), including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Read-only telemetry; existing surfaces unchanged.
## [v0.51.94] — 2026-05-19 — Release BR (stage-387 — 10-PR full sweep batch — Slice 4b runner adapter facade + folder zip download + partial recovery marker dedupe + browser api() client-side timeout + auto-compression card rotation finish + composer draft rollback fix + metadata count reconciliation + active-session refresh on external sidecar updates + indexed context metadata + gateway-queues approval peek)
### Fixed
@@ -4082,6 +4298,11 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **`/reasoning` toast aligned with BRAIN prefix** — success toast now reads `🧠 Reasoning effort: <level>` consistent with the command's other toasts. (`static/commands.js`) [#939]
- **Bootstrap Python discovery finds `.venv/` layout**`discover_launcher_python` now checks both `venv/` and `.venv/` inside the agent directory, covering installations that use a leading-dot venv layout. (`bootstrap.py`) [#941]
## v0.50.185 — 2026-04-24
### Fixed
- **`/btw` `stream_end` now sets `_streamDone`** — defense-in-depth improvement per Opus code review: the `stream_end` SSE handler now sets `_streamDone=true` before closing the connection, guarding against any server flow that emits `stream_end` without a preceding `done`/`apperror` event. (`static/messages.js`)
## v0.50.184 — 2026-04-24
### Fixed
+8 -1
View File
@@ -131,7 +131,14 @@ The bootstrap will:
> Native Windows is not supported for this bootstrap yet. Use Linux, macOS, or WSL2.
> For Windows / WSL auto-start at login, see [`docs/wsl-autostart.md`](docs/wsl-autostart.md).
> A community-maintained native Windows guide is tracked in [#1952](https://github.com/nesquena/hermes-webui/issues/1952).
A community-maintained native Windows setup is documented at [@markwang2658/hermes-windows-native-guide](https://github.com/markwang2658/hermes-windows-native-guide) (companion setup repo: [@markwang2658/hermes-windows-native](https://github.com/markwang2658/hermes-windows-native)). Notes from the community report in [#1952](https://github.com/nesquena/hermes-webui/issues/1952):
- **Memory:** community-measured ~330 MB native vs ~1080 MB with WSL2+Docker (varies by configuration).
- **What works:** chat, workspace browser, session management, all themes.
- **Known limitations:** some POSIX-style file paths surface in the workspace browser; bash-assuming agent tools may not work natively.
- **Native Windows setup:** install Python 3.11+, then from the hermes-agent root in PowerShell: `python -m venv venv``pip install -r requirements.txt``pwsh .\start.ps1` (it auto-discovers `venv\Scripts\python.exe`).
- **WSL2 relationship:** not a prerequisite — a WSL2-built venv (`venv/bin/python`, ELF) isn't invokable by native Windows Python, so use the native setup above. WSL2 stays useful as a parallel install if you want the full `bootstrap.py` + Linux runtime.
If provider setup is still incomplete after install, the onboarding wizard will point you to finish it with `hermes model` instead of trying to replicate the full CLI setup in-browser.
For a step-by-step walkthrough of the wizard, provider choices, local model server Base URLs, and safe re-runs, see [`docs/onboarding.md`](docs/onboarding.md).
+4 -1
View File
@@ -435,10 +435,12 @@ def check_auth(handler, parsed) -> bool:
return True
# Not authorized
if parsed.path.startswith('/api/'):
body = b'{"error":"Authentication required"}'
handler.send_response(401)
handler.send_header('Content-Type', 'application/json')
handler.send_header('Content-Length', str(len(body)))
handler.end_headers()
handler.wfile.write(b'{"error":"Authentication required"}')
handler.wfile.write(body)
else:
handler.send_response(302)
# Pass the original path as ?next= so login.js redirects back after auth.
@@ -468,6 +470,7 @@ def check_auth(handler, parsed) -> bool:
# `?`, `&`, `=`) gets percent-encoded.
_next = _urlparse.quote(_path_with_query, safe='/')
handler.send_header('Location', 'login?next=' + _next)
handler.send_header('Content-Length', '0')
handler.end_headers()
return False
+6 -1
View File
@@ -53,7 +53,7 @@ def _content_has_part_type(content, part_types):
)
def _is_context_compression_marker(message):
def is_context_compression_marker(message):
"""Return true for synthetic compression/reference cards, not user turns."""
if not isinstance(message, dict):
return False
@@ -71,6 +71,11 @@ def _is_context_compression_marker(message):
)
def _is_context_compression_marker(message):
"""Backward-compatible alias for callers that have not switched yet."""
return is_context_compression_marker(message)
def visible_messages_for_anchor(messages, *, auto_compression: bool = False):
"""Return transcript messages that can anchor compression UI metadata.
+125
View File
@@ -713,6 +713,7 @@ _PROVIDER_DISPLAY = {
"x-ai": "xAI",
"nvidia": "NVIDIA NIM",
"xiaomi": "Xiaomi",
"bedrock": "AWS Bedrock",
}
# Provider alias → canonical slug. Users configure providers using the
@@ -1213,6 +1214,16 @@ _PROVIDER_MODELS = {
"xai-oauth": [
{"id": "grok-4.20", "label": "Grok 4.20"},
],
# AWS Bedrock — static fallback list; live model list is fetched via
# hermes_cli.models.provider_model_ids("bedrock") when available (#2720).
"bedrock": [
{"id": "global.anthropic.claude-opus-4-7", "label": "Global Anthropic Claude Opus 4.7"},
{"id": "global.anthropic.claude-opus-4-6-v1", "label": "Global Anthropic Claude Opus 4.6"},
{"id": "global.anthropic.claude-sonnet-4-6", "label": "Global Anthropic Claude Sonnet 4.6"},
{"id": "global.anthropic.claude-opus-4-5-20251101-v1:0", "label": "GLOBAL Anthropic Claude Opus 4.5"},
{"id": "global.anthropic.claude-sonnet-4-5-20250929-v1:0", "label": "Global Claude Sonnet 4.5"},
{"id": "global.anthropic.claude-haiku-4-5-20251001-v1:0", "label": "Global Anthropic Claude Haiku 4.5"},
],
}
@@ -2174,6 +2185,112 @@ def set_hermes_default_model(model_id: str) -> dict:
return {"ok": True, "model": persisted_model}
# ── Auxiliary model configuration ──────────────────────────────────────────
# Canonical auxiliary task slots. Keep in sync with hermes_cli/config.py
# DEFAULT_CONFIG["auxiliary"] and hermes_cli/web_server.py _AUX_TASK_SLOTS.
AUX_TASK_SLOTS: tuple[str, ...] = (
"vision",
"web_extract",
"compression",
"session_search",
"skills_hub",
"approval",
"mcp",
"title_generation",
"curator",
)
def get_auxiliary_models() -> dict:
"""Return current auxiliary task assignments from config.yaml.
Shape:
{
"tasks": [
{"task": "vision", "provider": "auto", "model": "", "base_url": ""},
...
],
"main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"},
}
"""
reload_config()
model_cfg = cfg.get("model", {})
if not isinstance(model_cfg, dict):
model_cfg = {}
main_provider = str(model_cfg.get("provider") or "").strip()
main_model = str(model_cfg.get("default") or model_cfg.get("name") or "").strip()
aux_cfg = cfg.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
tasks = []
for slot in AUX_TASK_SLOTS:
entry = aux_cfg.get(slot, {})
if not isinstance(entry, dict):
entry = {}
tasks.append({
"task": slot,
"provider": str(entry.get("provider") or "auto").strip(),
"model": str(entry.get("model") or "").strip(),
"base_url": str(entry.get("base_url") or "").strip(),
})
return {
"tasks": tasks,
"main": {"provider": main_provider, "model": main_model},
}
def set_auxiliary_model(task: str, provider: str, model: str) -> dict:
"""Persist an auxiliary model assignment in config.yaml.
Special case: task='__reset__' clears all auxiliary slots.
"""
config_path = _get_config_path()
with _cfg_lock:
config_data = _load_yaml_config_file(config_path)
if task == "__reset__":
# Per-slot reset: set each slot to auto, preserving extra fields
# (timeout, extra_body, api_key, base_url, download_timeout, etc.)
aux_cfg = config_data.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
for slot in AUX_TASK_SLOTS:
slot_cfg = aux_cfg.get(slot, {})
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = "auto"
slot_cfg["model"] = ""
aux_cfg[slot] = slot_cfg
config_data["auxiliary"] = aux_cfg
else:
aux_cfg = config_data.get("auxiliary", {})
if not isinstance(aux_cfg, dict):
aux_cfg = {}
slot_cfg = aux_cfg.get(task, {})
if not isinstance(slot_cfg, dict):
slot_cfg = {}
slot_cfg["provider"] = provider or "auto"
slot_cfg["model"] = model or ""
if provider and (provider.startswith("custom:") or provider == "custom"):
try:
_, _, resolved_base_url = resolve_model_provider(model)
if resolved_base_url:
slot_cfg["base_url"] = str(resolved_base_url).strip().rstrip("/")
except Exception:
pass
aux_cfg[task] = slot_cfg
config_data["auxiliary"] = aux_cfg
_save_yaml_config_file(config_path, config_data)
reload_config()
return {"ok": True, "task": task, "provider": provider, "model": model}
# ── TTL cache for get_available_models() ─────────────────────────────────────
_available_models_cache: dict | None = None
_available_models_cache_ts: float = 0.0
@@ -3007,6 +3124,8 @@ def get_available_models() -> dict:
"MINIMAX_CN_API_KEY",
"XAI_API_KEY",
"MISTRAL_API_KEY",
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
):
val = os.getenv(k)
if val:
@@ -3046,6 +3165,10 @@ def get_available_models() -> dict:
detected_providers.add("opencode-zen")
if all_env.get("OPENCODE_GO_API_KEY"):
detected_providers.add("opencode-go")
# AWS Bedrock uses IAM credentials rather than a single API key.
# Detect when both access key and secret are available (#2720).
if all_env.get("AWS_ACCESS_KEY_ID") and all_env.get("AWS_SECRET_ACCESS_KEY"):
detected_providers.add("bedrock")
# LM Studio: detect via LM_API_KEY + LM_BASE_URL in ~/.hermes/.env
if all_env.get("LM_API_KEY") and all_env.get("LM_BASE_URL"):
detected_providers.add("lmstudio")
@@ -4348,6 +4471,7 @@ _SETTINGS_DEFAULTS = {
"show_previous_messaging_sessions": False, # show older Telegram/Discord/etc. reset segments
"sync_to_insights": False, # mirror WebUI token usage to state.db for /insights
"check_for_updates": True, # check if webui/agent repos are behind upstream
"ignore_agent_updates": False, # keep WebUI update notices but suppress Agent update checks
"whats_new_summary_enabled": False, # show an LLM-written What's New summary before diff links
"theme": "dark", # light | dark | system
"skin": "default", # accent color skin: default | ares | mono | slate | poseidon | sisyphus | charizard | sienna | catppuccin | nous
@@ -4507,6 +4631,7 @@ _SETTINGS_BOOL_KEYS = {
"show_previous_messaging_sessions",
"sync_to_insights",
"check_for_updates",
"ignore_agent_updates",
"whats_new_summary_enabled",
"sound_enabled",
"rtl",
+1 -1
View File
@@ -1022,7 +1022,7 @@ def _handle_events_sse_stream(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
# Send an initial frame so the client knows the connection is open
+132 -59
View File
@@ -1723,6 +1723,89 @@ def _repair_stale_pending(session) -> bool:
return False
def _last_non_tool_role(messages) -> str:
if not isinstance(messages, list):
return ''
for message in reversed(messages):
role = _message_role(message)
if role and role != 'tool':
return role
return ''
def _last_non_tool_message(messages):
if not isinstance(messages, list):
return None
for message in reversed(messages):
role = _message_role(message)
if role and role != 'tool':
return message
return None
def _message_content_text(message) -> str:
if not isinstance(message, dict):
return ''
content = message.get('content')
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict) and isinstance(item.get('text'), str):
parts.append(item['text'])
return ''.join(parts)
return ''
def _inactive_cache_tail_needs_disk_check(cached) -> bool:
if cached is None:
return False
if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None):
return False
return _last_non_tool_role(getattr(cached, 'messages', None) or []) == 'user'
def _cache_has_stale_unsaved_user_tail(cached, disk_session) -> bool:
"""Return True when an inactive cached session has an unsaved user tail.
A completed turn is saved to the sidecar before the browser reloads it. In
rare compaction/reconnect paths the in-process cache can retain a recovered
or optimistic user row after the saved assistant tail even though the row was
never persisted. If /api/session serves that cache entry, the visible
transcript appears to end on the old prompt and the saved assistant answer
looks missing until a fork/reload resets the cache.
"""
if cached is None or disk_session is None:
return False
if getattr(cached, 'active_stream_id', None) or getattr(cached, 'pending_user_message', None):
return False
cached_messages = getattr(cached, 'messages', None) or []
disk_messages = getattr(disk_session, 'messages', None) or []
if len(cached_messages) <= len(disk_messages):
return False
if _last_non_tool_role(cached_messages) != 'user':
return False
if _last_non_tool_role(disk_messages) != 'assistant':
return False
cached_tail = _last_non_tool_message(cached_messages)
previous_disk_user = None
for message in reversed(disk_messages):
if _message_role(message) == 'user':
previous_disk_user = message
break
if previous_disk_user is None:
return False
# Only drop tails that look like a duplicated optimistic/recovered user row.
# A genuinely new concurrent user edit must stay in memory so stale-session
# guards can report and preserve it.
return _message_content_text(cached_tail) == _message_content_text(previous_disk_user)
def get_session(sid, metadata_only=False):
"""Load a session, optionally with metadata only (skipping the messages array).
@@ -1736,6 +1819,19 @@ def get_session(sid, metadata_only=False):
if cached is not None:
SESSIONS.move_to_end(sid) # LRU: mark as recently used
if cached is not None:
if not metadata_only and _inactive_cache_tail_needs_disk_check(cached):
try:
disk_session = Session.load(sid)
if _cache_has_stale_unsaved_user_tail(cached, disk_session):
with LOCK:
SESSIONS[sid] = disk_session
SESSIONS.move_to_end(sid)
cached = disk_session
except Exception:
logger.debug(
"stale cached user-tail check failed for session %s",
sid, exc_info=True,
)
if not metadata_only and _session_has_pending_journal_retry(cached):
try:
_try_retry_journal_recovery_in_place(cached)
@@ -2057,9 +2153,27 @@ def all_sessions(diag=None):
_diag_stage(diag, "all_sessions.prune_index")
with LOCK:
in_memory_ids = set(SESSIONS.keys())
try:
persisted_ids = {
p.stem
for p in SESSION_DIR.glob('*.json')
if not p.name.startswith('_')
}
except Exception:
persisted_ids = None
index = [
s for s in index
if _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids)
if (
str(s.get('session_id') or '') in in_memory_ids
or (
persisted_ids is not None
and str(s.get('session_id') or '') in persisted_ids
)
or (
persisted_ids is None
and _index_entry_exists(s.get('session_id'), in_memory_ids=in_memory_ids)
)
)
]
backfilled = []
for i, s in enumerate(index):
@@ -2815,21 +2929,28 @@ def _json_loads_if_string(value):
return value
def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) -> list:
"""Read messages for a Hermes session from the active profile's state.db.
def get_state_db_session_messages(sid, *, stitch_continuations: bool = False, profile=None) -> list:
"""Read messages for a Hermes session from state.db.
This generic reader intentionally works for any session source, including
WebUI-origin sessions that were later updated through another Hermes surface
such as the Gateway API Server. When ``stitch_continuations`` is true it
preserves the historical CLI/external-agent behavior of walking compatible
compression/close parent segments before reading messages.
When *profile* is supplied, reads from that profile's state.db; otherwise
falls back to the active profile's state.db. This generic reader works for
any session source, including WebUI-origin sessions that were later updated
through another Hermes surface such as the Gateway API Server. When
``stitch_continuations`` is true it preserves the historical CLI/external-agent
behavior of walking compatible compression/close parent segments before reading
messages.
"""
try:
import sqlite3
except ImportError:
return []
db_path = _active_state_db_path()
if isinstance(profile, str) and profile:
db_path = _get_profile_home(profile) / 'state.db'
if not db_path.exists():
db_path = _active_state_db_path()
else:
db_path = _active_state_db_path()
if not db_path.exists():
return []
@@ -2852,7 +2973,8 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) ->
'reasoning_content',
'codex_message_items',
]
selected = ['role', 'content', 'timestamp'] + [c for c in optional if c in available]
id_col = ['id'] if 'id' in available else []
selected = id_col + ['role', 'content', 'timestamp'] + [c for c in optional if c in available]
session_chain = [str(sid)]
if stitch_continuations:
@@ -2928,55 +3050,6 @@ def get_state_db_session_messages(sid, *, stitch_continuations: bool = False) ->
return msgs
def get_state_db_session_summary(sid) -> dict:
"""Return cheap message count/max timestamp for one state.db session.
This is intentionally narrower than ``get_state_db_session_messages`` for
metadata-only WebUI polling: callers only need a staleness signal, not a
fully materialized transcript with tool/reasoning metadata.
"""
import os
try:
import sqlite3
except ImportError:
return {}
db_path = _active_state_db_path()
if not sid or not db_path.exists():
return {}
try:
with closing(sqlite3.connect(str(db_path))) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("PRAGMA table_info(messages)")
available = {str(row['name']) for row in cur.fetchall()}
if not {'session_id', 'timestamp'}.issubset(available):
return {}
cur.execute(
"""
SELECT COUNT(*) AS message_count, MAX(timestamp) AS last_message_at
FROM messages
WHERE session_id = ?
""",
(str(sid),),
)
row = cur.fetchone()
if not row:
return {}
count = int(row['message_count'] or 0)
last_message_at = row['last_message_at']
result = {'message_count': count}
if last_message_at not in (None, ''):
try:
result['last_message_at'] = float(last_message_at)
except (TypeError, ValueError):
pass
return result
except Exception:
return {}
def _normalized_message_timestamp_for_key(value):
if value is None or value == "":
return ""
+478 -69
View File
@@ -6,6 +6,7 @@ Extracted from server.py (Sprint 11) so server.py is a thin shell.
import html as _html
import copy
import io
import gzip
import json
import logging
import os
@@ -76,6 +77,12 @@ _CSP_REPORT_RATE_LIMIT_MAX = 100
_CSP_REPORT_MAX_BODY_BYTES = 64 * 1024
def _session_field(session, field, default=None):
if isinstance(session, dict):
return session.get(field, default)
return getattr(session, field, default)
# ── Profile-scoped session/project filtering (#1611, #1614) ────────────────
#
# Sessions and projects are stored in the WebUI sidecar without per-row
@@ -1259,8 +1266,37 @@ def _csrf_exempt_path(path: str) -> bool:
return path in {"/api/auth/login", "/api/csp-report"}
_CSRF_FAILURE_ATTR = "_hermes_csrf_failure_reason"
def _set_csrf_failure_reason(handler, reason: str) -> bool:
try:
setattr(handler, _CSRF_FAILURE_ATTR, reason)
except Exception:
pass
return False
def _clear_csrf_failure_reason(handler) -> None:
try:
if hasattr(handler, _CSRF_FAILURE_ATTR):
delattr(handler, _CSRF_FAILURE_ATTR)
except Exception:
pass
def _csrf_rejection_error(handler) -> str:
reason = getattr(handler, _CSRF_FAILURE_ATTR, "")
if reason == "origin_mismatch":
return "Cross-origin mismatch - check reverse proxy headers"
if reason == "token_mismatch":
return "Session expired - reload the page"
return "Cross-origin request rejected"
def _check_csrf(handler) -> bool:
"""Reject cross-origin or tokenless authenticated browser unsafe requests."""
_clear_csrf_failure_reason(handler)
origin = handler.headers.get("Origin", "")
referer = handler.headers.get("Referer", "")
host = handler.headers.get("Host", "")
@@ -1270,7 +1306,7 @@ def _check_csrf(handler) -> bool:
# Extract host:port from origin/referer
m = _re.match(r"^https?://([^/]+)", target)
if not m:
return False
return _set_csrf_failure_reason(handler, "origin_mismatch")
origin_host = m.group(1)
origin_scheme = m.group(0).split('://')[0].lower() # 'http' or 'https'
origin_name, origin_port = _normalize_host_port(origin_host)
@@ -1298,7 +1334,7 @@ def _check_csrf(handler) -> bool:
origin_allowed = True
break
if not origin_allowed:
return False
return _set_csrf_failure_reason(handler, "origin_mismatch")
from api.auth import CSRF_HEADER_NAME, is_auth_enabled, parse_cookie, verify_csrf_token
@@ -1306,7 +1342,9 @@ def _check_csrf(handler) -> bool:
return True
cookie_val = parse_cookie(handler)
submitted = handler.headers.get(CSRF_HEADER_NAME) or handler.headers.get("X-CSRF-Token")
return verify_csrf_token(cookie_val or "", submitted or "")
if verify_csrf_token(cookie_val or "", submitted or ""):
return True
return _set_csrf_failure_reason(handler, "token_mismatch")
def _client_ip_for_rate_limit(handler) -> str:
@@ -2004,6 +2042,37 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list:
return sidecar_messages
def _message_summary(messages) -> dict:
messages = list(messages or [])
last_message_at = 0.0
for msg in messages:
if not isinstance(msg, dict):
continue
try:
last_message_at = max(last_message_at, float(msg.get("timestamp") or 0))
except (TypeError, ValueError):
pass
return {"message_count": len(messages), "last_message_at": last_message_at}
def _metadata_only_message_summary(sid: str, profile: str | None = None) -> dict:
"""Return the reconciled message summary used by metadata-only session loads.
Threads ``profile=`` through to ``get_state_db_session_messages`` so
background-thread reads land on the correct profile's state.db (per the
cookie-bound profile selector fixes the same TLS-vs-thread race the
#2762 fix addressed for write paths).
"""
sidecar_session = Session.load(sid)
sidecar_messages = []
if sidecar_session:
sidecar_messages = getattr(sidecar_session, "messages", []) or []
state_db_messages = get_state_db_session_messages(sid, profile=profile)
return _message_summary(
merge_session_messages_append_only(sidecar_messages, state_db_messages)
)
def _session_requires_cli_metadata_lookup(session) -> bool:
"""Return True when a sidecar/session row still needs CLI metadata.
@@ -2529,6 +2598,15 @@ _LOGIN_LOCALE = {
"invalid_pw": "\ube44\ubc00\ubc88\ud638\uac00 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4",
"conn_failed": "\uc5f0\uacb0 \uc2e4\ud328",
},
"tr": {
"lang": "tr-TR",
"title": "Oturum a\u00e7",
"subtitle": "Devam etmek i\u00e7in \u015fifrenizi girin",
"placeholder": "\u015eifre",
"btn": "Oturum a\u00e7",
"invalid_pw": "Ge\u00e7ersiz \u015fifre",
"conn_failed": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z",
},
}
@@ -3609,6 +3687,11 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/models/live":
return _handle_live_models(handler, parsed)
# ── Auxiliary models (GET/POST) ──
if parsed.path == "/api/model/auxiliary":
from api.config import get_auxiliary_models
return j(handler, get_auxiliary_models())
if parsed.path == "/api/dashboard/status":
from api import dashboard_probe
@@ -3751,24 +3834,19 @@ def handle_get(handler, parsed) -> bool:
is_messaging_session = _is_messaging_session_record(s) or _is_messaging_session_record(cli_meta)
cli_messages = []
state_db_messages = []
sidecar_metadata_messages = None
metadata_summary = None
_session_profile = getattr(s, 'profile', None) or None
if is_messaging_session:
cli_messages = get_cli_session_messages(sid)
elif load_messages:
state_db_messages = get_state_db_session_messages(sid)
state_db_messages = get_state_db_session_messages(sid, profile=_session_profile)
elif not is_messaging_session:
# Metadata-only callers still need the same append-only
# reconciliation contract as full loads. A raw state.db summary
# can count stale rows that the merge intentionally filters out,
# which makes sidebar polling think the transcript is always
# newer than the loaded conversation.
state_db_messages = get_state_db_session_messages(sid)
sidecar_metadata_session = Session.load(sid)
sidecar_metadata_messages = (
getattr(sidecar_metadata_session, "messages", []) or []
if sidecar_metadata_session
else []
)
# reconciliation contract as full loads so stale/replayed
# state.db rows do not make sidebar polling think the
# transcript is always newer. Helper threads profile= to
# honor #2827's TLS-vs-thread fix.
metadata_summary = _metadata_only_message_summary(sid, profile=_session_profile)
_t2 = _time.monotonic()
effective_model = (
_resolve_effective_session_model_for_display(s)
@@ -3798,12 +3876,16 @@ def handle_get(handler, parsed) -> bool:
sidecar_messages = getattr(s, "messages", []) or []
_all_msgs = merge_session_messages_append_only(cli_messages, sidecar_messages)
else:
_metadata_sidecar = sidecar_metadata_messages
if _metadata_sidecar is None:
_metadata_sidecar = getattr(s, "messages", []) or []
_all_msgs = merge_session_messages_append_only(_metadata_sidecar, state_db_messages)
if metadata_summary is None:
metadata_summary = _message_summary(getattr(s, "messages", []) or [])
_summary_message_count = metadata_summary["message_count"]
_summary_last_message_at = metadata_summary["last_message_at"]
_all_msgs = []
if not load_messages:
_summary_message_count = len(_all_msgs)
if metadata_summary is None:
metadata_summary = _message_summary(_all_msgs)
_summary_message_count = metadata_summary["message_count"]
_summary_last_message_at = metadata_summary["last_message_at"]
if _summary_message_count == 0:
# Legacy session with no loaded sidecar and no state.db summary —
# fall back to the persisted metadata count from session JSON.
@@ -3816,14 +3898,6 @@ def handle_get(handler, parsed) -> bool:
_summary_message_count = max(0, int(metadata_count))
except (TypeError, ValueError):
pass
try:
_summary_last_message_at = max(
float((m or {}).get("timestamp") or 0)
for m in _all_msgs
if isinstance(m, dict)
) if _all_msgs else 0
except (TypeError, ValueError):
_summary_last_message_at = 0
else:
_summary_message_count = None
_summary_last_message_at = None
@@ -3926,6 +4000,13 @@ def handle_get(handler, parsed) -> bool:
)
if cli_meta and _is_messaging_session_record(cli_meta):
raw = _merge_cli_sidebar_metadata(raw, cli_meta)
# ``message_count`` in /api/session is the display coordinate
# space used for pagination and the header badge. Messaging
# state.db metadata can include raw duplicate transport rows that
# _merged_session_messages_for_display() intentionally dedupes;
# keep the raw count available as ``actual_message_count`` but
# do not let it make the frontend expect phantom messages.
raw["message_count"] = _merged_message_count
# Signal to the frontend that older messages were omitted.
# For msg_before paging, compare against the filtered set,
# not the full list — otherwise we signal truncation even when
@@ -4251,6 +4332,7 @@ def handle_get(handler, parsed) -> bool:
settings = load_settings()
if not settings.get("check_for_updates", True):
return j(handler, {"disabled": True})
include_agent_updates = not bool(settings.get("ignore_agent_updates"))
qs = parse_qs(parsed.query)
force = qs.get("force", ["0"])[0] == "1"
# ?simulate=1 returns fake behind counts for UI testing (localhost only)
@@ -4272,7 +4354,8 @@ def handle_get(handler, parsed) -> bool:
},
"agent": {
"name": "agent",
"behind": 1,
"behind": 1 if include_agent_updates else 0,
"ignored": not include_agent_updates,
"current_sha": "aaa0001",
"latest_sha": "bbb0002",
"branch": "master",
@@ -4284,7 +4367,7 @@ def handle_get(handler, parsed) -> bool:
)
from api.updates import check_for_updates
return j(handler, check_for_updates(force=force))
return j(handler, check_for_updates(force=force, include_agent=include_agent_updates))
if parsed.path == "/api/chat/stream/status":
stream_id = parse_qs(parsed.query).get("stream_id", [""])[0]
@@ -4612,7 +4695,7 @@ def handle_post(handler, parsed) -> bool:
diag.stage("csrf")
if not _csrf_exempt_path(parsed.path) and not _check_csrf(handler):
try:
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
finally:
if diag:
diag.finish()
@@ -4786,6 +4869,25 @@ def handle_post(handler, parsed) -> bool:
except RuntimeError as e:
return bad(handler, str(e), 500)
# ── Auxiliary model set (POST) ──
if parsed.path == "/api/model/set":
scope = str(body.get("scope") or "").strip()
task = str(body.get("task") or "").strip()
provider = str(body.get("provider") or "auto").strip()
model = str(body.get("model") or "").strip()
if scope == "auxiliary":
from api.config import set_auxiliary_model
try:
return j(handler, set_auxiliary_model(task, provider, model))
except Exception as exc:
return bad(handler, str(exc), status=400)
if scope == "main":
try:
return j(handler, set_hermes_default_model(model))
except ValueError as exc:
return bad(handler, str(exc), status=400)
return bad(handler, f"unknown scope: {scope}", status=400)
# ── Providers (POST) ──
if parsed.path == "/api/providers":
provider_id = (body.get("provider") or "").strip().lower()
@@ -5454,6 +5556,9 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/file/path":
return _handle_file_path(handler, body)
if parsed.path == "/api/file/open-vscode":
return _handle_file_open_vscode(handler, body)
# ── Workspace management (POST) ──
if parsed.path == "/api/workspaces/add":
return _handle_workspace_add(handler, body)
@@ -5769,8 +5874,8 @@ def handle_post(handler, parsed) -> bool:
# Pre-snapshot from persisted index (acquires LOCK internally,
# so must run outside our own LOCK acquire below).
persisted_pinned_ids = {
getattr(existing, "session_id", None) for existing in all_sessions()
if getattr(existing, "pinned", False) and not getattr(existing, "archived", False)
_session_field(existing, "session_id", None) for existing in all_sessions()
if _session_field(existing, "pinned", False) and not _session_field(existing, "archived", False)
}
with LOCK:
# Final authoritative count: merge persisted-pinned with the
@@ -6142,13 +6247,15 @@ def handle_post(handler, parsed) -> bool:
_record_login_attempt(client_ip)
return bad(handler, "Invalid password", 401)
cookie_val = create_session()
body = json.dumps({"ok": True}).encode()
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
_security_headers(handler)
set_auth_cookie(handler, cookie_val)
handler.end_headers()
handler.wfile.write(json.dumps({"ok": True}).encode())
handler.wfile.write(body)
return True
if parsed.path == "/api/auth/logout":
@@ -6157,13 +6264,15 @@ def handle_post(handler, parsed) -> bool:
cookie_val = parse_cookie(handler)
if cookie_val:
invalidate_session(cookie_val)
body = json.dumps({"ok": True}).encode()
handler.send_response(200)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Cache-Control", "no-store")
_security_headers(handler)
clear_auth_cookie(handler)
handler.end_headers()
handler.wfile.write(json.dumps({"ok": True}).encode())
handler.wfile.write(body)
return True
# ── Checkpoints / Rollback (POST) ──
@@ -6189,8 +6298,11 @@ def handle_post(handler, parsed) -> bool:
def handle_patch(handler, parsed) -> bool:
"""Handle all PATCH routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/mcp/servers/"):
name = parsed.path[len("/api/mcp/servers/"):]
return _handle_mcp_server_toggle(handler, name, body)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_patch
@@ -6204,8 +6316,11 @@ def handle_patch(handler, parsed) -> bool:
def handle_delete(handler, parsed) -> bool:
"""Handle all DELETE routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
return j(handler, {"error": _csrf_rejection_error(handler)}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/mcp/servers/"):
name = parsed.path[len("/api/mcp/servers/"):]
return _handle_mcp_server_delete(handler, name)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_delete
@@ -6215,6 +6330,17 @@ def handle_delete(handler, parsed) -> bool:
return True
return False
def handle_put(handler, parsed) -> bool:
"""Handle all PUT routes. Returns True if handled, False for 404."""
if not _check_csrf(handler):
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
body = read_body(handler)
if parsed.path.startswith("/api/mcp/servers/"):
name = parsed.path[len("/api/mcp/servers/"):]
return _handle_mcp_server_update(handler, name, body)
return False
# ── GET route helpers ─────────────────────────────────────────────────────────
# MIME types for static file serving. Hoisted to module scope to avoid
@@ -6236,6 +6362,20 @@ _STATIC_MIME = {
# MIME types that are text-based and should carry charset=utf-8
_TEXT_MIME_TYPES = {"text/css", "application/javascript", "text/html", "image/svg+xml", "text/plain"}
# MIME types worth gzipping. Image and font formats (png/jpg/webp/woff2) are
# already compressed; gzip would only add CPU and a few bytes of framing.
_COMPRESSIBLE_MIME = {
"text/css", "application/javascript", "text/html", "image/svg+xml",
"application/json", "text/plain",
}
# In-process cache for raw bytes, compressed bytes, and ETag. The cache is keyed
# by absolute path and invalidated on (size, high-precision mtime) change, so a
# redeploy is picked up without a process restart. Missing/random paths never
# enter the cache; memory cost is bounded by the static/ tree's served files.
_STATIC_CACHE: dict = {}
_STATIC_CACHE_LOCK = threading.Lock()
def _serve_static(handler, parsed):
static_root = (Path(__file__).parent.parent / "static").resolve()
@@ -6251,13 +6391,63 @@ def _serve_static(handler, parsed):
ext = static_file.suffix.lower()
ct = _STATIC_MIME.get(ext.lstrip("."), "text/plain")
ct_header = f"{ct}; charset=utf-8" if ct in _TEXT_MIME_TYPES else ct
# Look up or populate the per-file cache (raw, optional gzip, ETag).
# Keyed by absolute path; invalidated by (size, nanosecond mtime).
st = static_file.stat()
sig = (st.st_size, st.st_mtime_ns)
cache_key = str(static_file)
raw = gz = etag = None
with _STATIC_CACHE_LOCK:
cached = _STATIC_CACHE.get(cache_key)
if cached and cached[0] == sig:
_, raw, gz, etag = cached
if raw is None:
raw = static_file.read_bytes()
# Weak ETag: equality semantics, derived from filesystem identity.
etag = f'W/"{sig[0]:x}-{sig[1]:x}"'
gz = (gzip.compress(raw, compresslevel=6)
if ct in _COMPRESSIBLE_MIME and len(raw) > 1024
else None)
with _STATIC_CACHE_LOCK:
_STATIC_CACHE[cache_key] = (sig, raw, gz, etag)
# The page template substitutes __WEBUI_VERSION__ at request time (see the
# `/`/`/index.html`/`/session/` branch above), and static/sw.js's
# SHELL_ASSETS list relies on the same convention. So a fingerprinted URL
# is safe to cache aggressively: any redeploy changes the URL.
version_values = parse_qs(parsed.query, keep_blank_values=True).get("v", [""])
has_fingerprint = bool(version_values[0])
cache_control = (
"public, max-age=31536000, immutable" if has_fingerprint
else "public, max-age=300"
)
# 304 short-circuit on conditional GET.
if handler.headers.get("If-None-Match") == etag:
handler.send_response(304)
handler.send_header("ETag", etag)
handler.send_header("Cache-Control", cache_control)
if gz is not None:
handler.send_header("Vary", "Accept-Encoding")
handler.end_headers()
return True
accept_enc = (handler.headers.get("Accept-Encoding") or "").lower()
use_gzip = gz is not None and "gzip" in accept_enc
body = gz if use_gzip else raw
handler.send_response(200)
handler.send_header("Content-Type", ct_header)
handler.send_header("Cache-Control", "no-store")
raw = static_file.read_bytes()
handler.send_header("Content-Length", str(len(raw)))
handler.send_header("Content-Length", str(len(body)))
handler.send_header("ETag", etag)
handler.send_header("Cache-Control", cache_control)
if gz is not None:
handler.send_header("Vary", "Accept-Encoding")
if use_gzip:
handler.send_header("Content-Encoding", "gzip")
handler.end_headers()
handler.wfile.write(raw)
handler.wfile.write(body)
return True
@@ -6422,7 +6612,7 @@ def _handle_sse_stream(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
try:
_replay_run_journal(handler, stream_id, _parse_run_journal_after_seq(qs))
@@ -6434,7 +6624,7 @@ def _handle_sse_stream(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
try:
while True:
@@ -6565,7 +6755,7 @@ def _handle_terminal_output(handler, parsed):
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
handler.send_header("Cache-Control", "no-cache")
handler.send_header("X-Accel-Buffering", "no")
handler.send_header("Connection", "keep-alive")
handler.send_header("Connection", "close")
handler.end_headers()
try:
while True:
@@ -6643,7 +6833,7 @@ def _handle_gateway_sse_stream(handler, parsed):
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.send_header('Connection', 'close')
handler.end_headers()
q = watcher.subscribe()
@@ -6676,7 +6866,7 @@ def _handle_session_events_stream(handler):
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.send_header('Connection', 'close')
handler.end_headers()
q = subscribe_session_events()
@@ -6762,6 +6952,7 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
handler.send_response(416)
handler.send_header("Content-Range", f"bytes */{file_size}")
handler.send_header("Accept-Ranges", "bytes")
handler.send_header("Content-Length", "0")
_security_headers(handler)
handler.end_headers()
return True
@@ -6806,6 +6997,51 @@ def _serve_file_bytes(handler, target: Path, mime: str, disposition: str, cache_
return True
def _html_preview_with_blank_base(raw: bytes) -> bytes:
base = '<base target="_blank">'
text = raw.decode("utf-8", errors="replace")
if re.search(r"<head(?:\s[^>]*)?>", text, flags=re.IGNORECASE):
text = re.sub(r"(<head\b[^>]*>)", r"\1" + base, text, count=1, flags=re.IGNORECASE)
elif re.search(r"<!doctype[^>]*>", text, flags=re.IGNORECASE):
text = re.sub(
r"(<!doctype[^>]*>)",
r"\1<head>" + base + "</head>",
text,
count=1,
flags=re.IGNORECASE,
)
else:
text = "<head>" + base + "</head>" + text
return text.encode("utf-8")
def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp: str):
"""Serve sandboxed workspace HTML preview with links targeting a new tab."""
try:
body = _html_preview_with_blank_base(target.read_bytes())
except PermissionError:
return bad(handler, "Permission denied", 403)
except Exception:
return bad(handler, "Could not read file", 500)
handler.send_response(200)
handler.send_header("Content-Type", "text/html; charset=utf-8")
handler.send_header("Content-Length", str(len(body)))
handler.send_header("Accept-Ranges", "none")
handler.send_header("Cache-Control", cache_control)
handler.send_header("Content-Disposition", _content_disposition_value("inline", target.name))
handler.send_header("Content-Security-Policy", csp)
handler.send_header("X-Content-Type-Options", "nosniff")
handler.send_header("Referrer-Policy", "same-origin")
handler.send_header(
"Permissions-Policy",
"camera=(), microphone=(self), geolocation=(), clipboard-write=(self)",
)
handler.end_headers()
handler.wfile.write(body)
return True
def _handle_media(handler, parsed):
"""Serve a local file by absolute path for inline display in the chat.
@@ -6827,10 +7063,12 @@ def _handle_media(handler, parsed):
if is_auth_enabled():
cv = parse_cookie(handler)
if not (cv and verify_session(cv)):
body = b'{"error":"Authentication required"}'
handler.send_response(401)
handler.send_header("Content-Type", "application/json")
handler.send_header("Content-Length", str(len(body)))
handler.end_headers()
handler.wfile.write(b'{"error":"Authentication required"}')
handler.wfile.write(body)
return
qs = parse_qs(parsed.query)
@@ -7064,6 +7302,15 @@ def _handle_folder_download(handler, parsed):
_content_disposition_value("attachment", zip_name),
)
handler.send_header("Cache-Control", "no-store")
# Under HTTP/1.1 (Handler.protocol_version, see server.py post-#2836)
# a response with no Content-Length and no Transfer-Encoding requires
# Connection: close so the client knows the body ends at FIN. The ZIP
# is built on-the-fly so we cannot send Content-Length up front; mirror
# the SSE-endpoint pattern #2836 uses. Without this header the client
# hangs waiting for the next pipelined response after the central
# directory bytes finish. Caught by Opus pre-release advisor on
# stage-batch11.
handler.send_header("Connection", "close")
handler.end_headers()
written = 0
@@ -7111,8 +7358,10 @@ def _handle_file_raw(handler, parsed):
# CSP sandbox directive applies the same isolation server-side: without
# allow-same-origin, the document is treated as a unique opaque origin and
# cannot read WebUI cookies, localStorage, or postMessage to the parent.
csp = "sandbox allow-scripts" if html_inline_ok else None
csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox" if html_inline_ok else None
# _serve_file_bytes sends Content-Security-Policy when csp is set.
if html_inline_ok:
return _serve_inline_html_preview(handler, target, "no-store", csp=csp)
return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp)
@@ -7187,7 +7436,7 @@ def _handle_approval_sse_stream(handler, parsed):
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.send_header('Connection', 'close')
handler.end_headers()
from api.streaming import _sse
@@ -7288,7 +7537,7 @@ def _handle_clarify_sse_stream(handler, parsed):
handler.send_header('Content-Type', 'text/event-stream; charset=utf-8')
handler.send_header('Cache-Control', 'no-cache')
handler.send_header('X-Accel-Buffering', 'no')
handler.send_header('Connection', 'keep-alive')
handler.send_header('Connection', 'close')
handler.end_headers()
from api.streaming import _sse
@@ -8262,6 +8511,41 @@ def _start_chat_stream_for_session(
return response
def _runtime_runner_client_factory():
"""Return the runner-local client when a supervised backend exists.
Slice 4d wires the `/api/chat/start` selection point without silently falling
back to the legacy in-process runtime when `runner-local` is explicitly
requested. The supervised runner backend itself is intentionally not created
in this helper yet; a later slice can replace this factory with the concrete
client while keeping the route contract stable.
"""
raise NotImplementedError("runner-local chat backend is not configured")
def _chat_start_response_from_run_start(result):
"""Expose only the legacy browser-facing chat-start response fields."""
payload = dict(getattr(result, "payload", {}) or {})
response = {}
for key in (
"stream_id",
"session_id",
"pending_started_at",
"turn_id",
"title",
"effective_model",
"effective_model_provider",
"error",
"active_stream_id",
"_status",
):
if key in payload:
response[key] = payload[key]
response.setdefault("stream_id", result.stream_id)
response.setdefault("session_id", result.session_id)
return response
def _runtime_adapter_goal_action(goal_args: str) -> str:
"""Return the bounded RuntimeAdapter goal action for WebUI /goal args."""
action = str(goal_args or "").strip().lower()
@@ -8471,10 +8755,12 @@ def _handle_chat_start(handler, body, diag=None):
from api.runtime_adapter import (
LegacyJournalRuntimeAdapter,
StartRunRequest,
build_runtime_adapter,
runtime_adapter_enabled,
runtime_adapter_runner_enabled,
)
if runtime_adapter_enabled():
if runtime_adapter_enabled() or runtime_adapter_runner_enabled():
def _legacy_start_run(request: StartRunRequest) -> dict:
return _start_chat_stream_for_session(
s,
@@ -8487,23 +8773,32 @@ def _handle_chat_start(handler, body, diag=None):
diag=diag,
)
adapter = LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run)
result = adapter.start_run(
StartRunRequest(
session_id=s.session_id,
message=msg,
attachments=attachments,
workspace=workspace,
profile=getattr(s, "profile", None),
provider=model_provider,
model=model,
source="webui",
metadata={"route": "/api/chat/start"},
def _legacy_adapter_factory():
return LegacyJournalRuntimeAdapter(start_run_delegate=_legacy_start_run)
try:
adapter = build_runtime_adapter(
legacy_adapter_factory=_legacy_adapter_factory,
runner_client_factory=_runtime_runner_client_factory,
)
)
response = dict(result.payload)
response.setdefault("stream_id", result.stream_id)
response.setdefault("session_id", result.session_id)
if adapter is None:
raise NotImplementedError("runtime adapter selection returned no adapter")
result = adapter.start_run(
StartRunRequest(
session_id=s.session_id,
message=msg,
attachments=attachments,
workspace=workspace,
profile=getattr(s, "profile", None),
provider=model_provider,
model=model,
source="webui",
metadata={"route": "/api/chat/start"},
)
)
except NotImplementedError as exc:
return j(handler, {"error": str(exc)}, status=501)
response = _chat_start_response_from_run_start(result)
else:
response = _start_chat_stream_for_session(
s,
@@ -8725,6 +9020,12 @@ def _handle_chat_sync(handler, body):
model=s.model,
title=s.title,
message_count=len(s.messages),
# #2762 / #2827 parity with api/streaming.py:5078: pass the
# session's profile explicitly so a future refactor that
# backgrounds this handler doesn't silently leak writes to
# the wrong profile's state.db. HTTP thread today, but
# defense-in-depth. Opus pre-release advisor MUST-FIX.
profile=getattr(s, 'profile', None),
)
except Exception:
logger.debug("Failed to update session cost tracking")
@@ -9526,6 +9827,90 @@ def _handle_file_path(handler, body):
return bad(handler, _sanitize_error(e))
def _handle_file_open_vscode(handler, body):
"""Open a workspace file or folder in VS Code (#2735).
Reads optional ``vscode`` config block from config.yaml:
vscode:
command: code # executable on PATH; defaults to "code"
host_path_prefix: /home/user/projects # Docker host path
container_path_prefix: /app/workspace # matching container path
If ``host_path_prefix`` and ``container_path_prefix`` are both set,
paths that begin with ``container_path_prefix`` are translated to the
host prefix before being handed to VS Code. This lets users running
Hermes WebUI inside Docker still open files in their local editor.
"""
try:
require(body, "session_id", "path")
except ValueError as e:
return bad(handler, str(e))
try:
s = get_session(body["session_id"])
except KeyError:
return bad(handler, "Session not found", 404)
try:
target = safe_resolve(Path(s.workspace), body["path"])
if not target.exists():
return bad(handler, f"File not found: {target}", 404)
target_str = str(target)
# Optional Docker host/container path translation
from api.config import get_config as _get_cfg # noqa: PLC0415
vscode_cfg = _get_cfg().get("vscode", {})
if not isinstance(vscode_cfg, dict):
vscode_cfg = {}
container_prefix = vscode_cfg.get("container_path_prefix", "")
host_prefix = vscode_cfg.get("host_path_prefix", "")
if container_prefix and host_prefix and target_str.startswith(container_prefix):
target_str = host_prefix + target_str[len(container_prefix):]
cmd = vscode_cfg.get("command", "code")
# Resolve the command to an absolute path so subprocess.Popen finds it
# even when the server process inherits a minimal PATH (e.g. when
# launched via start.sh on macOS where /usr/local/bin may be absent).
resolved_cmd = shutil.which(cmd)
if resolved_cmd is None:
# Try common VS Code installation paths as fallback.
# macOS: /usr/local/bin/code (symlink) or app bundle CLI
# Linux: /usr/bin/code or snap
# Windows: user-install under %LOCALAPPDATA%, system-install under %PROGRAMFILES%
_local_app_data = os.environ.get("LOCALAPPDATA", "")
_prog_files = os.environ.get("PROGRAMFILES", "C:\\Program Files")
_prog_files_x86 = os.environ.get("PROGRAMFILES(X86)", "C:\\Program Files (x86)")
_vscode_fallbacks = [
# macOS
"/usr/local/bin/code",
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
# Linux
"/usr/bin/code",
"/snap/bin/code",
# Windows (user install)
os.path.join(_local_app_data, "Programs", "Microsoft VS Code", "bin", "code.cmd"),
# Windows (system install)
os.path.join(_prog_files, "Microsoft VS Code", "bin", "code.cmd"),
os.path.join(_prog_files_x86, "Microsoft VS Code", "bin", "code.cmd"),
]
for fb in _vscode_fallbacks:
if fb and Path(fb).exists():
resolved_cmd = fb
break
if resolved_cmd is None:
return bad(
handler,
f"VS Code command not found: {cmd!r}. "
"Install VS Code and ensure the 'code' CLI is on PATH, "
"or set vscode.command in config.yaml to the full path.",
)
subprocess.Popen([resolved_cmd, target_str])
return j(handler, {"ok": True, "path": body["path"]})
except (ValueError, PermissionError, OSError) as e:
return bad(handler, _sanitize_error(e))
def _handle_workspace_add(handler, body):
# Strip surrounding paired quotes BEFORE any further processing — macOS
# Finder's "Copy as Pathname" wraps paths in single quotes, and users
@@ -12099,7 +12484,7 @@ def _handle_mcp_servers_list(handler):
]
return j(handler, {
"servers": result,
"toggle_supported": False,
"toggle_supported": True,
"reload_required": True,
})
@@ -12123,6 +12508,30 @@ def _handle_mcp_server_delete(handler, name):
return j(handler, {"ok": True, "deleted": name})
def _handle_mcp_server_toggle(handler, name, body):
"""Toggle enabled state for an MCP server (PATCH /api/mcp/servers/{name})."""
from urllib.parse import unquote
name = unquote(name)
if not name:
return bad(handler, "name is required")
if "enabled" not in body:
return bad(handler, "enabled field is required")
enabled = bool(body["enabled"])
cfg = get_config()
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
if name not in servers:
return bad(handler, f"MCP server '{name}' not found", 404)
if not isinstance(servers[name], dict):
return bad(handler, f"MCP server '{name}' has invalid config", 400)
servers[name]["enabled"] = enabled
cfg["mcp_servers"] = servers
_save_yaml_config_file(_get_config_path(), cfg)
reload_config()
return j(handler, {"ok": True, "name": name, "enabled": enabled})
_MASKED_PLACEHOLDER = "••••••"
+83 -14
View File
@@ -20,22 +20,78 @@ from pathlib import Path
logger = logging.getLogger(__name__)
def _get_state_db():
"""Get a SessionDB instance for the active profile's state.db.
Returns None if hermes_state is not importable or DB is unavailable.
Each caller is responsible for calling db.close() when done.
def _get_state_db(profile: str=None):
"""Get a SessionDB instance for a profile's state.db.
When ``profile`` is provided the function resolves *that* profile's
home directory directly (via ``_resolve_profile_home_for_name``).
If resolution fails (unknown profile name, IO error, etc.) the
function returns ``None`` rather than silently falling back to
``HERMES_HOME`` silently routing the write to the wrong DB
would defeat the point of the explicit-profile path (#2762).
When ``profile`` is None it falls back to the TLS-based
``get_active_hermes_home()`` lookup for backward compatibility,
with a final ``HERMES_HOME`` fallback only on that path. TLS may be
unset in background/worker threads, in which case the lookup falls
through to the process-global active profile and can write to the
wrong DB. Callers that know the session's profile (e.g.
``sync_session_usage`` after a stream completes on a background
thread) should pass it explicitly to avoid that race.
Returns None if hermes_state is not importable, the explicit
profile cannot be resolved, or the DB is unavailable. Each caller
is responsible for calling db.close() when done.
"""
try:
from hermes_state import SessionDB
except ImportError:
return None
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
logger.debug("Failed to resolve hermes home, using default")
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
if profile is not None:
# Explicit-profile path — a resolution failure here MUST NOT
# silently fall back to HERMES_HOME or the caller's "write to
# the named profile" contract is broken (the original #2762
# symptom: writes leaking into the wrong profile's state.db).
#
# Defense-in-depth (per #2827 maintainer review): validate the
# name shape BEFORE handing it to ``_resolve_profile_home_for_name``.
# The resolver itself rarely raises — for an invalid-but-non-
# malicious name (e.g. one that fails ``_PROFILE_ID_RE``) it
# quietly returns ``_DEFAULT_HERMES_HOME``, which is the exact
# leak we're trying to prevent on the explicit-profile path.
# Validating up-front turns that quiet leak into an explicit
# "refuse + log + return None" so the contract is "write to
# the EXACT named profile, or write nowhere."
try:
from api.profiles import (
_resolve_profile_home_for_name,
_PROFILE_ID_RE,
_is_root_profile,
)
if not (_is_root_profile(profile) or _PROFILE_ID_RE.fullmatch(profile)):
logger.warning(
"state_sync: refusing invalid profile name %r — skipping "
"write rather than leaking to the default state.db (#2762).",
profile,
)
return None
hermes_home = Path(_resolve_profile_home_for_name(profile)).expanduser().resolve()
except Exception:
logger.warning(
"state_sync: could not resolve profile %r — skipping write rather "
"than leaking to the active profile (#2762).", profile,
)
return None
else:
# Implicit / TLS-fallback path — preserves pre-#2762 behavior
# for any caller that doesn't pass profile= explicitly.
try:
from api.profiles import get_active_hermes_home
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
except Exception:
logger.debug("Failed to resolve hermes home, using default")
hermes_home = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes')))
db_path = hermes_home / 'state.db'
if not db_path.exists():
@@ -48,11 +104,16 @@ def _get_state_db():
return None
def sync_session_start(session_id: str, model=None) -> None:
def sync_session_start(session_id: str, model=None, profile: str=None) -> None:
"""Register a WebUI session in state.db (idempotent).
Called when a session's first message is sent.
``profile`` lets the caller name the target state.db explicitly,
avoiding the TLS-vs-background-thread mismatch in #2762. When
omitted, the active profile is resolved from TLS (then process
globals) as before.
"""
db = _get_state_db()
db = _get_state_db(profile=profile)
if not db:
return
try:
@@ -72,12 +133,20 @@ def sync_session_start(session_id: str, model=None) -> None:
def sync_session_usage(session_id: str, input_tokens: int=0, output_tokens: int=0,
estimated_cost=None, model=None, title: str=None,
message_count: int=None) -> None:
message_count: int=None, profile: str=None) -> None:
"""Update token usage and title for a WebUI session in state.db.
Called after each turn completes. Uses absolute=True to set totals
(the WebUI Session already accumulates across turns).
``profile`` lets the caller name the target state.db explicitly,
which is what fixes #2762: this function is invoked from the
agent streaming worker thread, where the request-thread's TLS
profile context has not been propagated. Without an explicit
profile, the TLS lookup falls back to the process-global active
profile and writes the session's usage to the wrong state.db
(e.g. ``hiyuki``'s instead of the cookie-switched ``maiko``'s).
"""
db = _get_state_db()
db = _get_state_db(profile=profile)
if not db:
return
try:
+8 -10
View File
@@ -35,7 +35,7 @@ from api.config import (
load_settings,
)
from api.helpers import redact_session_data, _redact_text
from api.compression_anchor import visible_messages_for_anchor
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
from api.metering import meter
from api.run_journal import RunJournalWriter
from api.turn_journal import append_turn_journal_event_for_stream
@@ -2299,15 +2299,7 @@ def _dedupe_replayed_active_context(previous_context, result_messages):
def _is_context_compression_marker(msg):
if not isinstance(msg, dict):
return False
text = _message_text(msg.get('content', '')).lower()
return (
'context compaction' in text
or 'context compression' in text
or 'context was auto-compressed' in text
or 'active task list was preserved across context compression' in text
)
return is_context_compression_marker(msg)
def _compact_summary_text(raw_text: str | None, limit: int = 320) -> str | None:
@@ -5078,6 +5070,12 @@ def _run_agent_streaming(
model=model,
title=s.title,
message_count=len(s.messages),
# #2762: pass the session's profile explicitly so the
# background-thread state.db lookup doesn't fall
# through to the process-global active profile and
# write to the wrong DB (TLS profile is set on the
# HTTP thread but not propagated to this worker).
profile=getattr(s, 'profile', None),
)
except Exception:
logger.debug("Failed to sync session to insights")
+11 -13
View File
@@ -70,18 +70,17 @@ _TERMINALS: dict[str, TerminalSession] = {}
_LOCK = threading.RLock()
def _terminal_shell_preexec_fn() -> None:
"""Ask Linux to terminate the PTY shell when the WebUI parent dies."""
try:
import ctypes
libc = ctypes.CDLL(None)
libc.prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG=1, SIGTERM=15
except Exception:
# Non-Linux platforms or restricted runtimes should still be able to
# open an embedded terminal; they just do not get the Linux pdeathsig
# hardening.
pass
# NOTE on parent-death-signal: a previous version of this module set
# PR_SET_PDEATHSIG via a preexec_fn to terminate orphaned PTY shells when the
# WebUI process crashed. That broke every Linux user (#2853): WebUI runs a
# ThreadingHTTPServer, so the Popen call happens on a short-lived per-request
# thread, and PR_SET_PDEATHSIG is per-thread. The PTY shell registered the
# spawning thread as its "parent" and was killed with SIGTERM the instant that
# thread joined — within ~10 ms of opening the terminal — surfacing as the
# `[terminal closed]` banner. The graceful path is covered by
# `atexit.register(close_all_terminals)` and the explicit `close_terminal`
# call sites; hard kills of the WebUI process leak the shell, which is the
# tradeoff for working on Linux at all.
def _decode_terminal_output(decoder, data: bytes) -> str:
@@ -193,7 +192,6 @@ def start_terminal(session_id: str, workspace: Path, rows: int = 24, cols: int =
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
preexec_fn=_terminal_shell_preexec_fn,
start_new_session=True,
)
os.close(slave_fd)
+61 -5
View File
@@ -29,7 +29,7 @@ try:
except ImportError:
_AGENT_DIR = None
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0}
_update_cache = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
_SUMMARY_CACHE_MAX = 16
_summary_cache: OrderedDict = OrderedDict()
_cache_lock = threading.Lock()
@@ -351,6 +351,21 @@ def _release_gap(tags, current, latest):
return 1
def _head_is_past_latest_tag(path, current_tag):
"""Return True when HEAD has moved past the latest reachable release tag.
`git describe --tags --always` returns the bare tag name (e.g. ``v2026.5.16``)
when HEAD is exactly on the tag, and a ``v2026.5.16-608-g1d22b9c2`` suffix
when HEAD has moved 608 commits past it. Used by both the update check and
the update apply path so they agree on which ref to advance to see #2653
(check side) and #2846 (apply side).
"""
if not current_tag:
return False
full_desc, ok = _run_git(['describe', '--tags', '--always'], path)
return bool(ok and full_desc and full_desc != current_tag)
def _select_apply_compare_ref(path):
"""Return the same remote ref family that the update check reports.
@@ -358,10 +373,31 @@ def _select_apply_compare_ref(path):
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.
When HEAD is past the latest tag (the agent repo's day-to-day state between
tagged releases), the check side falls through to the branch comparison via
`_check_repo_release` returning None. The apply side must mirror that
decision otherwise we run `git pull --ff-only <latest-tag>` against a
checkout that's already past the tag, no-op, restart, and the banner
re-appears with the same N commits available. See #2846.
"""
tags = _release_tags(path)
if tags:
return tags[0]
latest_tag = tags[0]
current_tag = _current_release_tag(path)
behind = _release_gap(tags, current_tag, latest_tag)
# Mirror the check side exactly: only fall through when behind == 0
# AND HEAD has moved past its nearest tag (case A: bench between
# tagged releases). Otherwise the tag is correct — including the
# case where HEAD is on an older release tag with commits on top
# AND a newer tag exists (case D), where `behind > 0` means the
# user is genuinely behind the latest release and should advance
# to it. Pre-#2855 the apply path only consulted `latest_tag`
# without the `behind`/`current_tag` predicate, so case D fell
# through to `origin/<branch>` and the pull landed past the
# advertised tag. See #2846 + Opus pre-release review for #2855.
if not (behind == 0 and _head_is_past_latest_tag(path, current_tag)):
return latest_tag
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
if ok and upstream:
@@ -381,6 +417,15 @@ def _check_repo_release(path, name):
current_tag = _current_release_tag(path)
behind = _release_gap(tags, current_tag, latest_tag)
# If behind == 0 but HEAD has moved past the tag (e.g. the agent repo
# keeps committing to master between tagged releases), the release check
# would report "Up to date" even though hundreds of commits are missing.
# Fall through to _check_repo_branch so the real commit count is reported
# instead. The same predicate is used by _select_apply_compare_ref so the
# check and apply sides cannot drift again. See #2653 (check), #2846 (apply).
if behind == 0 and _head_is_past_latest_tag(path, current_tag):
return None
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
remote_url = _normalize_remote_url(remote_url)
@@ -509,11 +554,21 @@ def _check_repo(path, name):
return _check_repo_branch(path, name, fetch=False)
def check_for_updates(force=False):
def _ignored_agent_update_info() -> dict:
"""Return a stable update-check payload for intentionally ignored Agent updates."""
return {'name': 'agent', 'behind': 0, 'ignored': True}
def check_for_updates(force=False, *, include_agent=True):
"""Return cached update status for webui and agent repos."""
global _check_in_progress
include_agent = bool(include_agent)
with _cache_lock:
if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL:
if (
not force
and _update_cache.get('include_agent') == include_agent
and time.time() - _update_cache['checked_at'] < CACHE_TTL
):
return dict(_update_cache)
if _check_in_progress:
return dict(_update_cache) # another thread is already checking
@@ -522,12 +577,13 @@ def check_for_updates(force=False):
try:
# Run checks outside the lock (network I/O)
webui_info = _check_repo(REPO_ROOT, 'webui')
agent_info = _check_repo(_AGENT_DIR, 'agent')
agent_info = _check_repo(_AGENT_DIR, 'agent') if include_agent else _ignored_agent_update_info()
with _cache_lock:
_update_cache['webui'] = webui_info
_update_cache['agent'] = agent_info
_update_cache['checked_at'] = time.time()
_update_cache['include_agent'] = include_agent
return dict(_update_cache)
finally:
_check_in_progress = False
+7
View File
@@ -46,6 +46,13 @@ That's it for a real personal Docker install. Your existing `~/.hermes`
directory is mounted, your `~/workspace` is browsable, and the WebUI
auto-detects your UID/GID from the mounted volume.
The single-container setup runs the WebUI only. It can create cron jobs and run
them manually from the Tasks panel. In Docker, scheduled jobs require the Hermes gateway daemon
to tick while you are away. If System Settings shows `Gateway not configured`,
use `docker-compose.two-container.yml`,
`docker-compose.three-container.yml`, or run `hermes gateway` separately before
relying on offline scheduled runs.
For troubleshooting, reinstall, or onboarding reproduction trials, do not mount
your real `~/.hermes` unless you intentionally want to test real state. Use an
isolated Hermes home and follow
+59 -7
View File
@@ -4,7 +4,7 @@
- **Author:** @Michaelyklam
- **Updated by:** @franksong2702
- **Created:** 2026-05-11
- **Revised:** 2026-05-21
- **Revised:** 2026-05-23
- **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-21
## Current Gate State — 2026-05-23
Slice 1 is now past the first active validation gate:
@@ -104,11 +104,14 @@ adapter-seam work:
`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.
- #2744 shipped the Slice 4d supervised runner route gate in v0.51.108.
- The next implementation slice is a default-off runner route-selection harness
for `/api/chat/start`. It should only engage when `runner-local` is explicitly
selected, return a bounded not-configured error until a supervised runner
client exists, keep `legacy-direct` / `legacy-journal` 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
@@ -843,6 +846,10 @@ Non-goals for Slice 4c:
#### Slice 4d: Supervised runner backend route gate
Status as of 2026-05-23: shipped in v0.51.108 via #2744. The gate remains a
docs/test contract: it defines the default-off route-selection requirements but
does not itself route live chat to a runner backend.
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
@@ -896,6 +903,51 @@ Non-goals for Slice 4d:
- no broad UI/product surface migration; WebUI remains the rich workbench while
only execution ownership moves.
#### Slice 4e: Default-off runner chat-start route-selection harness
The first implementation after the Slice 4d gate should wire the
`/api/chat/start` selection point to the existing `RuntimeAdapter` factory
without adding a supervised runner process yet. The harness must make the
selection behavior explicit: `legacy-direct` stays default, `legacy-journal`
continues to delegate to the legacy in-process stream path, and `runner-local`
does not silently fall back to legacy when no runner client is configured.
Scope:
- route `/api/chat/start` through `build_runtime_adapter(...)` when an adapter
mode is explicitly selected;
- keep the successful browser response whitelisted to legacy-compatible fields
such as `stream_id`, `session_id`, `pending_started_at`, `turn_id`, `title`,
and effective model/provider metadata;
- return a bounded not-configured error for `runner-local` until a supervised
runner client/backend lands;
- pass the existing explicit `StartRunRequest` payload fields across the seam.
Acceptance tests for Slice 4e:
1. **Default remains legacy-direct.** With no adapter env var, `/api/chat/start`
keeps using `_start_chat_stream_for_session(...)` directly.
2. **Legacy-journal remains behavior-preserving.** The flagged legacy adapter
still delegates to the same stream-start helper and preserves the public
response shape.
3. **Runner-local does not fallback silently.** If `runner-local` is selected but
no runner client exists, the route returns a bounded error instead of starting
a WebUI-owned legacy run behind the operator's back.
4. **No adapter-internal response drift.** `run_id`, `status`, and
`active_controls` remain internal until a later contract explicitly exposes
them.
5. **No runtime-surrogate globals.** The harness does not add runner-owned stream,
cancel, approval, clarify, cached-agent, goal, or queue maps to the main WebUI
process.
Non-goals for Slice 4e:
- no supervised runner process yet;
- no default-on runner mode;
- no execution-survives-WebUI-restart claim for production chat turns;
- no removal of `legacy-direct` or `legacy-journal`;
- no server-side queue endpoint or queue scheduler just for adapter symmetry.
## First Meaningful Success Criteria
The first meaningful milestones are deliberately split.
+22 -3
View File
@@ -115,7 +115,7 @@ from api.auth import check_auth
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
from api.helpers import j, get_profile_cookie
from api.profiles import set_request_profile, clear_request_profile
from api.routes import handle_delete, handle_get, handle_patch, handle_post
from api.routes import handle_delete, handle_get, handle_patch, handle_post, handle_put
from api.startup import auto_install_agent_deps, fix_credential_permissions
from api.updates import WEBUI_VERSION
@@ -170,6 +170,13 @@ class QuietHTTPServer(ThreadingHTTPServer):
class Handler(BaseHTTPRequestHandler):
# HTTP/1.1 enables keep-alive connection reuse — major latency win on
# high-RTT links where every saved TCP handshake is 2×RTT. Each response
# MUST declare framing (Content-Length, Transfer-Encoding: chunked, or
# Connection: close) so the client knows where the message ends. Helpers
# j()/t() emit Content-Length; SSE/streaming endpoints emit
# Connection: close because the body has no terminator. See PR notes.
protocol_version = "HTTP/1.1"
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
def setup(self):
@@ -232,8 +239,8 @@ class Handler(BaseHTTPRequestHandler):
duration_ms = round((time.time() - getattr(self, '_req_t0', time.time())) * 1000, 1)
record = _json.dumps({
'ts': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'method': self.command or '-',
'path': self.path or '-',
'method': getattr(self, 'command', None) or '-',
'path': getattr(self, 'path', None) or '-',
'status': int(code) if str(code).isdigit() else code,
'ms': duration_ms,
})
@@ -286,9 +293,21 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
self._handle_write(handle_post)
def do_PUT(self) -> None:
self._handle_write(handle_put)
def do_PATCH(self) -> None:
self._handle_write(handle_patch)
def do_OPTIONS(self) -> None:
"""Handle CORS preflight requests."""
self._req_t0 = time.time()
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.end_headers()
def do_DELETE(self) -> None:
self._handle_write(handle_delete)
+206
View File
@@ -0,0 +1,206 @@
<#
.SYNOPSIS
Native Windows launcher for Hermes WebUI - PowerShell equivalent
of start.sh, bypassing bootstrap.py's platform refusal.
.DESCRIPTION
Mirrors start.sh's discovery: load optional .env, find Python,
locate the hermes-agent install, set sensible env defaults, then
invoke server.py directly. The bootstrap.py path is skipped
because it currently raises on platform.system() == 'Windows';
server.py itself runs cleanly on native Windows.
Assumes Python + hermes-agent + the WebUI Python deps are already
installed natively on Windows - same assumption start.sh makes
when invoked outside a fresh bootstrap. For first-time setup, the
native Windows path is to install Python 3.11+, then create a
Windows venv (`python -m venv venv`) and `pip install -r
requirements.txt` from the hermes-agent root in PowerShell - this
script then finds `venv\Scripts\python.exe` automatically. A venv
created inside WSL2 is a Linux virtual environment (`venv/bin/python`)
and cannot be used by native Windows Python, so the bootstrap.py-
inside-WSL2 path produces a venv `start.ps1` can't invoke.
.PARAMETER Port
TCP port the WebUI binds to. Overrides HERMES_WEBUI_PORT env.
Default: 8787.
.PARAMETER BindHost
Bind address. Overrides HERMES_WEBUI_HOST env.
Default: 127.0.0.1.
.EXAMPLE
.\start.ps1
# Bind to 127.0.0.1:8787, foreground.
.EXAMPLE
.\start.ps1 -Port 9000
# Bind to 127.0.0.1:9000.
.EXAMPLE
$env:HERMES_WEBUI_HOST = '0.0.0.0'
.\start.ps1
# Bind to all interfaces (set a password first via env or Settings).
.LINK
https://github.com/nesquena/hermes-webui/issues/1952
#>
[CmdletBinding()]
param(
[int]$Port = 0,
[string]$BindHost = ''
)
$ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path -Parent $PSCommandPath
# === Load .env (mirroring start.sh's filtering) ========================
$envFile = Join-Path $RepoRoot '.env'
if (Test-Path $envFile) {
foreach ($line in Get-Content $envFile -Encoding UTF8) {
$trimmed = $line.Trim()
if (-not $trimmed -or $trimmed.StartsWith('#') -or -not $trimmed.Contains('=')) { continue }
$kv = $trimmed -split '=', 2
$key = ($kv[0].Trim() -replace '^export\s+', '')
# Filter out shell-readonly vars (UID, GID, EUID, EGID, PPID) per start.sh
if ($key -in @('UID', 'GID', 'EUID', 'EGID', 'PPID')) { continue }
if ($key -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { continue }
# Explicit $null check — an env var explicitly set to '' should still
# be considered "set" and NOT overridden by .env (empty string is
# falsey in PowerShell, so a plain truthy check would mis-skip).
if ($null -ne [Environment]::GetEnvironmentVariable($key)) { continue }
$val = $kv[1]
if ($val -match '^"(.*)"$') { $val = $Matches[1] }
elseif ($val -match "^'(.*)'$") { $val = $Matches[1] }
[Environment]::SetEnvironmentVariable($key, $val)
}
}
# === Find Python (matches start.sh order) ==============================
$Python = $env:HERMES_WEBUI_PYTHON
if (-not $Python) {
foreach ($candidate in @('python3', 'python', 'py')) {
$cmd = Get-Command $candidate -ErrorAction SilentlyContinue
if ($cmd) { $Python = $cmd.Source; break }
}
}
if (-not $Python) {
Write-Error 'Python 3 is required to run server.py (set HERMES_WEBUI_PYTHON or add python to PATH).'
exit 1
}
# === Find Hermes Agent dir (server.py imports from it) =================
# When HERMES_WEBUI_AGENT_DIR is set we still validate it on disk —
# an explicit override pointing at a missing dir should fail FAST
# with a clear message, not silently progress into a python3 launch
# that's about to crash on missing imports. Smoke-test feedback on
# PR #2783: nesquena/hermes-webui requested this guard.
$AgentDir = $env:HERMES_WEBUI_AGENT_DIR
if ($AgentDir -and -not (Test-Path (Join-Path $AgentDir 'hermes_cli') -PathType Container)) {
Write-Error "HERMES_WEBUI_AGENT_DIR is set to '$AgentDir' but no hermes_cli/ folder exists there. Unset the variable to fall back to auto-discovery, or fix the path."
exit 1
}
if (-not $AgentDir) {
# Build candidate list incrementally — ${env:ProgramFiles(x86)} is null on
# 32-bit Windows and in some constrained environments, and Join-Path throws
# on a null Path. Skip any system-wide root that isn't set so the launcher
# stays robust across Windows variants. USERPROFILE is always set so it
# stays unguarded; the dev-checkout sibling is path-derived, not env-based.
$candidates = @()
$candidates += (Join-Path $env:USERPROFILE '.hermes\hermes-agent')
foreach ($root in @($env:LOCALAPPDATA, ${env:ProgramW6432}, ${env:ProgramFiles}, ${env:ProgramFiles(x86)})) {
if ($root) { $candidates += (Join-Path $root 'hermes\hermes-agent') }
}
$candidates += (Join-Path (Split-Path -Parent $RepoRoot) 'hermes-agent')
# De-dup: when running in a WOW64 (32-bit-on-64-bit) PowerShell process,
# $env:ProgramFiles is redirected to C:\Program Files (x86), so without
# $env:ProgramW6432 (the canonical 64-bit override) we'd miss the real
# C:\Program Files\hermes\hermes-agent AND duplicate the x86 entry.
# Select-Object -Unique collapses any collisions regardless of cause.
$candidates = $candidates | Select-Object -Unique
foreach ($c in $candidates) {
if (Test-Path (Join-Path $c 'hermes_cli') -PathType Container) { $AgentDir = $c; break }
}
}
if (-not $AgentDir) {
$searched = $candidates -join ', '
Write-Error "hermes-agent not found. Searched: $searched. Set HERMES_WEBUI_AGENT_DIR explicitly to override."
exit 1
}
# === Prefer the agent's venv Python if available =======================
$agentVenvPython = Join-Path $AgentDir 'venv\Scripts\python.exe'
if (Test-Path $agentVenvPython) {
$Python = $agentVenvPython
}
# === Resolve bind + state defaults =====================================
$BindHostFinal = if ($BindHost) { $BindHost } elseif ($env:HERMES_WEBUI_HOST) { $env:HERMES_WEBUI_HOST } else { '127.0.0.1' }
$PortFinal = if ($Port) {
$Port
} elseif ($env:HERMES_WEBUI_PORT) {
# TryParse + range guard on the env var. A plain [int] cast on the
# env var throws InvalidCastException with no actionable context when
# the env var is set to a non-integer (typo, accidental shell
# expansion, etc.) — surface a targeted error message instead.
$parsedPort = 0
if (-not [int]::TryParse($env:HERMES_WEBUI_PORT, [ref]$parsedPort)) {
Write-Error "HERMES_WEBUI_PORT='$($env:HERMES_WEBUI_PORT)' is not a valid integer port. Unset the variable to use the default (8787), or set it to a number 1-65535."
exit 1
}
if ($parsedPort -lt 1 -or $parsedPort -gt 65535) {
Write-Error "HERMES_WEBUI_PORT=$parsedPort is out of TCP-port range. Must be 1-65535."
exit 1
}
$parsedPort
} else {
8787
}
$env:HERMES_WEBUI_HOST = $BindHostFinal
$env:HERMES_WEBUI_PORT = "$PortFinal"
if (-not $env:HERMES_WEBUI_STATE_DIR) {
$env:HERMES_WEBUI_STATE_DIR = Join-Path $env:USERPROFILE '.hermes\webui'
}
if (-not $env:HERMES_HOME) {
$env:HERMES_HOME = Join-Path $env:USERPROFILE '.hermes'
}
# === Ensure dirs exist =================================================
New-Item -ItemType Directory -Force -Path $env:HERMES_HOME | Out-Null
New-Item -ItemType Directory -Force -Path $env:HERMES_WEBUI_STATE_DIR | Out-Null
# === Launch (foreground, matches start.sh) =============================
Write-Host "[start.ps1] Hermes WebUI native Windows launcher" -ForegroundColor Cyan
Write-Host "[start.ps1] Python: $Python"
Write-Host "[start.ps1] Agent dir: $AgentDir"
Write-Host "[start.ps1] State dir: $env:HERMES_WEBUI_STATE_DIR"
Write-Host "[start.ps1] Binding: ${BindHostFinal}:${PortFinal}"
Write-Host ""
$serverPath = Join-Path $RepoRoot 'server.py'
if (-not (Test-Path $serverPath)) {
Write-Error "server.py not found at $serverPath - is this the hermes-webui repo root?"
exit 1
}
# Capture exit code, let finally{} run Pop-Location, exit AFTER the try.
# Plain `exit $LASTEXITCODE` inside the try block can prevent the finally
# from running in some termination paths (especially when dot-sourced or
# in interactive sessions), leaving the caller's working directory stuck
# at $RepoRoot.
$script:serverExitCode = 0
Push-Location $RepoRoot
try {
# @args was non-functional here — PowerShell does NOT populate $args when the
# script declares [CmdletBinding()] with an explicit param() block (Copilot's
# finding on PR #2807). Dropped rather than added a ValueFromRemainingArguments
# parameter, because the existing tracked use case is the launcher running
# server.py with the env-var-driven config — no pass-through args are needed.
# If pass-through becomes a requirement later, add a [Parameter(ValueFromRemainingArguments=$true)] [string[]]$ServerArgs and splat that.
& $Python $serverPath
$script:serverExitCode = $LASTEXITCODE
} finally {
Pop-Location
}
exit $script:serverExitCode
+22 -8
View File
@@ -1238,6 +1238,7 @@ const _SKINS=[
{name:'Charizard',colors:['#FB923C','#F97316','#EA580C']},
{name:'Sienna', colors:['#D97757','#C06A49','#9A523A']},
{name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']},
{name:'Hepburn', colors:['#c6246a','#ec5597','#f2abca']},
{name:'Nous', colors:['#4682B4','#3A6E9A','#2C5F88']},
{name:'Geist Contrast', value:'geist-contrast', colors:['#000000','#ffffff','#FFF175']},
];
@@ -1476,12 +1477,8 @@ function applyBotName(){
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');
}
// own persisted model later through loadSession(). Preserve the browser
// keys for legacy/no-default fallback paths instead of deleting them.
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;
@@ -1589,7 +1586,7 @@ function applyBotName(){
// Fetch available models without blocking session restore. The static HTML
// options are enough for first paint; the dynamic provider list can settle
// after the saved session is visible.
const _hydrateBootModelDropdown=()=>populateModelDropdown().then(()=>{
const _hydrateBootModelDropdown=()=>populateModelDropdown({preferProfileDefaultOnFreshBoot:true}).then(()=>{
const sessionModelState=S.session&&S.session.model
? {model:S.session.model,model_provider:S.session.model_provider||null}
: null;
@@ -1623,7 +1620,10 @@ function applyBotName(){
else if(typeof syncModelChip==='function') syncModelChip();
}
if(S.session) syncTopbar();
}).catch(()=>{});
}).catch(e=>{
window._modelDropdownReady=null;
throw e;
});
const _startBootModelDropdown=()=>{
const ready=window._modelDropdownReady;
if(ready&&typeof ready.then==='function') return ready;
@@ -1633,6 +1633,9 @@ function applyBotName(){
};
window._modelDropdownReady=null;
window._ensureModelDropdownReady=_startBootModelDropdown;
setTimeout(()=>{
try{Promise.resolve(_startBootModelDropdown()).catch(()=>{});}catch(_){}
},0);
// Start independent boot fetches without holding the conversation list behind
// them. The sidebar can render from /api/sessions while workspace/onboarding
// metadata settles in parallel.
@@ -1655,6 +1658,17 @@ function applyBotName(){
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function')
? window.HermesPWA.launchAction()
: null;
if(pwaLaunchAction==='new-chat'){
try{
await newSession(true);
if(S.session) await _startBootModelDropdown();
S._bootReady=true;
syncTopbar();syncWorkspacePanelState();await renderSessionList();if(typeof startGatewaySSE==='function')startGatewaySSE();return;
}catch(e){console.warn('[pwa] new-chat launch action failed', e);}
}
const savedLocal=localStorage.getItem('hermes-webui-session');
const saved=urlSession||savedLocal;
if(saved){
+1555 -40
View File
File diff suppressed because it is too large Load Diff
+27 -3
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Hermes</title>
<!-- base href enables subpath mount support; all static paths must stay relative (no leading slash).
MUST appear before manifest/favicon links so browsers resolve relative URLs against the
@@ -17,7 +17,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Hermes">
<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,nous:1,'geist-contrast':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,hepburn:1,nous:1,'geist-contrast':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<!-- theme-color: surfaces the active app chrome color to native status bars (Safari status bar, PWA, native WKWebView wrappers). Updated dynamically by boot.js when theme/skin changes. The light/dark default values match style.css :root --sidebar / :root.dark --sidebar. -->
<meta name="theme-color" content="#FAF7F0" media="(prefers-color-scheme: light)">
@@ -26,6 +26,8 @@
<script>(function(){try{var t=localStorage.getItem('hermes-theme')||'dark';if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';var c=t==='dark'?'#141425':'#FAF7F0';document.querySelectorAll('meta[name="theme-color"]').forEach(function(m){m.setAttribute('content',c);m.removeAttribute('media');});}catch(e){}})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<script>(function(){try{if(localStorage.getItem('hermes-webui-sidebar-collapsed')==='1')document.documentElement.dataset.sidebarCollapsed='1';}catch(e){}})()</script>
<link rel="preload" href="static/pwa-startup.js?v=__WEBUI_VERSION__" as="script">
<script src="static/pwa-startup.js?v=__WEBUI_VERSION__"></script>
<script>window.__HERMES_CONFIG__={maxUploadBytes:__MAX_UPLOAD_BYTES__,csrfToken:__CSRF_TOKEN_JSON__};</script>
<script>(function(){
var cfg=window.__HERMES_CONFIG__||{},token=cfg.csrfToken||'';
@@ -205,6 +207,7 @@
<button class="panel-head-btn has-tooltip has-tooltip--bottom" onclick="openCronCreate()" data-tooltip="New job" data-i18n-title="new_job" aria-label="New job"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
</div>
<div id="cronGatewayNotice" class="detail-alert cron-gateway-notice" style="display:none"></div>
<div class="cron-list" id="cronList"><div style="padding:12px;color:var(--muted);font-size:12px" data-i18n="loading">Loading...</div></div>
</div>
<!-- Kanban panel -->
@@ -996,6 +999,15 @@
<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 data-i18n="settings_label_auxiliary_models">Auxiliary Models</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:10px" data-i18n="settings_desc_auxiliary_models">Side-task routing for vision, compression, title generation, etc. "Auto" uses your main chat model.</div>
<div id="auxModelsContainer"></div>
<div style="margin-top:8px;display:flex;gap:8px">
<button type="button" id="btnResetAuxModels" class="settings-btn" style="font-size:12px;padding:4px 12px;border-radius:6px" data-i18n="settings_btn_reset_aux_models">Reset all to auto</button>
<button type="button" id="btnApplyAuxModels" class="settings-btn" style="font-size:12px;padding:4px 12px;border-radius:6px;display:none" data-i18n="settings_btn_apply_aux_models">Apply changes</button>
</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)">
@@ -1179,6 +1191,13 @@
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_check_updates">Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsIgnoreAgentUpdates" style="width:15px;height:15px;accent-color:var(--accent)">
<span data-i18n="settings_label_ignore_agent_updates">Ignore Agent updates</span>
</label>
<div style="font-size:11px;color:var(--muted);margin-top:4px" data-i18n="settings_desc_ignore_agent_updates">Keep WebUI update checks on, but hide Agent update notices and skip Agent update fetches.</div>
</div>
<div class="settings-field">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="settingsWhatsNewSummary" style="width:15px;height:15px;accent-color:var(--accent)">
@@ -1300,8 +1319,13 @@
<button class="panel-icon-btn close-preview has-tooltip has-tooltip--bottom" id="btnClearPreview" data-tooltip="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
</div>
<div class="workspace-panel-tabs" role="tablist" aria-label="Workspace panel views">
<button class="workspace-panel-tab active" id="workspaceFilesTab" type="button" onclick="switchWorkspacePanelTab('files')" role="tab" aria-selected="true">Files</button>
<button class="workspace-panel-tab" id="workspaceArtifactsTab" type="button" onclick="switchWorkspacePanelTab('artifacts')" role="tab" aria-selected="false">Artifacts <span id="workspaceArtifactsCount" class="workspace-artifacts-count">0</span></button>
</div>
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
<div class="file-tree" id="fileTree"></div>
<div class="workspace-artifacts" id="workspaceArtifacts" hidden></div>
<div id="wsEmptyState" style="display:none;flex:1;align-items:center;justify-content:center;padding:24px 16px;text-align:center;color:var(--muted);font-size:12px;line-height:1.6"></div>
<div class="preview-area" id="previewArea">
<div class="preview-path" id="previewPath">
@@ -1319,7 +1343,7 @@
</div>
<div class="preview-md" id="previewMd" style="display:none"></div>
<div class="preview-html-wrap" id="previewHtmlWrap" style="display:none;flex:1;border-radius:8px;overflow:hidden;border:1px solid var(--border2)">
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts" title="HTML preview"></iframe>
<iframe id="previewHtmlIframe" style="width:100%;height:100%;border:none;background:#fff" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox" title="HTML preview"></iframe>
</div>
<textarea id="previewEditArea" style="display:none;flex:1;width:100%;background:var(--code-bg);color:var(--pre-text);border:1px solid var(--border2);border-radius:8px;padding:12px;font-family:'SF Mono',ui-monospace,monospace;font-size:12px;line-height:1.6;resize:none;outline:none" oninput="_previewDirty=true;updateEditBtn()"></textarea>
</div>
+20 -1
View File
@@ -1,12 +1,31 @@
{
"id": "./",
"name": "Hermes",
"short_name": "Hermes",
"description": "Hermes AI Agent Web UI",
"start_url": "./",
"start_url": "./?source=pwa",
"scope": "./",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"background_color": "#0D0D1A",
"theme_color": "#0D0D1A",
"orientation": "portrait-primary",
"categories": ["productivity", "utilities"],
"shortcuts": [
{
"name": "New conversation",
"short_name": "New chat",
"description": "Open Hermes ready for a new chat",
"url": "./?source=pwa&action=new-chat",
"icons": [
{
"src": "static/favicon-192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}
],
"icons": [
{
"src": "static/favicon.svg",
+177 -37
View File
@@ -190,6 +190,42 @@ let _sendInProgress = false;
let _sendInProgressSid = null; // session_id of the in-flight send
const _sessionTitleProvisionalBySid = new Map();
function _clearStaleBusyStateBeforeSend({compressionRunning=false}={}){
if(!S||!S.busy||compressionRunning) return false;
const session=S.session||{};
const sid=session.session_id||'';
const hasRuntimeConfirmation=Boolean(
S.activeStreamId||
session.active_stream_id||
session.pending_user_message||
session.pending_started_at
);
if(hasRuntimeConfirmation) return false;
if(typeof INFLIGHT==='object'&&INFLIGHT&&sid&&INFLIGHT[sid]){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
}
S.activeStreamId=null;
if(session) session.active_stream_id=null;
if(typeof setBusy==='function') setBusy(false);
else S.busy=false;
if(typeof setComposerStatus==='function') setComposerStatus('');
if(typeof setStatus==='function') setStatus('');
if(typeof updateSendBtn==='function') updateSendBtn();
if(sid&&typeof clearOptimisticSessionStreaming==='function') clearOptimisticSessionStreaming(sid);
return true;
}
function _runOptionalPreStartUiStep(label, fn){
try{
return typeof fn==='function'?fn():undefined;
}catch(e){
const message=e&&e.message?e.message:String(e||'unknown error');
try{console.warn('[webui] optional pre-start UI step failed', label, message);}catch(_){ }
return undefined;
}
}
function _sessionTitleLooksDefaultOrProvisional(titleText, provisionalText){
const title=String(titleText||'').replace(/\s+/g,' ').trim();
if(!title||title==='Untitled'||title==='New Chat')return true;
@@ -262,6 +298,7 @@ async function send(){
}
const compressionRunning=typeof isCompressionUiRunning==='function'&&isCompressionUiRunning();
_clearStaleBusyStateBeforeSend({compressionRunning});
// If busy or a manual compression is still running, handle based on busy_input_mode
if(S.busy||compressionRunning){
if(text){
@@ -409,39 +446,68 @@ async function send(){
const userMsg={role:'user',content:displayText,attachments:uploaded.length?uploadedNames:undefined,_ts:Date.now()/1000};
S.toolCalls=[]; // clear tool calls from previous turn
clearLiveToolCards(); // clear any leftover live cards from last turn
S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);
// First optimistic pass: make the local user turn visible before /api/chat/start
// can save pending state on the server.
if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
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:[]});
}
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
startApprovalPolling(activeSid);
startClarifyPolling(activeSid);
_fetchYoloState(activeSid); // sync YOLO pill with backend state
S.activeStreamId = null; // will be set after stream starts
if(typeof updateSendBtn==='function') updateSendBtn();
// Set provisional title from user message immediately so session appears
// in the sidebar right away with a meaningful name. /api/chat/start persists
// the server-side provisional title and may refine this optimistic text.
if(S.session&&(S.session.title==='Untitled'||!S.session.title)){
const provisionalTitle=displayText.slice(0,64);
applySessionTitleUpdate(activeSid, provisionalTitle, {force:true, rememberProvisional:true});
if(typeof upsertActiveSessionForLocalTurn==='function'){
// Second optimistic pass: carry the provisional title into the cached row
// without re-fetching /api/sessions before pending state exists server-side.
upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()});
let optimisticMessages;
try{
S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);
// First optimistic pass: make the local user turn visible before /api/chat/start
// can save pending state on the server.
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.initial', ()=>{
if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
}
});
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:[]});
}
} else if(typeof upsertActiveSessionForLocalTurn==='function'){
upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
} else {
renderSessionListFromCache(); // ensure it's visible even if already titled
_runOptionalPreStartUiStep('renderSessionListFromCache.initial', ()=>{
if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();
});
_runOptionalPreStartUiStep('startApprovalPolling.prestart', ()=>startApprovalPolling(activeSid));
_runOptionalPreStartUiStep('startClarifyPolling.prestart', ()=>startClarifyPolling(activeSid));
_runOptionalPreStartUiStep('fetchYoloState.prestart', ()=>_fetchYoloState(activeSid)); // sync YOLO pill with backend state
S.activeStreamId = null; // will be set after stream starts
_runOptionalPreStartUiStep('updateSendBtn.prestart', ()=>{
if(typeof updateSendBtn==='function') updateSendBtn();
});
// Set provisional title from user message immediately so session appears
// in the sidebar right away with a meaningful name. /api/chat/start persists
// the server-side provisional title and may refine this optimistic text.
if(S.session&&(S.session.title==='Untitled'||!S.session.title)){
const provisionalTitle=displayText.slice(0,64);
_runOptionalPreStartUiStep('applySessionTitleUpdate.provisional', ()=>{
applySessionTitleUpdate(activeSid, provisionalTitle, {force:true, rememberProvisional:true});
});
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.provisional', ()=>{
if(typeof upsertActiveSessionForLocalTurn==='function'){
// Second optimistic pass: carry the provisional title into the cached row
// without re-fetching /api/sessions before pending state exists server-side.
upsertActiveSessionForLocalTurn({title:provisionalTitle,messageCount:S.messages.length,timestampMs:Date.now()});
}
});
} else if(typeof upsertActiveSessionForLocalTurn==='function'){
_runOptionalPreStartUiStep('upsertActiveSessionForLocalTurn.titled', ()=>{
upsertActiveSessionForLocalTurn({title:S.session&&S.session.title||displayText.slice(0,64),messageCount:S.messages.length,timestampMs:Date.now()});
});
} else {
_runOptionalPreStartUiStep('renderSessionListFromCache.prestart', ()=>{
renderSessionListFromCache(); // ensure it's visible even if already titled
});
}
}catch(preStartError){
// The user turn must reach /api/chat/start even if local optimistic UI
// bookkeeping (render cache, storage quota, sidebar reconciliation, etc.)
// throws. Otherwise the pane can show a user bubble + spinner while the
// backend never receives the turn.
const message=preStartError&&preStartError.message?preStartError.message:String(preStartError||'unknown error');
try{console.warn('[webui] pre-start optimistic UI failed; continuing to /api/chat/start', message);}catch(_){ }
if(!S.messages.includes(userMsg)) S.messages.push(userMsg);
optimisticMessages=[...S.messages];
INFLIGHT[activeSid]={messages:optimisticMessages,uploaded:uploadedNames,toolCalls:[]};
try{setBusy(true);}catch(_){S.busy=true;}
S.activeStreamId=null;
}
// Start the agent via POST, get a stream_id back
@@ -1285,6 +1351,20 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
};
step();
}
function _flushPendingSegmentRender(){
if(!assistantBody||!_renderPending) return;
_cancelAnimationFramePendingStreamRender();
const displayText=segmentStart===0
? _parseStreamState().displayText
: _stripXmlToolCalls(assistantText.slice(segmentStart));
if(_smdParser){
_smdWrite(displayText);
} else if(renderMd){
assistantBody.innerHTML=renderMd(displayText);
} else {
assistantBody.innerHTML=esc(displayText);
}
}
function _resetAssistantSegment(){
assistantRow=null;
assistantBody=null;
@@ -1410,6 +1490,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(!visible){
return;
}
reasoningText='';
liveReasoningText='';
if(alreadyStreamed){
if(!S.session||S.session.session_id!==activeSid) return;
_resetAssistantSegment();
@@ -1423,6 +1505,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof updateThinking==='function') updateThinking(_liveThinkingText());
else appendThinking(_liveThinkingText());
}
_flushPendingSegmentRender();
ensureAssistantRow(true);
_resetAssistantSegment();
_scheduleRender();
@@ -1460,6 +1543,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
S.toolCalls=INFLIGHT[activeSid].toolCalls;
persistInflightState();
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return;
// NOTE: don't removeThinking() here — keep the thinking card visible
// above the tool card so the turn reads top-to-bottom as:
@@ -1467,12 +1551,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
// to be re-created below everything when reasoning resumed post-tool.
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
liveReasoningText='';
reasoningText='';
const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove();
appendLiveToolCard(tc);
snapshotLiveTurn();
// Reset the live assistant row reference so that any text tokens arriving
// after this tool call create a NEW segment appended below the tool card,
// rather than updating the old segment that sits above it in the DOM.
_flushPendingSegmentRender();
_freshSegment=true;
_smdEndParser();
_resetAssistantSegment();
@@ -1504,6 +1590,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(d.duration!==undefined) tc.duration=d.duration;
S.toolCalls=inflight.toolCalls;
persistInflightState();
if(S.session&&S.session.session_id===activeSid&&typeof scheduleRenderSessionArtifacts==='function') scheduleRenderSessionArtifacts();
if(!S.session||S.session.session_id!==activeSid) return;
appendLiveToolCard(tc);
snapshotLiveTurn();
@@ -1597,6 +1684,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
});
source.addEventListener('done',e=>{
if(_streamFinalized) return;
_terminalStateReached=true;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
const _doneData=JSON.parse(e.data);
@@ -1707,11 +1795,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
}
}
if(d.session.tool_calls&&d.session.tool_calls.length){
const hasMessageToolMetadata=S.messages.some(m=>{
if(!m||m.role!=='assistant') return false;
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasPartialTc=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
return hasTc||hasPartialTc||hasTu;
});
if(!hasMessageToolMetadata&&d.session.tool_calls&&d.session.tool_calls.length){
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
} else {
S.toolCalls=S.toolCalls.map(tc=>({...tc,done:true}));
S.toolCalls=hasMessageToolMetadata?[]:S.toolCalls.map(tc=>({...tc,done:true}));
}
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
if(typeof _copyActivityDisclosureState==='function'&&lastAsst){
const assistantIdx=S.messages.indexOf(lastAsst);
if(assistantIdx>=0) _copyActivityDisclosureState('live:'+streamId, 'assistant:'+assistantIdx);
@@ -1767,12 +1863,31 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
_finishDone();
});
source.addEventListener('stream_end',e=>{
source.addEventListener('stream_end',async e=>{
if(_streamFinalized){
source.close();
return;
}
_terminalStateReached=true;
try{
const d=JSON.parse(e.data||'{}');
if((d.session_id||activeSid)!==activeSid) return;
}catch(_){}
// Some replay/journal paths can deliver stream_end without a preceding
// done event. In that case closing the EventSource is not enough: the
// live DOM/inflight state remains projected and can duplicate Thinking or
// assistant content until a later session switch. Settle from the persisted
// session before closing so the pane converges on canonical state.
if(await _restoreSettledSession()){
source.close();
return;
}
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
source.close();
});
@@ -2040,9 +2155,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
async function _restoreSettledSession(){
try{
const data=await api(`/api/session?session_id=${encodeURIComponent(activeSid)}`);
// Opus #2852 race-fix: if a late `done` event ran the finalize path while
// we were awaiting the network roundtrip, bail out — done already settled.
if(_streamFinalized) return true;
const session=data&&data.session;
if(!session) return false;
if(session.active_stream_id||session.pending_user_message) return false;
if(_persistTimer){clearTimeout(_persistTimer);_persistTimer=null;}
_streamFinalized=true;
_cancelAnimationFramePendingStreamRender();
_streamFadeCleanupReduceMotionListener();
_smdEndParser();
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
_clearOwnerInflightState();
_closeSource();
_clearApprovalForOwner();
@@ -2336,7 +2460,9 @@ function showApprovalCard(pending, pendingCount) {
card.classList.add("visible");
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
const onceBtn = $("approvalBtnOnce");
if (onceBtn) setTimeout(() => onceBtn.focus({preventScroll: true}), 50);
if (onceBtn && document.activeElement !== $('msg')) {
setTimeout(() => onceBtn.focus({preventScroll: true}), 50);
}
}
async function respondApproval(choice) {
@@ -2772,7 +2898,11 @@ function showClarifyCard(pending) {
card.classList.add("visible");
_syncClarifyCollapseButton(card);
if (typeof applyLocaleToDOM === "function") applyLocaleToDOM();
if (input && !sameClarify) setTimeout(() => input.focus({preventScroll: true}), 50);
// Move focus to clarify input synchronously (not in setTimeout) and
// only if the user wasn't mid-type in the composer textarea.
if (input && !sameClarify && document.activeElement !== $('msg')) {
input.focus({preventScroll: true});
}
}
async function respondClarify(response) {
@@ -2804,6 +2934,16 @@ async function respondClarify(response) {
_clarifyId = null;
_clearClarifyPendingForSession(sid);
hideClarifyCard(true, 'sent');
// Echo the user's clarify choice as a visible message in the conversation
if (S.session && S.session.session_id === sid) {
S.messages.push({
role: 'user',
content: value,
_clarify_response: true,
_ts: Date.now() / 1000,
});
if (typeof renderMessages === 'function') renderMessages({preserveScroll: true});
}
}
} else {
// Stale / expired / wrong session — keep the card and draft visible.
+460 -14
View File
@@ -428,9 +428,44 @@ function _cronDiagnostics(job) {
return JSON.stringify(fields, null, 2);
}
function _cronGatewayNoticeHtml(status) {
if (!status || (status.configured && status.running)) return '';
const notConfigured = !status.configured;
const title = notConfigured
? 'Gateway not configured'
: 'Gateway not running';
const body = notConfigured
? 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon. If this is a single-container Docker install, jobs can be created and run manually here, but scheduled ticks need a gateway container or `hermes gateway` running outside the WebUI.'
: 'In Hermes WebUI, scheduled jobs require the Hermes gateway daemon to be running. Start the gateway container or `hermes gateway` before relying on offline scheduled runs.';
return `
<div class="detail-alert-title">${esc(title)}</div>
<p>${esc(body)}</p>
`;
}
async function loadCronGatewayNotice() {
const box = $('cronGatewayNotice');
if (!box) return;
try {
const status = await api('/api/gateway/status');
const html = _cronGatewayNoticeHtml(status);
if (html) {
box.innerHTML = html;
box.style.display = '';
} else {
box.innerHTML = '';
box.style.display = 'none';
}
} catch (_) {
box.innerHTML = '';
box.style.display = 'none';
}
}
async function loadCrons(animate) {
const box = $('cronList');
const refreshBtn = $('cronRefreshBtn');
loadCronGatewayNotice();
if (animate && refreshBtn) {
refreshBtn.style.opacity = '0.5';
refreshBtn.disabled = true;
@@ -1239,17 +1274,188 @@ function _kanbanRenderSidebar(columns){
}
/**
* Render inline markdown (bold, italic, code, links, strikethrough).
* Input is already HTML-escaped.
*/
function _kanbanRenderMarkdownInline(escaped){
return String(escaped || '')
.replace(/~~([^~\n]+)~~/g, (_m, text) => `<del>${text}</del>`)
.replace(/`([^`\n]+)`/g, (_m, code) => `<code>${code}</code>`)
.replace(/\*\*([^*\n]+)\*\*/g, (_m, text) => `<strong>${text}</strong>`)
.replace(/(^|[^*])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
.replace(/(^|[^*a-zA-Z0-9])\*([^*\n]+)\*/g, (_m, prefix, text) => `${prefix}<em>${text}</em>`)
.replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`);
}
/**
* Render full markdown block content: headings, code blocks, lists, tables,
* task lists, blockquotes, horizontal rules, paragraphs + inline formatting.
*/
function _kanbanRenderMarkdown(source){
if (!source) return '';
return `<div class="hermes-kanban-md">${esc(source).split(/\r?\n/).map(line => line.trim() ? `<p>${_kanbanRenderMarkdownInline(line)}</p>` : '').join('')}</div>`;
const lines = esc(source).split(/\r?\n/);
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const trimmed = line.trim();
// ── Code block ──
if (/^```/.test(trimmed)) {
const lang = trimmed.slice(3).trim();
const codeLines = [];
i++;
while (i < lines.length && !/^```/.test(lines[i].trim())) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
const codeHtml = codeLines.join('\n');
out.push(lang
? `<pre class="hermes-kanban-code"><code class="language-${_kanbanRenderMarkdownInline(lang)}">${codeHtml}</code></pre>`
: `<pre class="hermes-kanban-code"><code>${codeHtml}</code></pre>`);
continue;
}
// ── Horizontal rule ──
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(trimmed)) {
out.push('<hr>');
i++;
continue;
}
// ── Heading ──
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
out.push(`<h${level}>${_kanbanRenderMarkdownInline(headingMatch[2])}</h${level}>`);
i++;
continue;
}
// ── Blockquote ──
if (/^>\s?/.test(trimmed)) {
const quoteLines = [];
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''));
i++;
}
out.push(`<blockquote>${_kanbanRenderMarkdownInline(quoteLines.join('<br>'))}</blockquote>`);
continue;
}
// ── Table row ──
if (/^\|.+\|$/.test(trimmed)) {
const tableRows = [];
const tableAligns = [];
while (i < lines.length && /^\|.+\|$/.test(lines[i].trim())) {
const row = lines[i].trim();
// Detect alignment separator row
if (/^\|[\s:]*-{3,}[\s:]*\|/.test(row)) {
const cells = row.split('|').filter(c => c.trim().length > 0);
cells.forEach(c => {
const t = c.trim();
if (t.startsWith(':') && t.endsWith(':')) tableAligns.push('center');
else if (t.endsWith(':')) tableAligns.push('right');
else tableAligns.push('left');
});
} else {
const cells = row.split('|').filter(c => c.trim().length > 0);
tableRows.push(cells.map((c, ci) => {
const align = tableAligns[ci] ? ` style="text-align:${tableAligns[ci]}"` : '';
return `<td${align}>${_kanbanRenderMarkdownInline(c.trim())}</td>`;
}).join(''));
}
i++;
}
if (tableRows.length) {
out.push(`<table><tbody>${tableRows.map(r => `<tr>${r}</tr>`).join('')}</tbody></table>`);
}
continue;
}
// ── Task list item ──
const taskMatch = trimmed.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
if (taskMatch) {
const checked = taskMatch[1] !== ' ';
const text = taskMatch[2];
const items = [];
items.push(`<li class="hermes-kanban-task${checked ? ' checked' : ''}"><input type="checkbox"${checked ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(text)}</li>`);
i++;
// Collect continuation items
while (i < lines.length) {
const next = lines[i].trim();
const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
const nextLi = next.match(/^[-*+]\s+(.+)$/);
if (nextTask) {
const c = nextTask[1] !== ' ';
items.push(`<li class="hermes-kanban-task${c ? ' checked' : ''}"><input type="checkbox"${c ? ' checked' : ''} disabled> ${_kanbanRenderMarkdownInline(nextTask[2])}</li>`);
i++;
} else if (nextLi) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextLi[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
// ── Unordered list item ──
const ulMatch = trimmed.match(/^[-*+]\s+(.+)$/);
if (ulMatch) {
const items = [];
items.push(`<li>${_kanbanRenderMarkdownInline(ulMatch[1])}</li>`);
i++;
while (i < lines.length) {
const next = lines[i].trim();
const nextUl = next.match(/^[-*+]\s+(.+)$/);
const nextTask = next.match(/^[-*+]\s+\[( |x|X)\]\s+(.+)$/);
if (nextTask) break; // let task list handler get it
if (nextUl) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextUl[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ul>${items.join('')}</ul>`);
continue;
}
// ── Ordered list item ──
const olMatch = trimmed.match(/^\d+\.\s+(.+)$/);
if (olMatch) {
const items = [];
items.push(`<li>${_kanbanRenderMarkdownInline(olMatch[1])}</li>`);
i++;
while (i < lines.length) {
const next = lines[i].trim();
const nextOl = next.match(/^\d+\.\s+(.+)$/);
if (nextOl) {
items.push(`<li>${_kanbanRenderMarkdownInline(nextOl[1])}</li>`);
i++;
} else {
break;
}
}
out.push(`<ol>${items.join('')}</ol>`);
continue;
}
// ── Empty line ──
if (!trimmed) {
out.push('');
i++;
continue;
}
// ── Paragraph ──
out.push(`<p>${_kanbanRenderMarkdownInline(trimmed)}</p>`);
i++;
}
return `<div class="hermes-kanban-md">${out.join('\n')}</div>`;
}
function _kanbanFormatDuration(seconds){
@@ -1816,7 +2022,7 @@ function _kanbanCommentHtml(comment){
const by = comment.author || comment.created_by || comment.actor || '';
const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || '');
return `<div class="kanban-detail-row">
<div class="kanban-detail-row-main">${esc(body)}</div>
<div class="kanban-detail-row-main">${_kanbanRenderMarkdown(body)}</div>
<div class="kanban-detail-row-meta">${esc([by, at].filter(Boolean).join(' · '))}</div>
</div>`;
}
@@ -2368,7 +2574,7 @@ function _kanbanRenderTaskDetail(data){
<div class="kanban-task-preview-title">${esc(title)}</div>
<button class="btn secondary kanban-edit-btn" onclick="openKanbanEdit('${esc(task.id)}')" data-i18n="kanban_edit_task" title="${esc(t('kanban_edit_task') || 'Edit task')}">${esc(t('kanban_edit_task') || 'Edit task')}</button>
</div>
<div class="kanban-task-preview-body">${esc(body)}</div>
<div class="kanban-task-preview-body">${_kanbanRenderMarkdown(body)}</div>
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
<div class="kanban-status-actions">${statusButtons}</div>
<div class="kanban-detail-grid">
@@ -2388,8 +2594,7 @@ async function loadKanbanTask(taskId){
if (!taskId) return;
try {
const data = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + _kanbanBoardQuery());
const logEndpoint = '/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log' + _kanbanBoardQuery();
try { data.log = await api(logEndpoint + '?tail=65536'); } catch(e) { data.log = {}; }
try { data.log = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log' + _kanbanBoardQuery({tail: 65536})); } catch(e) { data.log = {}; }
_kanbanCurrentTaskId = taskId;
const task = data.task || {};
const title = _kanbanTaskTitle(task);
@@ -5636,6 +5841,8 @@ function _preferencesPayloadFromUi(){
if(syncCb) payload.sync_to_insights=syncCb.checked;
const updateCb=$('settingsCheckUpdates');
if(updateCb) payload.check_for_updates=updateCb.checked;
const ignoreAgentUpdatesCb=$('settingsIgnoreAgentUpdates');
if(ignoreAgentUpdatesCb) payload.ignore_agent_updates=ignoreAgentUpdatesCb.checked;
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
if(whatsNewSummaryCb) payload.whats_new_summary_enabled=whatsNewSummaryCb.checked;
const soundCb=$('settingsSoundEnabled');
@@ -5759,7 +5966,7 @@ async function loadSettingsPanel(){
const themeVal=settings.theme||'dark';
if(themeSel) themeSel.value=themeVal;
if(typeof _syncThemePicker==='function') _syncThemePicker(themeVal);
const skinVal=(settings.skin||'default').toLowerCase();
const skinVal=(localStorage.getItem('hermes-skin')||settings.skin||'default').toLowerCase();
const skinSel=$('settingsSkin');
if(skinSel) skinSel.value=skinVal;
if(typeof _buildSkinPicker==='function') _buildSkinPicker(skinVal);
@@ -5862,6 +6069,8 @@ async function loadSettingsPanel(){
}
modelSel.addEventListener('change',_markSettingsDirty,{once:false});
}
// Auxiliary models — load task assignments and provider/model options
_loadAuxiliaryModels();
// Send key preference
const sendKeySel=$('settingsSendKey');
if(sendKeySel){sendKeySel.value=settings.send_key||'enter';sendKeySel.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
@@ -5927,6 +6136,8 @@ async function loadSettingsPanel(){
if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const updateCb=$('settingsCheckUpdates');
if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const ignoreAgentUpdatesCb=$('settingsIgnoreAgentUpdates');
if(ignoreAgentUpdatesCb){ignoreAgentUpdatesCb.checked=!!settings.ignore_agent_updates;ignoreAgentUpdatesCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const whatsNewSummaryCb=$('settingsWhatsNewSummary');
if(whatsNewSummaryCb){whatsNewSummaryCb.checked=!!settings.whats_new_summary_enabled;whatsNewSummaryCb.addEventListener('change',_schedulePreferencesAutosave,{once:false});}
const soundCb=$('settingsSoundEnabled');
@@ -6676,10 +6887,13 @@ function _refreshModelDropdownsAfterProviderChange(){
if(typeof window._invalidateSlashModelCache==='function'){
window._invalidateSlashModelCache();
}
if(typeof populateModelDropdown==='function'){
// Fire-and-forget: don't block the providers panel refresh on a
// dropdown rebuild. The composer/Settings dropdowns will catch up
// on the very next paint frame.
// Fire-and-forget: don't block the providers panel refresh on a
// dropdown rebuild. The composer/Settings dropdowns will catch up
// on the very next paint frame.
if(typeof window._ensureModelDropdownReady==='function'){
window._modelDropdownReady=null;
Promise.resolve(window._ensureModelDropdownReady()).catch(()=>{});
}else if(typeof populateModelDropdown==='function'){
Promise.resolve(populateModelDropdown()).catch(()=>{});
}
}catch(_e){
@@ -6818,6 +7032,223 @@ async function checkUpdatesNow(){
}
}
// ── Auxiliary Models ──────────────────────────────────────────────────────────
// Canonical auxiliary task slots with display names.
// Keep in sync with hermes_cli/main.py _AUX_TASKS and hermes_cli/web_server.py _AUX_TASK_SLOTS.
const _AUX_TASK_SLOTS=[
{key:'vision',name:'Vision',desc:'image/screenshot analysis'},
{key:'compression',name:'Compression',desc:'context summarization'},
{key:'web_extract',name:'Web extract',desc:'web page summarization'},
{key:'session_search',name:'Session search',desc:'past-conversation recall'},
{key:'approval',name:'Approval',desc:'smart command approval'},
{key:'mcp',name:'MCP',desc:'MCP tool reasoning'},
{key:'title_generation',name:'Title generation',desc:'session titles'},
{key:'skills_hub',name:'Skills hub',desc:'skills search/install'},
{key:'curator',name:'Curator',desc:'skill-usage review pass'},
];
let _auxProviders=[]; // cached provider list from /api/model/options
let _auxOriginalConfig=null; // snapshot of initial config for dirty detection
function _auxSelectStyle(){
return 'width:100%;padding:6px 8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:12px;box-sizing:border-box';
}
function _buildAuxProviderOptions(sel,providers,currentProvider){
sel.innerHTML='';
// "auto" = use main model
const autoOpt=document.createElement('option');
autoOpt.value='auto';autoOpt.textContent='auto ('+t('settings_aux_provider_auto')+')';
if(currentProvider==='auto'||!currentProvider) autoOpt.selected=true;
sel.appendChild(autoOpt);
for(const p of providers){
const opt=document.createElement('option');
opt.value=p.slug;opt.textContent=p.name;
if(p.slug===currentProvider) opt.selected=true;
sel.appendChild(opt);
}
}
function _buildAuxModelOptions(sel,provider,providers,currentModel){
sel.innerHTML='';
const emptyOpt=document.createElement('option');
emptyOpt.value='';emptyOpt.textContent=t('settings_aux_model_auto')||'auto (use provider default)';
sel.appendChild(emptyOpt);
if(!provider||provider==='auto'){
sel.value=currentModel||'';
return;
}
// Find matching provider in cached list
const pData=providers.find(p=>p.slug===provider);
if(pData&&pData.models){
for(const mId of pData.models){
const opt=document.createElement('option');
opt.value=mId;opt.textContent=mId;
if(mId===currentModel) opt.selected=true;
sel.appendChild(opt);
}
}
// Always allow custom model — add a text input option hint
const customOpt=document.createElement('option');
customOpt.value='__custom__';customOpt.textContent=t('settings_aux_model_custom')||'Custom model…';
sel.appendChild(customOpt);
// If currentModel not in list and not empty, add it as a custom option
if(currentModel&&!pData?.models?.includes(currentModel)){
const existingOpt=document.createElement('option');
existingOpt.value=currentModel;existingOpt.textContent=currentModel+' (configured)';
existingOpt.selected=true;
sel.insertBefore(existingOpt,customOpt);
}
}
function _onAuxProviderChange(taskKey,providers){
const provSel=$('aux-prov-'+taskKey);
const modelSel=$('aux-model-'+taskKey);
if(!provSel||!modelSel) return;
const provider=provSel.value;
_buildAuxModelOptions(modelSel,provider,providers,'');
_markAuxDirty();
}
async function _onAuxModelChange(taskKey){
const modelSel=$('aux-model-'+taskKey);
if(!modelSel) return;
if(modelSel.value==='__custom__'){
const customModel=await showPromptDialog({title:t('settings_aux_model_custom')||'Custom model',message:t('settings_aux_model_custom_prompt')||'Enter model ID:',placeholder:'model/provider:model-id',confirmLabel:t('settings_btn_apply_aux_models')||'Apply'});
if(customModel&&customModel.trim()){
// Insert custom model option before the __custom__ option
const opt=document.createElement('option');
opt.value=customModel.trim();opt.textContent=customModel.trim();
// Remove __custom__ selection
const customIdx=[...modelSel.options].findIndex(o=>o.value==='__custom__');
if(customIdx>=0) modelSel.insertBefore(opt,modelSel.options[customIdx]);
modelSel.value=customModel.trim();
}else{
modelSel.value='';
}
}
_markAuxDirty();
}
function _markAuxDirty(){
const applyBtn=$('btnApplyAuxModels');
if(applyBtn) applyBtn.style.display='';
_markSettingsDirty();
}
async function _loadAuxiliaryModels(){
const container=$('auxModelsContainer');
if(!container) return;
container.innerHTML='<div style="color:var(--muted);font-size:12px">'+(t('settings_aux_loading')||'Loading…')+'</div>';
try{
// Fetch auxiliary config AND the WebUI's own /api/models for provider/model lists
const [auxData,modelsData]=await Promise.all([
api('/api/model/auxiliary').catch(()=>null),
api('/api/models').catch(()=>null),
]);
// Build provider list from /api/models groups
// /api/models returns: { groups: [{ provider: str, provider_id: str, models: [{id,label}] }] }
const groups=(modelsData&&modelsData.groups)||[];
_auxProviders=groups.filter(g=>g.provider&&g.models&&g.models.length>0).map(g=>({
slug:g.provider_id||g.provider,
name:g.provider,
models:g.models.map(m=>m.id),
}));
const tasks=(auxData&&auxData.tasks)||[];
// Build a quick lookup: taskKey → {provider, model}
const taskMap={};
for(const t of tasks) taskMap[t.task]=t;
_auxOriginalConfig=JSON.parse(JSON.stringify(taskMap));
container.innerHTML='';
for(const slot of _AUX_TASK_SLOTS){
const cfg=taskMap[slot.key]||{provider:'auto',model:''};
const row=document.createElement('div');
row.style.cssText='display:grid;grid-template-columns:120px 1fr 1fr;gap:8px;align-items:center;margin-bottom:8px';
// Task name + description
const label=document.createElement('div');
label.style.cssText='font-size:12px;font-weight:500;color:var(--text);line-height:1.3';
label.innerHTML=esc(slot.name)+'<div style="font-size:10px;color:var(--muted);font-weight:400">'+esc(slot.desc)+'</div>';
row.appendChild(label);
// Provider select
const provSel=document.createElement('select');
provSel.id='aux-prov-'+slot.key;
provSel.style.cssText=_auxSelectStyle();
_buildAuxProviderOptions(provSel,_auxProviders,cfg.provider);
provSel.addEventListener('change',()=>_onAuxProviderChange(slot.key,_auxProviders));
row.appendChild(provSel);
// Model select
const modelSel=document.createElement('select');
modelSel.id='aux-model-'+slot.key;
modelSel.style.cssText=_auxSelectStyle();
_buildAuxModelOptions(modelSel,cfg.provider,_auxProviders,cfg.model);
modelSel.addEventListener('change',()=>_onAuxModelChange(slot.key));
row.appendChild(modelSel);
container.appendChild(row);
}
// Hide apply button (no changes yet)
const applyBtn=$('btnApplyAuxModels');
if(applyBtn) applyBtn.style.display='none';
// Reset button
const resetBtn=$('btnResetAuxModels');
if(resetBtn&&!resetBtn._bound){
resetBtn._bound=true;
resetBtn.addEventListener('click',async()=>{
if(!(await showConfirmDialog({title:t('settings_aux_reset_confirm_title')||'Reset auxiliary models?',message:t('settings_aux_reset_confirm_msg')||'This will set all auxiliary tasks to auto (use main model).',confirmLabel:t('settings_btn_reset_aux_models')||'Reset',danger:true}))) return;
try{
await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:'__reset__',provider:'auto',model:''})});
if(typeof showToast==='function') showToast(t('settings_aux_reset_done')||'Auxiliary models reset to auto');
_loadAuxiliaryModels();
}catch(e){
if(typeof showToast==='function') showToast(t('settings_aux_save_failed')||'Failed to reset auxiliary models');
}
});
}
// Apply button
if(applyBtn&&!applyBtn._bound){
applyBtn._bound=true;
applyBtn.addEventListener('click',_applyAuxModels);
}
}catch(e){
console.warn('[settings] auxiliary models load failed',e);
container.innerHTML='<div style="color:var(--muted);font-size:12px">'+(t('settings_aux_load_failed')||'Could not load auxiliary model settings. Make sure the agent API is available.')+'</div>';
}
}
async function _applyAuxModels(){
let saved=0;
for(const slot of _AUX_TASK_SLOTS){
const provSel=$('aux-prov-'+slot.key);
const modelSel=$('aux-model-'+slot.key);
if(!provSel) continue;
const provider=provSel.value;
const model=(modelSel&&modelSel.value!=='__custom__')?(modelSel.value||''):'';
const orig=_auxOriginalConfig?.[slot.key]||{provider:'auto',model:''};
// Only save if changed
if(provider!==orig.provider||model!==orig.model){
try{
await api('/api/model/set',{method:'POST',body:JSON.stringify({scope:'auxiliary',task:slot.key,provider,model})});
saved++;
}catch(e){
console.warn('[settings] failed to save aux task',slot.key,e);
if(typeof showToast==='function') showToast(t('settings_aux_save_failed')||'Failed to save auxiliary model for '+slot.name);
return;
}
}
}
if(typeof showToast==='function') showToast(saved?(t('settings_aux_saved')||'Auxiliary models updated'):(t('settings_aux_no_changes')||'No changes to apply'));
// Reload to refresh state
_loadAuxiliaryModels();
}
async function saveSettings(andClose){
const model=($('settingsModel')||{}).value;
const modelChanged=(model||'')!==(_settingsHermesDefaultModelOnOpen||'');
@@ -6856,6 +7287,7 @@ async function saveSettings(andClose){
body.pinned_sessions_limit=pinnedSessionsLimit;
body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked;
body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked;
body.ignore_agent_updates=!!($('settingsIgnoreAgentUpdates')||{}).checked;
body.whats_new_summary_enabled=!!($('settingsWhatsNewSummary')||{}).checked;
body.sound_enabled=!!($('settingsSoundEnabled')||{}).checked;
body.rtl=!!($('settingsRtl')||{}).checked;
@@ -7059,6 +7491,16 @@ function _mcpStatusLabel(status){
}[status]||'mcp_status_unknown';
return t(key);
}
function toggleMcpServer(name, enabled){
api('/api/mcp/servers/'+encodeURIComponent(name),{
method:'PATCH',
body:JSON.stringify({enabled:enabled}),
}).then(r=>{
if(r&&r.ok) showToast(t(enabled?'mcp_enabled_toast':'mcp_disabled_toast',name));
else showToast(t('mcp_toggle_failed'),'error');
loadMcpServers();
}).catch(()=>{showToast(t('mcp_toggle_failed'),'error');loadMcpServers();});
}
function loadMcpServers(){
const list=$('mcpServerList');
if(!list) return;
@@ -7069,7 +7511,6 @@ function loadMcpServers(){
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
return;
}
const toggleNote=r.toggle_supported?'':'<div class="mcp-readonly-note">'+esc(t('mcp_toggle_followup'))+'</div>';
list.innerHTML=r.servers.map(s=>{
const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown'));
const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
@@ -7083,6 +7524,11 @@ function loadMcpServers(){
const envInfo=s.env?Object.entries(s.env).map(([k,v])=>`${k}=${v}`).join(', '):'';
const headersInfo=s.headers?Object.entries(s.headers).map(([k,v])=>`${k}=${v}`).join(', '):'';
const secretInfo=[envInfo,headersInfo].filter(Boolean).join(' | ');
const isEnabled=s.enabled!==false;
const encodedName=encodeURIComponent(s.name).replace(/'/g,"\\'");
const toggleBtn=r.toggle_supported
?`<button type="button" class="mcp-toggle-btn ${isEnabled?'mcp-toggle-enabled':'mcp-toggle-disabled'}" title="${esc(t(isEnabled?'mcp_disable_server':'mcp_enable_server'))}" onclick="toggleMcpServer('${encodedName}',${!isEnabled})">${esc(t(isEnabled?'mcp_enabled_yes':'mcp_enabled_no'))}</button>`
:`<span>${esc(t(isEnabled?'mcp_enabled_yes':'mcp_enabled_no'))}</span>`;
return `<div class="mcp-server-row">
<div class="mcp-server-row-head">
<span class="mcp-server-name">${esc(s.name)}</span>
@@ -7090,9 +7536,9 @@ function loadMcpServers(){
${statusBadge}
</div>
<div class="mcp-server-detail">${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}</div>
<div class="mcp-server-meta"><span class="mcp-tool-count">${esc(t('mcp_tool_count',toolCount))}</span><span>${esc(t(s.enabled===false?'mcp_enabled_no':'mcp_enabled_yes'))}</span></div>
<div class="mcp-server-meta"><span class="mcp-tool-count">${esc(t('mcp_tool_count',toolCount))}</span>${toggleBtn}</div>
</div>`;
}).join('')+toggleNote;
}).join('');
}).catch(()=>{list.innerHTML=`<div class="mcp-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_load_failed'))}</div>`});
}
let _mcpToolsCache=[];
+83
View File
@@ -0,0 +1,83 @@
// Early PWA startup helpers.
// Runs before the main UI bundle so installed launches can paint with the
// correct native-like classes and capture browser install events early.
(function(){
'use strict';
var root=document.documentElement;
function mql(query){
try{return window.matchMedia&&window.matchMedia(query).matches;}catch(_){return false;}
}
function isStandalone(){
return window.navigator.standalone===true ||
mql('(display-mode: standalone)') ||
mql('(display-mode: fullscreen)') ||
mql('(display-mode: window-controls-overlay)');
}
function isIOS(){
return /iPad|iPhone|iPod/.test(window.navigator.userAgent||'') ||
(window.navigator.platform==='MacIntel' && window.navigator.maxTouchPoints>1);
}
function syncMode(){
var standalone=isStandalone();
root.classList.toggle('pwa-standalone',standalone);
root.classList.toggle('pwa-browser',!standalone);
root.classList.toggle('pwa-ios',isIOS());
root.classList.toggle('pwa-offline',window.navigator.onLine===false);
root.dataset.pwaDisplayMode=standalone?'standalone':'browser';
return standalone;
}
function dispatch(name,detail){
try{window.dispatchEvent(new CustomEvent(name,{detail:detail||{}}));}catch(_){}
}
syncMode();
window.addEventListener('online',function(){syncMode();dispatch('hermes:pwa-connection-change',{online:true});});
window.addEventListener('offline',function(){syncMode();dispatch('hermes:pwa-connection-change',{online:false});});
if(window.matchMedia){
['(display-mode: standalone)','(display-mode: fullscreen)','(display-mode: window-controls-overlay)'].forEach(function(query){
try{
var media=window.matchMedia(query);
var handler=function(){syncMode();};
if(media.addEventListener)media.addEventListener('change',handler);
else if(media.addListener)media.addListener(handler);
}catch(_){}
});
}
window.addEventListener('beforeinstallprompt',function(event){
event.preventDefault();
window.hermesDeferredInstallPrompt=event;
root.classList.add('pwa-installable');
dispatch('hermes:pwa-installable');
});
window.addEventListener('appinstalled',function(){
window.hermesDeferredInstallPrompt=null;
root.classList.remove('pwa-installable');
root.classList.add('pwa-installed');
dispatch('hermes:pwa-installed');
});
document.addEventListener('visibilitychange',function(){
if(document.visibilityState==='visible'){
syncMode();
root.classList.add('pwa-resumed');
window.setTimeout(function(){root.classList.remove('pwa-resumed');},1200);
}
});
window.HermesPWA={
isStandalone:isStandalone,
syncMode:syncMode,
launchAction:function(){
try{return new URLSearchParams(window.location.search||'').get('action')||null;}catch(_){return null;}
},
promptInstall:function(){
var prompt=window.hermesDeferredInstallPrompt;
if(!prompt||typeof prompt['prompt']!=='function')return Promise.resolve({outcome:'unavailable'});
window.hermesDeferredInstallPrompt=null;
root.classList.remove('pwa-installable');
prompt['prompt']();
return Promise.resolve(prompt.userChoice).catch(function(){return {outcome:'dismissed'};});
}
};
})();
+58 -22
View File
@@ -589,11 +589,12 @@ async function loadSession(sid){
if(_msgInner){
if(e.status===404){
_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;">Session not available in web UI.</div>';
// If this 404 was for the saved active-session ID (not a click-into request),
// wipe the stale localStorage value and rethrow so boot can fall through to
// the empty-state instead of sticking to a broken "Session not available" view.
if(!currentSid&&localStorage.getItem('hermes-webui-session')===sid){
// Option A (#2798): for boot-time stale URL/localStorage session IDs,
// always clear persisted session, strip /session/{id} from URL, and
// rethrow so boot can deterministically fall through to empty-state.
if(!currentSid){
localStorage.removeItem('hermes-webui-session');
try{ history.replaceState(null,'','/'); }catch(_){ }
if (_loadingSessionId === sid) _loadingSessionId = null;
throw e;
}
@@ -788,7 +789,10 @@ async function loadSession(sid){
syncTopbar();renderMessages();
if(typeof resumeManualCompressionForSession==='function') resumeManualCompressionForSession(sid);
const _dirP=loadDir('.');
await _dirP;
// Workspace refresh is guarded by session id inside loadDir(); do not
// block session-load completion, draft restore, or model resolution on
// file-tree IO for users focused on the chat.
if(_dirP&&typeof _dirP.catch==='function') _dirP.catch(()=>{});
}
}
@@ -823,6 +827,8 @@ async function loadSession(sid){
// Clear the in-flight session marker now that this load has completed (#1060).
if (_loadingSessionId === sid) _loadingSessionId = null;
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
// ── Cross-channel handoff hint ──
// After session fully loaded, check if this is a messaging session with
// enough conversation rounds to warrant a handoff hint bar.
@@ -1824,26 +1830,24 @@ function _openSessionActionMenu(session, anchorEl){
}
));
}
const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();
menu.appendChild(_buildSessionAction(
session.pinned?t('session_unpin'):t('session_pin'),
pinLimitReached?_pinnedSessionsLimitMessage():(session.pinned?t('session_unpin_desc'):t('session_pin_desc')),
session.pinned?t('session_unpin_desc'):t('session_pin_desc'),
session.pinned?ICONS.pin:ICONS.unpin,
async()=>{
closeSessionActionMenu();
if(pinLimitReached){
if(typeof showToast==='function') showToast(_pinnedSessionsLimitMessage(),3000,'error');
return;
}
const newPinned=!session.pinned;
try{
await api('/api/session/pin',{method:'POST',body:JSON.stringify({session_id:session.session_id,pinned:newPinned})});
session.pinned=newPinned;
if(S.session&&S.session.session_id===session.session_id) S.session.pinned=newPinned;
renderSessionList();
}catch(err){showToast(t('session_pin_failed')+err.message);}
}catch(err){
showToast(t('session_pin_failed')+err.message);
await renderSessionList();
}
},
(session.pinned?'is-active':'')+(pinLimitReached?' is-disabled':'')
session.pinned?'is-active':''
));
menu.appendChild(_buildSessionAction(
t('session_move_project'),
@@ -2023,6 +2027,31 @@ function _isOptimisticFirstTurnSessionRow(s){
);
}
function _shouldKeepLocalOnlyOptimisticSessionRow(local){
if(!_isOptimisticFirstTurnSessionRow(local)) return false;
const sid=local.session_id;
if(typeof _sendInProgress!=='undefined'&&_sendInProgress&&sid===_sendInProgressSid) return true;
const activeSid=S&&S.session&&S.session.session_id;
const isActive=Boolean(activeSid&&activeSid===sid);
const hasRuntimeConfirmation=Boolean(local.active_stream_id||local.pending_user_message||local.pending_started_at);
if(isActive&&S.busy&&hasRuntimeConfirmation) return true;
const localTs=Number(local.last_message_at||local.updated_at||0);
const ageMs=localTs>0?Date.now()-(localTs*1000):Infinity;
return Boolean(isActive&&S.busy&&ageMs>=0&&ageMs<5000);
}
function _dropStaleOptimisticSessionRow(sid){
if(!sid) return;
if(INFLIGHT&&INFLIGHT[sid]){
delete INFLIGHT[sid];
if(typeof clearInflightState==='function') clearInflightState(sid);
}
if(typeof _sessionStreamingById!=='undefined'&&_sessionStreamingById&&typeof _sessionStreamingById.set==='function'){
_sessionStreamingById.set(sid,false);
}
if(typeof _forgetObservedStreamingSession==='function') _forgetObservedStreamingSession(sid);
}
function _mergeOptimisticFirstTurnSessions(fetchedSessions){
const merged=Array.isArray(fetchedSessions)?[...fetchedSessions]:[];
const bySid=new Map();
@@ -2034,24 +2063,31 @@ function _mergeOptimisticFirstTurnSessions(fetchedSessions){
if(idx>=0){
const fetched=merged[idx]||{};
const fetchedIsServerIdle=_isServerIdleSessionRow(fetched);
const keepLocalOptimistic=fetchedIsServerIdle?false:_shouldKeepLocalOnlyOptimisticSessionRow(local);
const localCount=Number(local.message_count||0);
const fetchedCount=Number(fetched.message_count||0);
const localTs=Number(local.last_message_at||local.updated_at||0);
const fetchedTs=Number(fetched.last_message_at||fetched.updated_at||0);
if(!keepLocalOptimistic&&typeof _dropStaleOptimisticSessionRow==='function') _dropStaleOptimisticSessionRow(sid);
merged[idx]={
...local,
...fetched,
message_count:Math.max(localCount,fetchedCount),
last_message_at:Math.max(localTs,fetchedTs),
updated_at:Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs),
active_stream_id:fetchedIsServerIdle?null:(fetched.active_stream_id||local.active_stream_id||null),
pending_user_message:fetchedIsServerIdle?null:(fetched.pending_user_message||local.pending_user_message||null),
pending_started_at:fetchedIsServerIdle?null:(fetched.pending_started_at||local.pending_started_at||null),
is_streaming:fetchedIsServerIdle?false:Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local)),
title:keepLocalOptimistic?(local.title||fetched.title):fetched.title,
message_count:keepLocalOptimistic?Math.max(localCount,fetchedCount):fetchedCount,
last_message_at:keepLocalOptimistic?Math.max(localTs,fetchedTs):fetchedTs,
updated_at:keepLocalOptimistic?Math.max(Number(local.updated_at||0),Number(fetched.updated_at||0),localTs,fetchedTs):Number(fetched.updated_at||fetchedTs||0),
active_stream_id:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.active_stream_id||local.active_stream_id||null):null),
pending_user_message:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.pending_user_message||local.pending_user_message||null):null),
pending_started_at:fetchedIsServerIdle?null:(keepLocalOptimistic?(fetched.pending_started_at||local.pending_started_at||null):null),
is_streaming:fetchedIsServerIdle?false:(keepLocalOptimistic&&Boolean(fetched.is_streaming||local.is_streaming||_isSessionLocallyStreaming(local))),
};
}else{
merged.push({...local,is_streaming:true});
bySid.set(sid,merged.length-1);
if(_shouldKeepLocalOnlyOptimisticSessionRow(local)){
merged.push({...local,is_streaming:true});
bySid.set(sid,merged.length-1);
}else{
_dropStaleOptimisticSessionRow(sid);
}
}
}
return merged;
+96 -9
View File
@@ -221,6 +221,52 @@
:root[data-skin="catppuccin"] .tool-card-more{color:var(--accent-text);}
:root[data-skin="catppuccin"] .tool-card-running-dot{background:var(--accent);}
/* Skin: Hepburn — magenta-rose palette derived from the Hepburn TUI theme */
:root[data-skin="hepburn"]{
--bg:#fff3f7;--sidebar:#fbe4ed;--surface:#fff9fb;
--border:#ecc8d5;--border2:rgba(242,120,173,0.18);
--text:#3d1a28;--muted:#906270;--strong:#260912;--em:#72384a;
--accent:#d44a7a;--accent-hover:#c6246a;--accent-text:#c6246a;
--accent-bg:rgba(242,120,173,0.10);--accent-bg-strong:rgba(242,120,173,0.20);
--code-bg:#fbe6ef;--code-inline-bg:rgba(242,120,173,0.12);--code-text:#d44a7a;--pre-text:#3d1a28;
--topbar-bg:rgba(251,228,237,0.96);--main-bg:rgba(255,243,247,0.5);
--input-bg:rgba(242,120,173,0.06);--hover-bg:rgba(242,120,173,0.08);
--focus-ring:rgba(242,120,173,0.35);--focus-glow:rgba(242,120,173,0.12);
--blue:#8671e5;--gold:#d44a7a;
--error:#c0392b;--success:#3d8b40;--warning:#e67e22;--info:#8671e5;
--surface-subtle:rgba(242,120,173,0.04);--surface-subtle-hover:rgba(242,120,173,0.08);
--border-subtle:rgba(242,120,173,0.10);--border-muted:rgba(242,120,173,0.16);
}
:root.dark[data-skin="hepburn"]{
--bg:#110a0f;--sidebar:#1e0f19;--surface:#241420;
--border:#311a28;--border2:rgba(242,120,173,0.20);
--text:#f2e4ee;--muted:#c8a4b8;--strong:#fcf4f8;--em:#e5c4d8;
--accent:#f278ad;--accent-hover:#f5a0c5;--accent-text:#f278ad;
--accent-bg:rgba(242,120,173,0.14);--accent-bg-strong:rgba(242,120,173,0.25);
--code-bg:#1e0f19;--code-inline-bg:rgba(242,120,173,0.22);--code-text:#f5a0c5;--pre-text:#f2e4ee;
--topbar-bg:rgba(30,15,25,0.96);--main-bg:rgba(17,10,15,0.5);
--input-bg:rgba(242,120,173,0.08);--hover-bg:rgba(242,120,173,0.12);
--focus-ring:rgba(242,120,173,0.45);--focus-glow:rgba(242,120,173,0.18);
--blue:#8671e5;--gold:#ec5597;
--error:#ff5c5c;--success:#6cd4a5;--warning:#f2b370;--info:#8671e5;
--surface-subtle:rgba(242,120,173,0.05);--surface-subtle-hover:rgba(242,120,173,0.10);
--border-subtle:rgba(242,120,173,0.12);--border-muted:rgba(242,120,173,0.22);
}
:root[data-skin="hepburn"]:not(.dark) .new-chat-btn,
:root.dark[data-skin="hepburn"] .new-chat-btn{background:#ec5597;border-color:#ec5597;color:#fff;font-weight:600;box-shadow:0 1px 3px rgba(236,85,151,0.3);}
:root[data-skin="hepburn"]:not(.dark) .new-chat-btn:hover,
:root.dark[data-skin="hepburn"] .new-chat-btn:hover{background:#c6246a;border-color:#c6246a;color:#fff;}
:root[data-skin="hepburn"] .tool-card{background:rgba(242,120,173,0.04);border-color:var(--border);}
:root.dark[data-skin="hepburn"] .tool-card{background:rgba(242,120,173,0.06);}
:root[data-skin="hepburn"] .tool-card:hover{border-color:var(--accent-bg-strong);}
:root[data-skin="hepburn"] .tool-card-running{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
:root[data-skin="hepburn"] .tool-arg-key,
:root[data-skin="hepburn"] .tool-card-more{color:var(--accent-text);}
:root[data-skin="hepburn"] .tool-card-running-dot{background:var(--accent);}
:root[data-skin="hepburn"] .send-btn{background:#ec5597;border-color:#ec5597;color:#fff;box-shadow:0 1px 3px rgba(236,85,151,0.3);}
:root[data-skin="hepburn"] .send-btn:hover{background:#c6246a;border-color:#c6246a;color:#fff;box-shadow:0 2px 8px rgba(198,36,106,0.45);}
:root[data-skin="hepburn"] .session-item.active{border-left:2px solid var(--accent);}
/* Skin: Nous Research (steel blue, monospace, sharp corners, deep navy dark)
Full palette rewrite inspired by the Nous Research visual identity.
Monochromatic steel blue accent, monospace typography, near-sharp corners,
@@ -595,6 +641,13 @@
/* ── Smooth dark mode transitions ── */
body,header,footer,aside,nav,main,div,button,input,textarea,select{transition-property:background-color,border-color,color;transition-duration:.15s;transition-timing-function:ease;}
:root{--app-titlebar-safe-top:0px;}
.pwa-standalone{overscroll-behavior:none;}
.pwa-standalone body{-webkit-tap-highlight-color:transparent;}
.pwa-standalone .app-titlebar-reload{display:inline-flex;}
.pwa-offline .app-titlebar::after{content:'';position:absolute;left:50%;bottom:5px;width:5px;height:5px;border-radius:999px;background:var(--warning);box-shadow:0 0 0 3px color-mix(in srgb,var(--warning) 22%,transparent);transform:translateX(-50%);}
.pwa-resumed .app-titlebar-title{animation:pwa-title-resume .6s ease-out;}
@keyframes pwa-title-resume{0%{opacity:.65;}100%{opacity:1;}}
@media (prefers-reduced-motion: reduce){.pwa-resumed .app-titlebar-title{animation:none;}}
@supports (padding-top: env(safe-area-inset-top)){
@media (display-mode: standalone), (display-mode: fullscreen){
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
@@ -1063,6 +1116,7 @@
.panel-view.active{display:flex;}
/* Cron panel */
.cron-list{flex:1;overflow-y:auto;padding:8px;}
.cron-gateway-notice{margin:8px 8px 0;}
.cron-item{width:100%;min-width:0;box-sizing:border-box;border-radius:10px;border:1px solid var(--border);margin-bottom:6px;overflow:hidden;transition:border-color .15s,background .15s;background:rgba(255,255,255,.02);cursor:pointer;}
.cron-item:hover{border-color:var(--border2);}
.cron-header{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:pointer;}
@@ -1323,7 +1377,7 @@
.suggestion:hover{background:var(--accent-bg);color:var(--text);border-color:var(--accent-bg);transform:translateX(2px);}
/* ── Composer ── */
.composer-wrap{padding:12px 20px 16px;background:var(--bg);flex-shrink:0;}
.composer-box{max-width:780px;margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;}
.composer-box{max-width:clamp(780px,60vw,1100px);margin:0 auto;background:linear-gradient(var(--input-bg),var(--input-bg)),var(--bg);border:1px solid var(--border2);border-radius:16px;display:flex;flex-direction:column;transition:border-color .2s,box-shadow .2s;position:relative;z-index:2;}
.composer-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg);}
.composer-wrap.drag-over .composer-box{border-color:var(--accent-text);background:var(--accent-bg);}
.drop-hint{display:none;position:absolute;inset:0;align-items:center;justify-content:center;background:var(--accent-bg);border:2px dashed var(--accent);border-radius:14px;font-size:14px;color:var(--accent-text);pointer-events:none;z-index:10;flex-direction:column;gap:8px;}
@@ -1349,7 +1403,16 @@
.composer-left{display:flex;align-items:center;gap:4px;min-width:0;flex:1;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}
.composer-left::-webkit-scrollbar{display:none;}
.composer-divider{width:1px;height:16px;background:var(--border);margin:0 3px;flex-shrink:0;}
.composer-profile-wrap{position:relative;flex:0 1 auto;min-width:0;}
/* Composer footer chip wraps share position:relative + flex:0 0 auto so
they keep their natural width and let .composer-left handle overflow
via horizontal scroll. flex-shrink:0 here is what fixes #2740 (chips
were compressing past their content and visually overlapping). Each
wrap declares its own display / gap below as needed. */
.composer-profile-wrap,
.composer-ws-wrap,
.composer-reasoning-wrap,
.composer-toolsets-wrap,
.composer-model-wrap{position:relative;flex:0 0 auto;}
.composer-profile-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-profile-chip:hover{background-color:var(--hover-bg);}
.composer-profile-chip.active{background:var(--accent-bg);border-color:var(--accent-bg-strong);}
@@ -1358,7 +1421,7 @@
.composer-profile-chip.switching .composer-profile-icon{position:relative;}
.composer-profile-icon,.composer-profile-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-profile-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-ws-wrap{position:relative;flex:0 1 auto;min-width:0;display:flex;align-items:center;gap:4px;}
.composer-ws-wrap{display:flex;align-items:center;gap:4px;}
.composer-workspace-group{display:inline-flex;align-items:stretch;max-width:284px;border-radius:999px;overflow:hidden;background-color:transparent;border:1px solid var(--border2);transition:background-color .15s,border-color .15s;}
.composer-workspace-group:hover{background-color:var(--hover-bg);}
.composer-workspace-group:hover{border-color:var(--border2);}
@@ -1373,7 +1436,6 @@
.composer-workspace-chip.active{color:var(--text);background:var(--accent-bg);}
.composer-workspace-icon,.composer-workspace-chevron{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;line-height:1;}
.composer-workspace-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.composer-reasoning-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-reasoning-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-reasoning-chip.inactive{opacity:.78;}
.composer-reasoning-chip:hover{color:var(--text);background-color:var(--hover-bg);}
@@ -1386,7 +1448,7 @@
.reasoning-option:hover{background:rgba(255,255,255,.07);}
.reasoning-option.selected{background:var(--accent-bg);}
/* Toolsets chip — session-level toolset override (#493) */
.composer-toolsets-wrap{position:relative;flex:0 1 auto;min-width:0;display:none;}
.composer-toolsets-wrap{display:none;}
.composer-toolsets-chip{display:inline-flex;align-items:center;gap:8px;max-width:180px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-toolsets-chip:hover{color:var(--text);background-color:var(--hover-bg);}
.composer-toolsets-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
@@ -1407,7 +1469,6 @@
.toolsets-apply-btn:hover{opacity:.9;}
.toolsets-clear-btn{background:transparent;color:var(--muted);border:1px solid var(--border2);}
.toolsets-clear-btn:hover{background:var(--hover-bg);color:var(--text);}
.composer-model-wrap{position:relative;flex:0 1 auto;min-width:0;}
.composer-model-chip{display:inline-flex;align-items:center;gap:8px;max-width:280px;padding:8px 10px 8px 12px;border-radius:999px;border:1px solid transparent;background-color:transparent;color:var(--muted);font-weight:500;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s;}
.composer-model-chip:hover{color:var(--text);background-color:var(--hover-bg);}
.composer-model-chip.active{color:var(--text);background:var(--accent-bg);border-color:var(--accent-bg);}
@@ -2294,6 +2355,15 @@ body.resizing .sidebar{transition:none!important;}
.tool-call-group:not(.tool-call-group-collapsed) .tool-call-group-chevron{transform:rotate(90deg);}
.tool-call-group-body{display:block;padding-left:var(--space-3);}
.tool-call-group.tool-call-group-collapsed .tool-call-group-body{display:none;}
.agent-activity-status{display:grid;grid-template-columns:18px minmax(0,1fr) auto;align-items:start;gap:var(--space-2);padding:5px 0;color:var(--muted);font-size:var(--font-size-xs);line-height:1.45;border-bottom:1px solid color-mix(in srgb,var(--border-subtle) 60%,transparent);}
.agent-activity-status:last-child{border-bottom:0;}
.agent-activity-status-icon{display:inline-flex;align-items:center;justify-content:center;min-height:18px;opacity:.72;color:var(--muted);}
.agent-activity-status-copy{display:flex;flex-direction:column;min-width:0;gap:1px;}
.agent-activity-status-label{color:var(--text);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.agent-activity-status-detail{color:var(--muted);opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.agent-activity-status-time{font-variant-numeric:tabular-nums;opacity:.55;white-space:nowrap;}
.agent-activity-status-waiting .agent-activity-status-label{color:var(--muted);}
.agent-activity-status-error .agent-activity-status-label{color:var(--error);}
.tool-call-group-label{font-weight:600;color:var(--muted);position:relative;display:inline-block;overflow:hidden;}
.tool-call-group[data-live-tool-call-group="1"] .tool-call-group-label{
color:var(--muted);
@@ -2911,6 +2981,10 @@ main.main.showing-logs > #mainLogs{display:flex;}
.mcp-status-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;}
.mcp-status-invalid_config,.mcp-status-unknown{background:rgba(239,68,68,.12);color:#f87171;}
.mcp-tool-count{color:var(--text);}
.mcp-toggle-btn{font-size:10px;font-weight:600;padding:2px 8px;border-radius:999px;border:1px solid transparent;cursor:pointer;transition:opacity .15s;}
.mcp-toggle-btn:hover{opacity:.8;}
.mcp-toggle-enabled{background:rgba(34,197,94,.15);color:#4ade80;border-color:rgba(34,197,94,.3);}
.mcp-toggle-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;border-color:rgba(161,161,170,.25);}
.mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;}
.mcp-tool-search{width:100%;margin:0 0 8px 0;padding:8px 10px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:8px;font-size:12px;outline:none;}
.mcp-tool-search:focus{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-bg-soft);}
@@ -3783,6 +3857,19 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.detail-run-body{display:none;margin-top:6px;font-size:12px;color:var(--muted);white-space:pre-wrap;line-height:1.5;max-height:260px;overflow-y:auto;background:var(--sidebar);border:1px solid var(--border);border-radius:6px;padding:8px 10px;}
.detail-run-item.open .detail-run-body{display:block;}
.detail-run-body.expanded{max-height:none;overflow-y:visible;}
.workspace-panel-tabs{display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid var(--border);}
.workspace-panel-tab{flex:1;border:1px solid transparent;background:transparent;color:var(--muted);border-radius:7px;padding:5px 8px;font-size:12px;cursor:pointer;}
.workspace-panel-tab.active{background:var(--surface-subtle);color:var(--text);border-color:var(--border2);}
.workspace-artifacts{flex:1;overflow:auto;padding:8px;}
.workspace-artifact-empty{padding:16px 8px;color:var(--muted);font-size:12px;line-height:1.5;text-align:center;}
.workspace-artifact-item{display:block;width:100%;text-align:left;background:var(--surface-subtle);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;margin-bottom:6px;cursor:pointer;}
.workspace-artifact-item:hover{border-color:var(--accent);}
.workspace-artifact-path{font-family:'SF Mono',ui-monospace,monospace;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.workspace-artifact-meta{margin-top:3px;color:var(--muted);font-size:11px;}
.workspace-artifacts-count{opacity:.65;font-size:11px;}
.rightpanel[data-active-tab="artifacts"] .breadcrumb-bar,.rightpanel[data-active-tab="artifacts"] .file-tree,.rightpanel[data-active-tab="artifacts"] #wsEmptyState{display:none!important;}
.rightpanel[data-active-tab="artifacts"] .workspace-artifacts{display:block;}
.rightpanel[data-active-tab="files"] .workspace-artifacts{display:none!important;}
.cron-run-usage-strip{display:inline-flex;align-items:center;gap:4px;margin-left:8px;color:var(--text-secondary);font-size:11px;opacity:.72;white-space:nowrap;}
.cron-run-usage-footer{display:flex;margin:8px 0 0 0;padding-top:8px;border-top:1px solid var(--border-subtle);}
.cron-item.active,.ws-row.active,.profile-card.active{background:var(--accent-bg);}
@@ -4185,7 +4272,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-run-dispatch-btn:hover{
background:color-mix(in srgb,var(--accent,#FFD700) 24%,transparent);
}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;margin-bottom:6px;}
.kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;}
.kanban-status-actions .btn{font-size:11px;padding:4px 8px;}
/* Generic styled buttons used throughout the Kanban panel. The Kanban PR
@@ -4255,7 +4342,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-detail-section h3{font-size:12px;font-weight:650;color:var(--text);margin:0 0 8px;}
.kanban-detail-row{padding:8px 0;border-top:1px solid var(--border);}
.kanban-detail-row:first-of-type{border-top:0;padding-top:0;}
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;white-space:pre-wrap;}
.kanban-detail-row-main{font-size:12px;color:var(--text);line-height:1.45;}.kanban-detail-row-main .hermes-kanban-md p:last-child{margin-bottom:0;}
.kanban-detail-row-meta{font-size:10px;color:var(--muted);margin-top:4px;}
.kanban-detail-pre{font-size:11px;line-height:1.4;white-space:pre-wrap;word-break:break-word;background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:6px;margin:6px 0 0;color:var(--muted);}
.kanban-detail-empty{font-size:12px;color:var(--muted);}
@@ -4287,7 +4374,7 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
.kanban-card-stale-amber{border-color:rgba(245,197,66,.55)}
.kanban-card-stale-red{border-color:rgba(255,95,95,.65)}
.kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px}
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}.hermes-kanban-md h1,.hermes-kanban-md h2,.hermes-kanban-md h3,.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{margin:10px 0 4px;font-weight:650;color:var(--text)}.hermes-kanban-md h1{font-size:15px}.hermes-kanban-md h2{font-size:14px}.hermes-kanban-md h3{font-size:13px}.hermes-kanban-md h4,.hermes-kanban-md h5,.hermes-kanban-md h6{font-size:12px}.hermes-kanban-md ul,.hermes-kanban-md ol{margin:4px 0;padding-left:20px}.hermes-kanban-md li{margin:2px 0}.hermes-kanban-md li.checked{opacity:.6}.hermes-kanban-md li input[type=checkbox]{margin:0 4px 0 0;vertical-align:middle}.hermes-kanban-md table{border-collapse:collapse;margin:6px 0;font-size:11px;width:100%}.hermes-kanban-md td{border:1px solid var(--border);padding:4px 6px;vertical-align:top}.hermes-kanban-md blockquote{margin:4px 0;padding:2px 8px;border-left:3px solid var(--accent);color:var(--muted);font-size:12px}.hermes-kanban-md pre{background:var(--input-bg);border:1px solid var(--border);border-radius:6px;padding:8px;margin:6px 0;overflow-x:auto;font-size:11px;line-height:1.4;color:var(--text)}.hermes-kanban-md hr{border:none;border-top:1px solid var(--border);margin:8px 0}
@media (max-width: 640px){
.kanban-board{scroll-snap-type:x mandatory;}
+3 -2
View File
@@ -23,6 +23,7 @@ const CACHE_NAME = 'hermes-shell-__WEBUI_VERSION__';
const VQ = '?v=__WEBUI_VERSION__';
const SHELL_ASSETS = [
'./static/style.css' + VQ,
'./static/pwa-startup.js' + VQ,
'./static/boot.js' + VQ,
'./static/ui.js' + VQ,
'./static/messages.js' + VQ,
@@ -115,7 +116,7 @@ self.addEventListener('fetch', (event) => {
// freshly set login cookie until the user manually refreshes.
if (event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request).then((response) => {
fetch(new Request(event.request, { cache: 'no-store' })).then((response) => {
if (
event.request.method === 'GET' &&
response.status === 200 &&
@@ -152,7 +153,7 @@ self.addEventListener('fetch', (event) => {
// but avoids executing stale JS/CSS after a local hotfix when WEBUI_VERSION
// has not changed yet (e.g. before a guarded restart updates the ?v token).
event.respondWith(
fetch(event.request).then((response) => {
fetch(new Request(event.request, { cache: 'no-store' })).then((response) => {
if (
event.request.method === 'GET' &&
response.status === 200
+164 -23
View File
@@ -779,6 +779,33 @@ function _modelStateForSelect(sel, modelId){
const provider=String(_getOptionProviderId(opt)||'').trim();
return {model:value,model_provider:(provider&&provider!=='default')?provider:null};
}
function _captureModelDropdownSelection(sel){
if(!sel||!sel.value) return null;
try{
const state=_modelStateForSelect(sel,sel.value);
if(state&&state.model) return state;
}catch(_){}
return {model:String(sel.value||''),model_provider:null};
}
function _reconcileModelDropdownSelection(sel,data,previousState,opts){
if(!sel) return null;
const activeSession=(typeof S!=='undefined'&&S&&S.session)?S.session:null;
// Fresh boot is the only path where the profile/server default intentionally
// beats a browser-persisted or static fallback value. Every other model-list
// rebuild should preserve the loaded session model or the user's current
// in-page selection when it still exists in the refreshed catalog.
const shouldApplyBootDefault=!!(opts&&opts.preferProfileDefaultOnFreshBoot);
if(shouldApplyBootDefault && data&&data.default_model && !(activeSession&&activeSession.model)){
return _applyModelToDropdown(data.default_model,sel,data.active_provider||null);
}
if(activeSession&&activeSession.model){
return _applyModelToDropdown(activeSession.model,sel,activeSession.model_provider||null);
}
if(previousState&&previousState.model){
return _applyModelToDropdown(previousState.model,sel,previousState.model_provider||null);
}
return null;
}
function _providerQualifiedModelValueForSelect(sel, modelId){
return _modelStateForSelect(sel,modelId).model;
}
@@ -988,7 +1015,7 @@ function _applySessionModelFallback(sel){
return null;
}
async function populateModelDropdown(){
async function populateModelDropdown(opts={}){
const sel=$('modelSelect');
if(!sel) return;
try{
@@ -1046,6 +1073,7 @@ async function populateModelDropdown(){
: _synthGroupsFromConfigured();
if(!groups.length) return; // no server groups and no configured fallback
const previousSelection=_captureModelDropdownSelection(sel);
// Clear existing options
sel.innerHTML='';
_dynamicModelLabels={};
@@ -1078,12 +1106,7 @@ async function populateModelDropdown(){
}
sel.appendChild(og);
}
// 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);
}
_reconcileModelDropdownSelection(sel,data,previousSelection,opts);
if(typeof syncModelChip==='function') syncModelChip();
const dd=$('composerModelDropdown');
if(dd&&dd.classList.contains('open')&&typeof renderModelDropdown==='function'){
@@ -2208,6 +2231,7 @@ function _startCompressionElapsedTimer(){if(!_compressionElapsedTimer)_compressi
function _clearCompressionElapsedTimer(){if(_compressionElapsedTimer){clearInterval(_compressionElapsedTimer);_compressionElapsedTimer=null;}}
let _activityElapsedTimer=null;
let _activityElapsedTimerGroup=null;
function _activityNowSeconds(){return Date.now()/1000;}
function _activityElapsedStartedAt(group){
if(!group)return null;
const raw=(group.dataset&&group.dataset.turnStartedAt!==undefined&&group.dataset.turnStartedAt!=='')
@@ -2219,7 +2243,52 @@ function _activityElapsedStartedAt(group){
function _activityElapsedLabel(group){
const started=_activityElapsedStartedAt(group);
if(!started)return'';
return _formatActiveElapsedTimer((Date.now()/1000)-started);
return _formatActiveElapsedTimer(_activityNowSeconds()-started);
}
function _activityMarkObserved(group, ts){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const stamp=Number(ts||_activityNowSeconds());
if(Number.isFinite(stamp)&&stamp>0) group.setAttribute('data-last-activity-at',String(stamp));
}
function _activityLastObservedAge(group){
const stamp=Number(group&&group.getAttribute('data-last-activity-at'));
if(!Number.isFinite(stamp)||stamp<=0)return null;
return Math.max(0,_activityNowSeconds()-stamp);
}
function _activityClockLabel(ts){
const stamp=Number(ts||_activityNowSeconds());
if(!Number.isFinite(stamp)||stamp<=0)return'';
try{return new Date(stamp*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});}catch(_){return'';}
}
function _activityStatusNode({kind='info',label='',detail='',status='done',ts=null,id=''}){
const row=document.createElement('div');
row.className=`agent-activity-status agent-activity-status-${kind} agent-activity-status-${status}`;
if(id) row.setAttribute('data-activity-event-id',id);
if(ts) row.setAttribute('data-activity-at',String(ts));
const iconMap={run:li('play',13),model:li('bot',13),waiting:'<span class="tool-card-running-dot"></span>',thinking:li('lightbulb',13),tool:li('wrench',13),done:li('check',13),warning:li('alert-triangle',13)};
row.innerHTML=`<span class="agent-activity-status-icon">${iconMap[kind]||li('clock',13)}</span><span class="agent-activity-status-copy"><span class="agent-activity-status-label">${esc(label)}</span>${detail?`<span class="agent-activity-status-detail">${esc(detail)}</span>`:''}</span><span class="agent-activity-status-time">${esc(_activityClockLabel(ts))}</span>`;
return row;
}
function _appendActivityEvent(group, event){
if(!group)return null;
const body=group.querySelector('.tool-call-group-body');
if(!body)return null;
const eventId=event&&event.id;
let row=eventId?body.querySelector(`.agent-activity-status[data-activity-event-id="${CSS.escape(eventId)}"]`):null;
const next=_activityStatusNode(event||{});
if(row){row.replaceWith(next);row=next;}
else{body.appendChild(next);row=next;}
_activityMarkObserved(group,event&&event.ts);
return row;
}
function _ensureLiveActivityBaseline(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const started=_activityElapsedStartedAt(group)||_activityNowSeconds();
if(!group.getAttribute('data-turn-started-at')) group.setAttribute('data-turn-started-at',String(started));
if(!group.getAttribute('data-last-activity-at')) group.setAttribute('data-last-activity-at',String(started));
_appendActivityEvent(group,{id:'run-started',kind:'run',label:'Run started',detail:'Observable activity will appear here as the agent works.',status:'done',ts:started});
const modelLabel=(S.session&&S.session.model)?getModelLabel(S.session.model):'';
if(modelLabel)_appendActivityEvent(group,{id:'run-model',kind:'model',label:`Model: ${modelLabel}`,detail:S.activeProfile&&S.activeProfile!=='default'?`Profile: ${S.activeProfile}`:'',status:'done',ts:started});
}
function _setActivityElapsedStartedAt(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
@@ -2240,8 +2309,10 @@ function _updateActiveActivityElapsedTimer(){
group.removeAttribute('data-active-turn-elapsed');
}
if(durationEl){
durationEl.textContent=label?`Working ${label}`:'';
durationEl.style.display=label?'':'none';
const activeText=label?`Working for ${label}`:'';
const progressText=_activityLiveProgressLabel(group);
durationEl.textContent=[progressText, activeText].filter(Boolean).join(' · ');
durationEl.style.display=durationEl.textContent?'':'none';
}
}
function _startActivityElapsedTimer(group){
@@ -2889,7 +2960,7 @@ function renderMd(raw){
t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]);
// Stash [label](url) links before autolink so the URL in href= is not re-linked
const _link_stash=[];
t=t.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${_markdownHref(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);return `\x00L${_link_stash.length-1}\x00`;});
t=t.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`<a href="${_markdownHref(u)}" target="_blank" rel="noopener">${esc(lb)}</a>`);return `\x00L${_link_stash.length-1}\x00`;});
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `<a href="${clean}" target="_blank" rel="noopener">${esc(clean)}</a>${trail}`;});
t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]);
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
@@ -2982,7 +3053,7 @@ function renderMd(raw){
// Stash existing <a> tags first to avoid re-linking already-linked URLs.
const _a_stash=[];
s=s.replace(/(<a\b[^>]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
s=s.replace(/\[([^\]]+)\]\(((?:https?|file):\/\/[^\)]+)\)/g,(_,label,url)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
s=s.replace(/\[([^\]]+)\]\(((?:https?:\/\/|file:\/\/|mailto:|tel:)[^\s\)]+)\)/g,(_,label,url)=>`<a href="${_markdownHref(url)}" target="_blank" rel="noopener">${esc(label)}</a>`);
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
// Restore raw <pre> only after markdown rewrites so literal preformatted
// content stays placeholder-protected, then let the sanitizer normalize tags.
@@ -3016,6 +3087,7 @@ function renderMd(raw){
if(!compact) return false;
if(/^(javascript|data|vbscript):/i.test(compact)) return false;
if(/^https?:\/\//i.test(raw)) return true;
if(/^(mailto:|tel:)/i.test(raw)) return true;
if(img && /^api\//i.test(raw)) return true;
if(!img && (/^api\//i.test(raw) || /^#/.test(raw))) return true;
return false;
@@ -5219,7 +5291,10 @@ function ensureActivityGroup(inner, opts){
}else if(activityKey&&!group.getAttribute('data-activity-disclosure-key')){
group.setAttribute('data-activity-disclosure-key',activityKey);
}
if(live) _setActivityElapsedStartedAt(group);
if(live){
_setActivityElapsedStartedAt(group);
_ensureLiveActivityBaseline(group);
}
_syncToolCallGroupSummary(group);
if(live) _startActivityElapsedTimer(group);
return group;
@@ -5940,7 +6015,7 @@ function renderMessages(options){
const msgCount=S.messages.length;
if(sid!==_messageRenderWindowSid) _resetMessageRenderWindow(sid);
const renderWindowSize=_currentMessageRenderWindowSize();
const renderSignature=_messageRenderCacheSignature();
let cachedRenderSignature=null;
const hasTransientTranscriptUi=!!(
(window._compressionUi&&(!window._compressionUi.sessionId||window._compressionUi.sessionId===sid)) ||
(window._handoffUi&&(!window._handoffUi.sessionId||window._handoffUi.sessionId===sid))
@@ -5955,6 +6030,8 @@ function renderMessages(options){
// cross-channel handoff summaries; otherwise the cached transcript returns
// before those cards can be inserted.
if(sid&&sid!==_sessionHtmlCacheSid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){
const renderSignature=_messageRenderCacheSignature();
cachedRenderSignature=renderSignature;
const cached=_sessionHtmlCache.get(sid);
if(cached&&cached.msgCount===msgCount&&cached.renderWindowSize===renderWindowSize&&cached.signature===renderSignature){
inner.innerHTML=cached.html;
@@ -6076,6 +6153,13 @@ function renderMessages(options){
const assistantSegments=new Map();
const assistantThinking=new Map();
const userRows=new Map();
const toolCallAssistantIdxs=new Set();
if(Array.isArray(S.toolCalls)){
for(const tc of S.toolCalls){
if(!tc) continue;
toolCallAssistantIdxs.add(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1);
}
}
// Windowed render loop replaces the legacy full loop:
// for(let vi=0;vi<visWithIdx.length;vi++)
for(let vi=0;vi<renderVisWithIdx.length;vi++){
@@ -6098,7 +6182,7 @@ function renderMessages(options){
thinkingText=content.filter(p=>p&&(p.type==='thinking'||p.type==='reasoning')).map(p=>p.thinking||p.reasoning||p.text||'').join('\n');
content=content.filter(p=>p&&p.type==='text').map(p=>p.text||p.content||'').join('\n');
}
if(!thinkingText && m.reasoning) thinkingText=m.reasoning;
if(!thinkingText && (m.reasoning_content || m.reasoning)) thinkingText=m.reasoning_content || m.reasoning;
if(!thinkingText && typeof content==='string'){
const thinkMatch=content.match(/^\s*<think>([\s\S]*?)<\/think>\s*/);
if(thinkMatch){
@@ -6322,11 +6406,12 @@ function renderMessages(options){
// a display list from per-message tool_calls (OpenAI format) stored in each
// assistant message. This covers the reload case described in issue #140.
if(!S.busy && (!S.toolCalls||!S.toolCalls.length)){
// Pass 1: index tool outputs by tool_call_id / tool_use_id so the
// Index tool outputs by tool_call_id / tool_use_id so the
// fallback-built cards carry their result snippet (not just the command).
// Without this step CLI-origin sessions reload with empty tool cards.
const resultsByTid={};
S.messages.forEach(m=>{
const fallbackToolSources=[];
S.messages.forEach((m,rawIdx)=>{
if(!m) return;
// OpenAI / Hermes CLI format: role=tool with tool_call_id
if(m.role==='tool'){
@@ -6346,10 +6431,14 @@ function renderMessages(options){
resultsByTid[tid]=_cliToolResultSnippet(raw);
});
}
if(m.role==='assistant'){
const hasTopLevelToolCalls=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasContentToolUse=Array.isArray(m.content)&&m.content.some(p=>p&&typeof p==='object'&&p.type==='tool_use');
if(hasTopLevelToolCalls||hasContentToolUse) fallbackToolSources.push({m,rawIdx});
}
});
const derived=[];
S.messages.forEach((m,rawIdx)=>{
if(m.role!=='assistant') return;
fallbackToolSources.forEach(({m,rawIdx})=>{
// OpenAI format: top-level tool_calls field on the assistant message
(m.tool_calls||[]).forEach(tc=>{
if(!tc||typeof tc!=='object') return;
@@ -6496,7 +6585,7 @@ function renderMessages(options){
const hasTurnUsage=!!msg._turnUsage;
const compactActivityForMessage=isSimplifiedToolCalling()&&(
assistantThinking.has(mi)||
(S.toolCalls||[]).some(tc=>tc&&(tc.assistant_msg_idx!==undefined?tc.assistant_msg_idx:-1)===mi)
toolCallAssistantIdxs.has(mi)
);
const durationText=compactActivityForMessage?'':_formatTurnDuration(msg._turnDuration);
if(!hasTurnUsage&&!durationText&&!gatewayText&&!failoverText&&!modelWarningText) continue;
@@ -6563,10 +6652,11 @@ function renderMessages(options){
if(typeof _applyMediaPlaybackPreferences==='function') _applyMediaPlaybackPreferences(inner);
// Populate session cache so switching back here skips a full rebuild.
_sessionHtmlCacheSid=sid;
if(sid&&!hasTransientTranscriptUi){
if(sid&&!INFLIGHT[sid]&&!hasTransientTranscriptUi){
const _html=inner.innerHTML;
// Only cache sessions with <300KB rendered HTML; evict oldest beyond 8 sessions.
if(_html.length<300_000){
const renderSignature=cachedRenderSignature===null?_messageRenderCacheSignature():cachedRenderSignature;
_sessionHtmlCache.set(sid,{html:_html,msgCount,renderWindowSize,signature:renderSignature});
if(_sessionHtmlCache.size>8){_sessionHtmlCache.delete(_sessionHtmlCache.keys().next().value);}
}
@@ -6657,7 +6747,9 @@ function _syncToolCallGroupSummary(group){
const label=group.querySelector('.tool-call-group-label');
const durationEl=group.querySelector('.tool-call-group-duration');
if(label){
if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`;
if(group.getAttribute('data-live-tool-call-group')==='1'){
label.textContent=toolCount?`Activity: ${toolCount} tool${toolCount===1?'':'s'}`:'Activity · Running';
}else if(toolCount) label.textContent=`Activity: ${toolCount} tool${toolCount===1?'':'s'}`;
else label.textContent='Activity';
label.setAttribute('data-sweep-label', label.textContent);
}
@@ -6691,9 +6783,14 @@ function _activityProgressLabelForToolName(name){
function _activityLiveProgressLabel(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1') return '';
const idleAge=_activityLastObservedAge(group);
if(idleAge!==null&&idleAge>=90) return `No recent activity for ${_formatActiveElapsedTimer(idleAge)}`;
const running=group.querySelector('.tool-card.tool-card-running .tool-card-name');
const latest=running || Array.from(group.querySelectorAll('.tool-card-name')).pop();
return _activityProgressLabelForToolName(latest?latest.textContent:'');
const waiting=group.querySelector('.agent-activity-status-waiting .agent-activity-status-label');
if(latest) return _activityProgressLabelForToolName(latest.textContent);
if(waiting&&waiting.textContent) return waiting.textContent;
return 'Starting agent';
}
// ── Live tool card helpers (called during SSE streaming) ──
@@ -6759,6 +6856,19 @@ function appendLiveToolCard(tc){
const anchor=children.filter(el=>el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking')).pop();
const group=ensureActivityGroup(inner,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
const body=group.querySelector('.tool-call-group-body');
const toolName=_toolDisplayName(tc);
const toolEventId=tid?`tool-${tid}`:`tool-${String(tc.name||'tool').replace(/[^a-z0-9_-]/gi,'_')}`;
const toolDone=tc.done!==false;
_appendActivityEvent(group,{
id:toolEventId,
kind:'tool',
label:toolDone?`Tool finished: ${toolName}`:`Running tool: ${toolName}`,
detail:tc.preview||tc.snippet||'',
status:toolDone?(tc.is_error?'error':'done'):'waiting',
ts:_activityNowSeconds(),
});
const waiting=body.querySelector('.agent-activity-status[data-activity-event-id="thinking-placeholder"] .agent-activity-status-label');
if(waiting&&!toolDone) waiting.textContent='Waiting on tool result';
// Update existing card in place (tool_complete after tool_start)
if(tid){
const existing=body.querySelector(`.tool-card-row[data-live-tid="${CSS.escape(tid)}"]`);
@@ -7583,6 +7693,7 @@ function appendThinking(text='', options){
return;
}
const thinkingText=String(text||'').trim()||'Thinking…';
const cleanThinking=_sanitizeThinkingDisplayText(thinkingText);
const allChildren=Array.from(blocks.children);
const anchor=allChildren.filter(el=>
el.id!=='toolRunningRow' &&
@@ -7591,6 +7702,20 @@ function appendThinking(text='', options){
const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()});
const body=group&&group.querySelector('.tool-call-group-body');
if(!body) return;
if(!cleanThinking||cleanThinking==='Thinking…'){
const label=body.querySelector('.tool-card.tool-card-running')?'Waiting on tool result':'Waiting on model';
const detail=body.querySelector('.tool-card-row')
? 'The agent is running; tool results and response text will appear here.'
: 'No tool activity has been reported yet.';
_appendActivityEvent(group,{id:'thinking-placeholder',kind:'waiting',label,detail,status:'waiting',ts:_activityNowSeconds()});
const active=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
if(active) active.removeAttribute('data-thinking-active');
_syncToolCallGroupSummary(group);
scrollIfPinned();
return;
}
const placeholder=body.querySelector('.agent-activity-status[data-activity-event-id="thinking-placeholder"]');
if(placeholder) placeholder.remove();
let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]');
if(!row){
const thinkingCards=Array.from(body.querySelectorAll('.agent-activity-thinking'));
@@ -7604,6 +7729,7 @@ function appendThinking(text='', options){
}else{
_renderThinkingInto(row,thinkingText);
}
_activityMarkObserved(group);
_syncToolCallGroupSummary(group);
scrollIfPinned();
if(_scrollPinned){
@@ -7918,6 +8044,12 @@ function _showWorkspaceRootContextMenu(e){
catch(err){showToast(t('reveal_failed')+(err.message||err));}
}));
menu.appendChild(_workspaceContextMenuItem(t('open_in_vscode'),async()=>{
menu.remove();
try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:'.'})});}
catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}
}));
menu.appendChild(_workspaceContextMenuItem(t('copy_file_path'),async()=>{
menu.remove();
try{
@@ -8163,6 +8295,15 @@ function _showFileContextMenu(e, item){
revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}};
menu.appendChild(revealItem);
// Open in VS Code (#2735)
const vscodeItem=document.createElement('div');
vscodeItem.textContent=t('open_in_vscode');
vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)';
vscodeItem.onmouseleave=()=>vscodeItem.style.background='';
vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}};
menu.appendChild(vscodeItem);
// Copy file path — resolves the absolute on-disk path on the server (so the
// user gets the full /home/.../workspace/foo.py rather than the relative
// path the file tree shows) and writes it to the OS clipboard. Useful for
+190 -5
View File
@@ -103,16 +103,162 @@ function _restoreExpandedDirs(){
}catch(e){S._expandedDirs=new Set();}
}
let _workspacePanelActiveTab = 'files';
let _renderSessionArtifactsTimer = null;
function _setWorkspacePanelTabDataset(){
const panel = document.querySelector('.rightpanel');
if(panel) panel.dataset.activeTab = _workspacePanelActiveTab;
}
function scheduleRenderSessionArtifacts(){
if(_renderSessionArtifactsTimer) clearTimeout(_renderSessionArtifactsTimer);
_renderSessionArtifactsTimer = setTimeout(()=>{
_renderSessionArtifactsTimer = null;
renderSessionArtifacts();
}, 100);
}
if(typeof document !== 'undefined'){
if(document.readyState === 'loading') document.addEventListener('DOMContentLoaded', _setWorkspacePanelTabDataset, {once:true});
else _setWorkspacePanelTabDataset();
}
function switchWorkspacePanelTab(tab){
_workspacePanelActiveTab = tab === 'artifacts' ? 'artifacts' : 'files';
_setWorkspacePanelTabDataset();
const filesTab = $('workspaceFilesTab');
const artifactsTab = $('workspaceArtifactsTab');
if(filesTab){
filesTab.classList.toggle('active', _workspacePanelActiveTab === 'files');
filesTab.setAttribute('aria-selected', _workspacePanelActiveTab === 'files' ? 'true' : 'false');
}
if(artifactsTab){
artifactsTab.classList.toggle('active', _workspacePanelActiveTab === 'artifacts');
artifactsTab.setAttribute('aria-selected', _workspacePanelActiveTab === 'artifacts' ? 'true' : 'false');
}
const artifacts = $('workspaceArtifacts');
if(artifacts) artifacts.hidden = _workspacePanelActiveTab !== 'artifacts';
if(_workspacePanelActiveTab === 'artifacts') renderSessionArtifacts();
}
const ARTIFACT_IGNORE_RE = /(^|\/)(?:\.git|\.hg|\.svn|node_modules|\.venv|venv|__pycache__|dist|build|\.next|\.cache)(?:\/|$)/;
// Canonical Hermes mutators plus MCP filesystem aliases that can create/edit files.
const ARTIFACT_MUTATION_TOOLS = new Set(['write_file','patch','edit_file','create_file','mcp_filesystem_write_file','mcp_filesystem_edit_file']);
function _normalizeArtifactPath(path){
if(!path) return '';
path = String(path).trim().replace(/[\`"'<>),.;:]+$/g,'').replace(/^[\`"'(<]+/g,'');
if(!path || path.length > 240 || path.includes('://')) return '';
if(ARTIFACT_IGNORE_RE.test(path)) return '';
if(!/[./]/.test(path)) return '';
return path;
}
function _artifactCandidatesFromText(text){
if(!text || typeof text !== 'string') return [];
const out = [];
const seen = new Set();
const add = (path) => {
path = _normalizeArtifactPath(path);
if(!path || seen.has(path)) return;
seen.add(path); out.push({path, kind:'diff'});
};
// Fallback text mining is intentionally narrow: only diff/patch fences imply
// the session changed a file. Prose mentions such as "edited package.json" are
// too noisy for an Artifacts list that should track write/edit outputs.
const fenced = /```(?:diff|patch)\s*\n[\s\S]*?```/gi;
let m;
while((m = fenced.exec(text))){
const block = m[0];
const fm = block.match(/(?:^|\n)(?:\+\+\+|---)\s+(?:[ab]\/)?([^\n\t]+)/);
if(fm) add(fm[1].trim());
}
return out;
}
function _artifactCandidatesFromToolCall(tc){
if(!tc) return [];
const name = String(tc.name || '').replace(/^functions\./,'');
const args = tc.arguments || tc.args || tc.input || {};
const result = tc.result || tc.output || tc.snippet || '';
const out = [];
const add = (path, source=name || 'tool') => {
path = _normalizeArtifactPath(path);
if(path) out.push({path, kind:source});
};
if(args && typeof args === 'object'){
for(const key of ['path','file_path','source','destination']) add(args[key]);
if(Array.isArray(args.paths)) args.paths.forEach(p=>add(p));
if(Array.isArray(args.edits)) args.edits.forEach(e=>add(e&&e.path));
}
const resultText = typeof result === 'string' ? result : (result ? JSON.stringify(result) : '');
// Tool results may include unified diffs from patch-style tools; scan those
// narrowly after structured args so diff headers can still contribute paths.
for(const a of _artifactCandidatesFromText(resultText)) out.push(a);
if(!out.length && ARTIFACT_MUTATION_TOOLS.has(name)){
const argsText = typeof args === 'string' ? args : JSON.stringify(args || {});
for(const a of _artifactCandidatesFromText(argsText)) out.push(a);
}
return out;
}
function collectSessionArtifacts(){
const items = [];
const seen = new Set();
const push = (path, source) => {
path = _normalizeArtifactPath(path);
if(!path || seen.has(path)) return;
seen.add(path); items.push({path, source});
};
for(const tc of (S.toolCalls || [])){
for(const a of _artifactCandidatesFromToolCall(tc)) push(a.path, a.kind || tc.name || 'tool');
}
for(const msg of (S.messages || [])){
const text = msg && (msg.content || msg.text || msg.message || '');
for(const a of _artifactCandidatesFromText(text)) push(a.path, a.kind);
}
return items.slice(0, 50);
}
function renderSessionArtifacts(){
const root = $('workspaceArtifacts');
const count = $('workspaceArtifactsCount');
if(!root) return;
const items = collectSessionArtifacts();
if(count) count.textContent = String(items.length);
if(!S.session){
root.innerHTML = '<div class="workspace-artifact-empty">Open a conversation to see files changed in this session.</div>';
return;
}
if(!items.length){
root.innerHTML = '<div class="workspace-artifact-empty">No artifacts detected yet. Files created or edited during this session will appear here.</div>';
return;
}
root.innerHTML = items.map(item => `<button type="button" class="workspace-artifact-item" data-artifact-path="${esc(item.path)}" onclick="openArtifactPath(this.dataset.artifactPath)"><div class="workspace-artifact-path">${esc(item.path)}</div><div class="workspace-artifact-meta">${esc(item.source || 'session')}</div></button>`).join('');
}
function openArtifactPath(path){
if(!path) return;
switchWorkspacePanelTab('files');
const rel = path.replace(/^~\//,'').replace(/^\.\//,'');
openFile(rel);
}
async function loadDir(path){
if(!S.session)return;
const sessionId=S.session.session_id;
try{
if(!path||path==='.'){
S._dirCache={};
_restoreExpandedDirs(); // restore per-workspace expanded state on root load
}
S.currentDir=path||'.';
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
const data=await api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`);
if(!S.session||S.session.session_id!==sessionId)return;
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
// #2673 — refresh Artifacts tab when its source data (the file tree) updates.
if(typeof renderSessionArtifacts==='function') renderSessionArtifacts();
// Pre-fetch contents of restored expanded dirs so they render without a second click
// (parallelized — avoids serial waterfall when multiple dirs are expanded)
if(!path||path==='.'){
@@ -120,10 +266,11 @@ async function loadDir(path){
const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]);
if(pending.length){
const results=await Promise.all(pending.map(dirPath=>
api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`)
api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(dirPath)}`)
.then(dc=>({dirPath,entries:dc.entries||[]}))
.catch(()=>({dirPath,entries:[]}))
));
if(!S.session||S.session.session_id!==sessionId)return;
for(const {dirPath,entries} of results) S._dirCache[dirPath]=entries;
}
if(expanded.size>0)renderFileTree();
@@ -143,8 +290,10 @@ async function loadDir(path){
async function _refreshGitBadge(){
const badge=$('gitBadge');
if(!badge||!S.session)return;
const sessionId=S.session.session_id;
try{
const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`);
const data=await api(`/api/git-info?session_id=${encodeURIComponent(sessionId)}`);
if(!S.session||S.session.session_id!==sessionId)return;
if(data.git&&data.git.is_git){
const g=data.git;
let text=g.branch||'git';
@@ -158,7 +307,10 @@ async function _refreshGitBadge(){
badge.style.display='none';
badge.textContent='';
}
}catch(e){badge.style.display='none';}
}catch(e){
if(!S.session||S.session.session_id!==sessionId)return;
badge.style.display='none';
}
}
function navigateUp(){
@@ -175,6 +327,8 @@ const HTML_EXTS = new Set(['.html','.htm']);
const PDF_EXTS = new Set(['.pdf']);
const AUDIO_EXTS = new Set(['.mp3','.wav','.m4a','.aac','.ogg','.oga','.opus','.flac']);
const VIDEO_EXTS = new Set(['.mp4','.mov','.m4v','.webm','.ogv','.avi','.mkv']);
const MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024;
const MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500;
// Binary formats that should download rather than preview
const DOWNLOAD_EXTS = new Set([
'.docx','.doc','.xlsx','.xls','.pptx','.ppt','.odt','.ods','.odp',
@@ -186,6 +340,31 @@ const DOWNLOAD_EXTS = new Set([
function fileExt(p){ const i=p.lastIndexOf('.'); return i>=0?p.slice(i).toLowerCase():''; }
function markdownPreviewByteLength(content){
const text=String(content||'');
if(typeof Blob==='function') return new Blob([text]).size;
if(typeof TextEncoder==='function') return new TextEncoder().encode(text).length;
return unescape(encodeURIComponent(text)).length;
}
function markdownPreviewLineCount(content){
const text=String(content||'');
if(!text) return 1;
return text.split('\n').length;
}
function shouldRenderMarkdownPreviewAsPlainText(content){
return markdownPreviewByteLength(content)>MD_PREVIEW_RICH_RENDER_MAX_BYTES
|| markdownPreviewLineCount(content)>MD_PREVIEW_RICH_RENDER_MAX_LINES;
}
function largeMarkdownPlainTextStatus(content){
const bytes=markdownPreviewByteLength(content);
const lines=markdownPreviewLineCount(content);
const sizeLabel=bytes>=1024?`${Math.round(bytes/1024)} KB`:`${bytes} B`;
return `Large markdown file (${sizeLabel}, ${lines} lines) shown as plain text. Click Edit to view raw.`;
}
let _previewCurrentPath = ''; // relative path of currently previewed file
let _previewCurrentMode = ''; // 'code' | 'md' | 'image' | 'html' | 'pdf' | 'audio' | 'video'
let _previewDirty = false; // true when edits are unsaved
@@ -317,8 +496,14 @@ async function openFile(path){
// Markdown: fetch text, render with renderMd, display as formatted HTML
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
showPreview('md');
_previewRawContent = data.content;
if(shouldRenderMarkdownPreviewAsPlainText(data.content)){
showPreview('code');
$('previewCode').textContent=data.content;
setStatus(largeMarkdownPlainTextStatus(data.content));
return;
}
showPreview('md');
$('previewMd').innerHTML=renderMd(data.content);
requestAnimationFrame(()=>{if(typeof renderKatexBlocks==='function')renderKatexBlocks();});
}catch(e){setStatus(t('file_open_failed'));}
+1
View File
@@ -44,6 +44,7 @@ PREFERENCE_FIELDS_AUTOSAVE = [
("settingsShowPreviousMessagingSessions", "show_previous_messaging_sessions"),
("settingsSyncInsights", "sync_to_insights"),
("settingsCheckUpdates", "check_for_updates"),
("settingsIgnoreAgentUpdates", "ignore_agent_updates"),
("settingsSoundEnabled", "sound_enabled"),
("settingsNotificationsEnabled", "notifications_enabled"),
("settingsSidebarDensity", "sidebar_density"),
+1 -1
View File
@@ -333,7 +333,7 @@ def test_panels_js_uses_locked_placeholder_i18n_key():
# (en/es/de/zh/zh-Hant/ru/ja/fr/pt). The repo currently ships 9 locales but
# substitutes 'ko' for 'fr' — we test what the repo actually has, not what the
# issue body lists, so a future addition of fr won't fail the suite either.
EXPECTED_LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
EXPECTED_LOCALES = ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
def _locale_block(locale_key: str) -> str:
@@ -91,3 +91,33 @@ def test_reconnect_settled_and_error_paths_keep_cleanup_session_scoped():
assert "stopApprovalPolling();stopClarifyPolling();" not in combined
assert "renderSessionList();setBusy(false)" not in combined
assert "_setActivePaneIdleIfOwner" in combined
def test_stream_end_without_done_restores_settled_session_before_closing():
"""If a journal/replay emits stream_end without done, the UI must settle from /api/session.
A close-only stream_end handler leaves live Thinking/inflight DOM around and
never replaces the pane with the persisted transcript when done is missing.
"""
body = _event_body("stream_end")
restore_idx = body.find("_restoreSettledSession()")
close_idx = body.rfind("source.close()")
finalized_idx = body.find("_streamFinalized=true")
assert restore_idx != -1, "stream_end handler must restore settled session when done is absent"
assert close_idx != -1, "stream_end handler must still close the EventSource"
assert restore_idx < close_idx, "restore must be attempted before closing the stream"
assert finalized_idx != -1, "stream_end terminal path must suppress trailing rAF/render work"
def test_done_handler_is_idempotent_for_replay_or_duplicate_done_events():
"""Duplicate/replayed done events must not replay completion sound or duplicate render."""
body = _event_body("done")
first_stmt = body.strip().splitlines()[0].strip()
assert "_streamFinalized" in first_stmt and "return" in first_stmt, (
"done handler must return early when the stream was already finalized"
)
guard_idx = body.find("if(_streamFinalized) return;")
sound_idx = body.find("playNotificationSound();")
assert sound_idx != -1, "done handler should still play completion sound once"
assert guard_idx != -1 and guard_idx < sound_idx, (
"completion sound must be behind the duplicate-done finalization guard"
)
+342
View File
@@ -0,0 +1,342 @@
"""Tests for issue #2735 — "Open in VS Code" action for workspace files/folders.
Pins three layers:
1. **Source wiring** the dispatch entry, handler structure, and menu items
exist in the correct files.
2. **i18n completeness** both new keys (``open_in_vscode`` and
``open_in_vscode_failed``) are present in every locale block.
3. **Live endpoint behaviour** error paths (missing fields, unknown session,
missing file, path traversal) behave correctly against the test server.
The success path (VS Code actually opening) is not covered here because it
requires VS Code to be installed on the CI host. The subprocess call is
intentionally fire-and-forget (matching ``_handle_file_reveal``), so its
failure is surfaced via the OSError catch and a 400 response. That
observable is tested in ``TestOpenInVsCodeEndpointBehaviour``.
"""
from __future__ import annotations
import json
import pathlib
import re
import sys
import urllib.error
import urllib.request
ROOT = pathlib.Path(__file__).resolve().parent.parent
ROUTES = ROOT / "api" / "routes.py"
UI = ROOT / "static" / "ui.js"
I18N = ROOT / "static" / "i18n.js"
sys.path.insert(0, str(pathlib.Path(__file__).parent))
from conftest import TEST_BASE # noqa: E402
# ═══════════════════════════════════════════════════════════════════════════════
# Source-level wiring
# ═══════════════════════════════════════════════════════════════════════════════
class TestOpenInVsCodeBackendWiring:
def test_route_dispatch_entry_present(self):
"""Dispatcher must route /api/file/open-vscode to the handler."""
src = ROUTES.read_text(encoding="utf-8")
assert 'parsed.path == "/api/file/open-vscode"' in src
def test_handler_function_defined(self):
src = ROUTES.read_text(encoding="utf-8")
assert "def _handle_file_open_vscode(handler, body):" in src
def test_handler_uses_safe_resolve(self):
"""Handler must use safe_resolve to prevent path traversal."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m, "_handle_file_open_vscode body not found"
body = m.group(0)
assert "safe_resolve(Path(s.workspace)" in body
def test_handler_checks_existence(self):
"""Handler must require the target to exist (unlike copy-path)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "exists()" in body
def test_handler_reads_vscode_config(self):
"""Handler must read the optional ``vscode`` config block."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert 'get("vscode"' in body
def test_handler_defaults_to_code_command(self):
"""Default executable must be ``code`` when config is absent."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert '"code"' in body
def test_handler_supports_path_prefix_mapping(self):
"""Handler must support container_path_prefix / host_path_prefix
so Docker users can map container paths to host paths."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "container_path_prefix" in body
assert "host_path_prefix" in body
def test_handler_uses_subprocess_popen(self):
"""Handler must use subprocess.Popen (async, non-blocking) consistent
with _handle_file_reveal."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "subprocess.Popen(" in body
def test_handler_resolves_command_via_shutil_which(self):
"""Handler must use shutil.which() to find the command so it works
even when the server's inherited PATH is minimal (e.g. macOS launch
via start.sh where /usr/local/bin may be absent from the subprocess
PATH)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "shutil.which(" in body
def test_handler_has_vscode_fallback_paths(self):
"""Handler must try common VS Code paths when shutil.which fails,
covering macOS (/usr/local/bin/code), Linux (/snap/bin/code), and
Windows (%LOCALAPPDATA%\\Programs\\Microsoft VS Code)."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "/usr/local/bin/code" in body # macOS
assert "/snap/bin/code" in body # Linux snap
assert "Microsoft VS Code" in body # Windows
def test_handler_returns_helpful_error_when_not_found(self):
"""When code command is not found anywhere, handler must return a
descriptive error instead of a bare OSError message."""
src = ROUTES.read_text(encoding="utf-8")
m = re.search(
r"def _handle_file_open_vscode\(handler, body\):.*?(?=\ndef )",
src,
re.DOTALL,
)
assert m
body = m.group(0)
assert "VS Code command not found" in body
class TestOpenInVsCodeFrontendWiring:
def test_file_context_menu_has_vscode_item(self):
"""_showFileContextMenu must include the Open in VS Code action."""
src = UI.read_text(encoding="utf-8")
assert "t('open_in_vscode')" in src
assert "/api/file/open-vscode" in src
def test_workspace_root_context_menu_has_vscode_item(self):
"""_showWorkspaceRootContextMenu must also include the VS Code action."""
src = UI.read_text(encoding="utf-8")
# Both the file and root menus call the same endpoint; verify at least
# two references in the file so we know both call sites exist.
assert src.count("/api/file/open-vscode") >= 2
def test_vscode_item_uses_hover_bg(self):
"""VS Code menu item must use var(--hover-bg), not var(--hover) or
any other undefined variable."""
src = UI.read_text(encoding="utf-8")
# Confirm the item is wired with the correct variable — count hover-bg
# usages; as long as our item follows the pattern the suite is green.
assert "var(--hover-bg)" in src
def test_vscode_failure_toast_uses_i18n_key(self):
"""Error toast on VS Code open failure must use the translatable key."""
src = UI.read_text(encoding="utf-8")
assert "t('open_in_vscode_failed')" in src
def test_vscode_item_guards_err_message(self):
"""Error handler must guard against non-Error objects with
(err.message||err) consistent with reveal handler."""
src = UI.read_text(encoding="utf-8")
# Find the open-vscode call site and check for the guard pattern near it.
idx = src.find("/api/file/open-vscode")
assert idx != -1
# Look in a window around the first call site.
window = src[max(0, idx - 200) : idx + 500]
assert "(err.message||err)" in window or "(err.message || err)" in window
class TestOpenInVsCodeI18n:
"""Both new translation keys must be present in every locale block."""
LOCALES = [
# (locale tag, sample anchor key: value)
("en", "reveal_in_finder: 'Reveal in File Manager'"),
("it", "reveal_in_finder: 'Mostra nel File Manager'"),
("ja", "reveal_in_finder: 'ファイルマネージャーで表示'"),
("ru", "reveal_in_finder: 'Показать в файловом менеджере'"),
("es", "reveal_in_finder: 'Mostrar en el gestor de archivos'"),
("de", "reveal_in_finder: 'Im Dateimanager anzeigen'"),
("zh-CN", "reveal_in_finder: '在文件管理器中显示'"),
("pt", "reveal_in_finder: 'Mostrar no gerenciador de arquivos'"),
("ko", "reveal_in_finder: '파일 관리자에서 열기'"),
]
def test_open_in_vscode_key_count(self):
"""open_in_vscode key must appear exactly once per locale (11 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode:")
assert count == 11, (
f"Expected 11 open_in_vscode: entries (one per locale), found {count}"
)
def test_open_in_vscode_failed_key_count(self):
"""open_in_vscode_failed key must appear exactly once per locale (11 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode_failed:")
assert count == 11, (
f"Expected 11 open_in_vscode_failed: entries (one per locale), found {count}"
)
def test_english_translation_not_a_placeholder(self):
"""English locale must have a human-readable string, not a TODO."""
src = I18N.read_text(encoding="utf-8")
assert "open_in_vscode: 'Open in VS Code'" in src
assert "open_in_vscode_failed: 'Failed to open in VS Code: '" in src
def test_non_english_locales_translated(self):
"""Non-English locales must have real translations, not TODO stubs."""
src = I18N.read_text(encoding="utf-8")
# Spot-check a selection of locales — none of these should be TODO stubs.
assert "open_in_vscode: 'Apri in VS Code'" in src # it
assert "open_in_vscode: 'VS Codeで開く'" in src # ja
assert "open_in_vscode: 'Открыть в VS Code'" in src # ru
assert "open_in_vscode: 'Abrir en VS Code'" in src # es
assert "open_in_vscode: 'In VS Code öffnen'" in src # de
assert "open_in_vscode: 'VS Code에서 열기'" in src # ko
def test_keys_adjacent_to_reveal_block(self):
"""New keys must appear near the reveal/copy block so locale coverage
is easy to spot in code review."""
src = I18N.read_text(encoding="utf-8")
# In the English block, open_in_vscode must appear between
# copy_file_path and download_folder.
copy_idx = src.index("copy_file_path: 'Copy file path'")
dl_idx = src.index("download_folder: 'Download Folder'", copy_idx)
vscode_idx = src.index("open_in_vscode: 'Open in VS Code'", copy_idx)
assert copy_idx < vscode_idx < dl_idx, (
"open_in_vscode key must appear between copy_file_path and "
"download_folder in the English locale block"
)
# ═══════════════════════════════════════════════════════════════════════════════
# Live endpoint behaviour
# ═══════════════════════════════════════════════════════════════════════════════
def _post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
TEST_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
class TestOpenInVsCodeEndpointBehaviour:
def _new_session(self):
body, status = _post("/api/session/new", {})
assert status == 200, body
return body["session"]["session_id"]
def test_missing_session_id_returns_400(self):
body, status = _post("/api/file/open-vscode", {"path": "."})
assert status == 400, body
assert "session_id" in body.get("error", "")
def test_missing_path_returns_400(self):
sid = self._new_session()
body, status = _post("/api/file/open-vscode", {"session_id": sid})
assert status == 400, body
assert "path" in body.get("error", "")
def test_unknown_session_returns_404(self):
body, status = _post(
"/api/file/open-vscode",
{"session_id": "nonexistent-session-xyz", "path": "."},
)
assert status == 404, body
assert "session" in body.get("error", "").lower()
def test_missing_file_returns_404_with_path(self):
"""Attempting to open a file that does not exist must return 404 and
include the resolved path in the error (mirrors _handle_file_reveal
behaviour introduced in #1764)."""
sid = self._new_session()
body, status = _post(
"/api/file/open-vscode",
{"session_id": sid, "path": "does-not-exist-2735.txt"},
)
assert status == 404, body
err = body.get("error", "")
assert "does-not-exist-2735.txt" in err, (
f"404 message must include the resolved path, got: {err!r}"
)
def test_path_traversal_rejected(self):
"""Handler must reject paths that escape the workspace root."""
sid = self._new_session()
body, status = _post(
"/api/file/open-vscode",
{"session_id": sid, "path": "../../../../../../etc/passwd"},
)
assert status == 400, body
+217
View File
@@ -0,0 +1,217 @@
"""Tests for auxiliary models settings UI — panels.js + index.html + i18n.js.
Verifies that the auxiliary models card is present in the settings HTML,
that the JS loading/saving logic is wired up, and that all locales have the
required i18n keys.
"""
from pathlib import Path
ROOT = Path(__file__).parent.parent
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
class TestAuxiliaryModelsHTML:
"""The auxiliary models card must be present in the settings preferences pane."""
def test_aux_models_container_exists(self):
"""The #auxModelsContainer div must exist in the preferences pane."""
assert 'id="auxModelsContainer"' in INDEX_HTML, (
"Missing #auxModelsContainer in index.html — auxiliary models card not rendered"
)
def test_reset_button_exists(self):
assert 'id="btnResetAuxModels"' in INDEX_HTML, (
"Missing #btnResetAuxModels button in index.html"
)
def test_apply_button_exists(self):
assert 'id="btnApplyAuxModels"' in INDEX_HTML, (
"Missing #btnApplyAuxModels button in index.html"
)
def test_aux_card_after_default_model(self):
"""Auxiliary Models card should come after the Default Model card in the DOM."""
model_idx = INDEX_HTML.find('id="settingsModel"')
aux_idx = INDEX_HTML.find('id="auxModelsContainer"')
assert model_idx >= 0, "Default Model select not found in index.html"
assert aux_idx >= 0, "Auxiliary Models container not found in index.html"
assert aux_idx > model_idx, (
"Auxiliary Models container must appear after Default Model in the DOM"
)
def test_i18n_label_on_aux_card(self):
"""The auxiliary models card label must use data-i18n attribute."""
assert 'data-i18n="settings_label_auxiliary_models"' in INDEX_HTML, (
"Missing data-i18n='settings_label_auxiliary_models' on auxiliary card label"
)
class TestAuxiliaryModelsJS:
"""The JS logic for loading and saving auxiliary models must be in panels.js."""
def test_load_function_exists(self):
assert "async function _loadAuxiliaryModels" in PANELS_JS, (
"Missing _loadAuxiliaryModels() in panels.js"
)
def test_apply_function_exists(self):
assert "async function _applyAuxModels" in PANELS_JS, (
"Missing _applyAuxModels() in panels.js"
)
def test_aux_task_slots_defined(self):
"""_AUX_TASK_SLOTS must list the 9 canonical task slots."""
assert "_AUX_TASK_SLOTS" in PANELS_JS, (
"Missing _AUX_TASK_SLOTS constant in panels.js"
)
# Verify all 9 tasks are present
for key in ("vision", "compression", "web_extract", "session_search",
"approval", "mcp", "title_generation", "skills_hub", "curator"):
assert f"key:'{key}'" in PANELS_JS, (
f"Missing auxiliary task slot '{key}' in _AUX_TASK_SLOTS"
)
def test_calls_model_auxiliary_api(self):
"""_loadAuxiliaryModels must call /api/model/auxiliary."""
assert "/api/model/auxiliary" in PANELS_JS, (
"panels.js must call /api/model/auxiliary to fetch current config"
)
def test_calls_model_set_api(self):
"""_applyAuxModels must call /api/model/set to save changes."""
assert "/api/model/set" in PANELS_JS, (
"panels.js must call /api/model/set to save auxiliary model changes"
)
def test_provider_cascade(self):
"""Changing provider must rebuild model dropdown."""
assert "_onAuxProviderChange" in PANELS_JS, (
"Missing _onAuxProviderChange() for provider→model cascade"
)
assert "_buildAuxModelOptions" in PANELS_JS, (
"Missing _buildAuxModelOptions() for model dropdown rebuild"
)
def test_custom_model_prompt(self):
"""Selecting 'Custom model…' must prompt for model ID."""
assert "__custom__" in PANELS_JS, (
"Missing __custom__ sentinel option for custom model input"
)
def test_reset_calls_api_with_reset_task(self):
"""Reset button must call /api/model/set with task='__reset__'."""
idx = PANELS_JS.find("btnResetAuxModels")
assert idx >= 0, "btnResetAuxModels not found in panels.js"
# Check that __reset__ is sent in the reset handler
body_after = PANELS_JS[idx:idx + 2000]
assert "__reset__" in body_after, (
"Reset handler must send task='__reset__' to /api/model/set"
)
def test_load_called_from_loadSettingsPanel(self):
"""_loadAuxiliaryModels must be called from loadSettingsPanel."""
assert "_loadAuxiliaryModels()" in PANELS_JS, (
"_loadAuxiliaryModels() is not called from loadSettingsPanel"
)
def test_dirty_flag_marking(self):
"""Changing an auxiliary dropdown must mark settings dirty."""
assert "_markAuxDirty" in PANELS_JS, (
"Missing _markAuxDirty() for dirty detection"
)
# _markAuxDirty should call _markSettingsDirty
idx = PANELS_JS.find("function _markAuxDirty")
body = PANELS_JS[idx:idx + 200]
assert "_markSettingsDirty" in body, (
"_markAuxDirty must call _markSettingsDirty"
)
class TestAuxiliaryModelsI18n:
"""All locales must have the auxiliary model i18n keys."""
REQUIRED_KEYS = [
"settings_label_auxiliary_models",
"settings_desc_auxiliary_models",
"settings_btn_reset_aux_models",
"settings_btn_apply_aux_models",
"settings_aux_provider_auto",
"settings_aux_model_auto",
"settings_aux_model_custom",
"settings_aux_model_custom_prompt",
"settings_aux_loading",
"settings_aux_load_failed",
"settings_aux_reset_confirm_title",
"settings_aux_reset_confirm_msg",
"settings_aux_reset_done",
"settings_aux_save_failed",
"settings_aux_saved",
"settings_aux_no_changes",
]
def test_all_i18n_keys_present(self):
"""Every required key must exist in i18n.js at least once."""
for key in self.REQUIRED_KEYS:
assert key in I18N_JS, (
f"Missing i18n key '{key}' in i18n.js"
)
def test_all_locales_have_auxiliary_keys(self):
"""Count of each key should equal the number of locales (12 with Turkish)."""
for key in self.REQUIRED_KEYS:
count = I18N_JS.count(f"{key}:")
assert count == 12, (
f"i18n key '{key}' found {count} times — expected 12 (one per locale)"
)
class TestAuxiliaryModelsBackend:
"""WebUI backend must expose /api/model/auxiliary and /api/model/set."""
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8")
def test_model_auxiliary_route_exists(self):
"""/api/model/auxiliary route must be registered in routes.py."""
assert '"/api/model/auxiliary"' in self.ROUTES_PY, (
"Missing /api/model/auxiliary route in routes.py"
)
def test_model_set_route_exists(self):
"""/api/model/set route must be registered in routes.py."""
assert '"/api/model/set"' in self.ROUTES_PY, (
"Missing /api/model/set route in routes.py"
)
def test_get_auxiliary_models_function_exists(self):
"""get_auxiliary_models() must exist in api/config.py."""
assert "def get_auxiliary_models" in self.CONFIG_PY, (
"Missing get_auxiliary_models() in api/config.py"
)
def test_set_auxiliary_model_function_exists(self):
"""set_auxiliary_model() must exist in api/config.py."""
assert "def set_auxiliary_model" in self.CONFIG_PY, (
"Missing set_auxiliary_model() in api/config.py"
)
def test_aux_task_slots_constant_exists(self):
"""AUX_TASK_SLOTS must be defined in api/config.py."""
assert "AUX_TASK_SLOTS" in self.CONFIG_PY, (
"Missing AUX_TASK_SLOTS constant in api/config.py"
)
def test_js_uses_models_endpoint_not_options(self):
"""Frontend must use /api/models (WebUI's own API) not /api/model/options (agent API)."""
# _loadAuxiliaryModels should call /api/models, not /api/model/options
idx = PANELS_JS.find("async function _loadAuxiliaryModels")
assert idx >= 0, "_loadAuxiliaryModels not found"
body = PANELS_JS[idx:idx + 800]
assert "/api/models" in body, (
"_loadAuxiliaryModels must call /api/models for provider/model lists"
)
assert "/api/model/options" not in body, (
"_loadAuxiliaryModels must NOT call /api/model/options (agent-only endpoint)"
)
+3 -1
View File
@@ -245,7 +245,9 @@ class TestSystemTheme:
def test_panels_hydrates_appearance_before_models_fetch(self):
src = read("static/panels.js")
skin_idx = src.index("const skinVal=(settings.skin||'default').toLowerCase();")
# PR #2799 (v0.51.119): skin precedence now prefers localStorage over settings.skin
# so the inline-gate-resolved DOM skin survives the picker hydration.
skin_idx = src.index("const skinVal=(localStorage.getItem('hermes-skin')||settings.skin||'default').toLowerCase();")
# models is now declared as let models=null before the try block
models_idx = src.index("models=await api('/api/models');")
assert skin_idx < models_idx, (
@@ -54,7 +54,8 @@ def test_pin_limit_setting_is_exposed_and_wired_through_ui():
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
assert "_pinnedSessionCount()>=_getPinnedSessionsLimit()" not in SESSIONS_JS
assert "await api('/api/session/pin'" in SESSIONS_JS
def test_settings_api_persists_integer_pin_limit_and_rejects_invalid_values():
+71
View File
@@ -1708,6 +1708,77 @@ def test_session_prefers_state_db_messages_over_stale_local_snapshot(cleanup_tes
pass
def test_messaging_session_message_count_matches_deduped_display_messages(cleanup_test_sessions):
"""Thread sessions must not advertise raw DB rows that display merge dedupes away."""
from api.models import Session
conn = _ensure_state_db()
sid = 'gw_display_count_regression_001'
cleanup_test_sessions.append(sid)
base_ts = time.time() - 60
rows = [
("user", "Thread question", base_ts + 1),
("assistant", "", base_ts + 2),
("assistant", "", base_ts + 2),
("tool", '{"ok": true}', base_ts + 3),
("assistant", "Thread answer", base_ts + 4),
]
raw_db_count = len(rows)
try:
_insert_gateway_session(
conn,
session_id=sid,
source='discord',
title='Discord Thread Count Regression',
message_count=raw_db_count,
started_at=base_ts,
)
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
for role, content, ts in rows:
_insert_message(conn, sid, role, content, ts)
conn.execute(
"UPDATE sessions SET message_count = ? WHERE id = ?",
(raw_db_count, sid),
)
conn.commit()
# A stale WebUI sidecar can exist for the same messaging thread. The API
# display merge dedupes repeated blank assistant separators, so the
# advertised count must match the returned display coordinate space, not
# the raw state.db row count.
s = Session(
session_id=sid,
title='Legacy Discord Snapshot',
workspace='/tmp/hermes-webui-test',
model='openai/gpt-5',
messages=[{"role": "user", "content": "Thread question", "timestamp": base_ts + 1}],
session_source='messaging',
raw_source='discord',
source_tag='discord',
source_label='Discord',
)
s.save(touch_updated_at=False)
post('/api/settings', {'show_cli_sessions': True})
data, status = get(f'/api/session?session_id={sid}&messages=1&resolve_model=0&msg_limit=100')
assert status == 200, data
session = data.get('session', {})
msgs = session.get('messages', [])
assert msgs[-1].get('content') == 'Thread answer'
assert len(msgs) < raw_db_count, "fixture must exercise display dedupe"
assert session.get('message_count') == len(msgs)
finally:
try:
_remove_test_sessions(conn, sid)
conn.close()
except Exception:
pass
try:
post('/api/settings', {'show_cli_sessions': False})
except Exception:
pass
def test_sessions_prefers_state_db_metadata_for_messaging_overlap(cleanup_test_sessions):
"""Sidebar metadata for messaging sessions should come from state.db, not local JSON snapshots."""
conn = _ensure_state_db()
+88 -1
View File
@@ -24,7 +24,7 @@ def _function_body(src: str, name: str) -> str:
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];")
setup_idx = body.index("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)
@@ -49,3 +49,90 @@ def test_stale_inflight_purge_preserves_current_send_before_stream_id_exists():
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"
def test_send_clears_stale_busy_state_before_queue_branch():
"""A stale client-only busy flag must not divert a new user turn into the invisible queue."""
body = _function_body(MESSAGES_JS, "send")
assert "_clearStaleBusyStateBeforeSend" in body, (
"send() should reconcile client-only stale busy state before deciding busy/queue mode"
)
reconcile_idx = body.index("_clearStaleBusyStateBeforeSend")
busy_branch_idx = body.index("if(S.busy||compressionRunning)")
chat_start_idx = body.index("api('/api/chat/start'")
assert reconcile_idx < busy_branch_idx < chat_start_idx, (
"stale busy reconciliation must run before the queue branch and before /api/chat/start"
)
def test_pre_start_optimistic_ui_helpers_cannot_block_chat_start():
"""Optional optimistic UI helpers must not strand a local bubble before /api/chat/start."""
body = _function_body(MESSAGES_JS, "send")
helper_body = _function_body(MESSAGES_JS, "_runOptionalPreStartUiStep")
optimistic_idx = body.index("S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);")
chat_start_idx = body.index("api('/api/chat/start'")
pre_start = body[optimistic_idx:chat_start_idx]
assert "try" in helper_body and "catch" in helper_body, (
"optional pre-start UI helper wrapper must catch errors before /api/chat/start"
)
assert "setStatus(`UI warning before send:" not in helper_body, (
"non-fatal pre-start UI helper failures should stay in the console; visible status flashes "
"look like real send errors even though /api/chat/start continues"
)
assert "_runOptionalPreStartUiStep" in pre_start, (
"send() should wrap optimistic sidebar/title/polling helpers before /api/chat/start"
)
assert "upsertActiveSessionForLocalTurn" in pre_start and "applySessionTitleUpdate" in pre_start
def test_pre_start_optimistic_block_cannot_prevent_chat_start():
"""Any pre-start UI/storage exception must still fall through to /api/chat/start."""
body = _function_body(MESSAGES_JS, "send")
optimistic_idx = body.index("S.messages.push(userMsg);renderMessages();appendThinking('',{pending:true});setBusy(true);")
chat_start_idx = body.index("api('/api/chat/start'")
pre_start = body[optimistic_idx:chat_start_idx]
assert "}catch(preStartError){" in pre_start, (
"The whole optimistic pre-start block needs a catch, not only individual optional helpers"
)
assert "continuing to /api/chat/start" in pre_start, (
"The recovery path should document that chat/start must still execute"
)
assert pre_start.rindex("}catch(preStartError){") < chat_start_idx, (
"pre-start catch must be before the /api/chat/start call"
)
def test_server_absent_optimistic_first_turn_rows_are_not_kept_forever():
"""A local first-turn sidebar row must expire when /api/chat/start never persisted it."""
body = _function_body(SESSIONS_JS, "_mergeOptimisticFirstTurnSessions")
assert "_shouldKeepLocalOnlyOptimisticSessionRow(local)" in body, (
"server-absent optimistic rows need an explicit keep/drop gate"
)
keep_idx = body.index("if(_shouldKeepLocalOnlyOptimisticSessionRow(local))")
append_idx = body.index("merged.push({...local,is_streaming:true});")
drop_idx = body.index("_dropStaleOptimisticSessionRow(sid);", append_idx)
assert keep_idx < append_idx < drop_idx, (
"local optimistic rows may only be appended inside the explicit keep gate"
)
drop_body = _function_body(SESSIONS_JS, "_dropStaleOptimisticSessionRow")
assert "clearInflightState(sid)" in drop_body, (
"dropping a phantom row should also clear persisted browser recovery state"
)
def test_server_idle_row_wins_over_stale_optimistic_count():
"""If the server says the row is idle, stale local message_count/title must not win."""
body = _function_body(SESSIONS_JS, "_mergeOptimisticFirstTurnSessions")
assert "const keepLocalOptimistic=" in body
assert "message_count:keepLocalOptimistic?Math.max(localCount,fetchedCount):fetchedCount" in body, (
"stale optimistic message_count must not override a confirmed idle server row"
)
assert "title:keepLocalOptimistic?(local.title||fetched.title):fetched.title" in body, (
"stale optimistic provisional title must not override a confirmed idle server row"
)
@@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n:
"voice_mode_toggle_active",
)
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
def test_legacy_voice_toggle_key_removed(self):
"""The old key whose string was 'Voice input' caused the duplicate-
@@ -171,7 +171,7 @@ class TestComposerVoiceButtonI18n:
class TestVoiceModePreferenceGate:
"""boot.js must hide btnVoiceMode by default, surface it via Preferences."""
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko")
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
def test_voice_mode_pref_is_localstorage_backed(self):
"""The pref reads from localStorage key 'hermes-voice-mode-button'."""
@@ -164,6 +164,15 @@ class TestProviderRemoveInvalidatesDropdowns:
"response (covers the dropdown + badge surfaces from #1539)."
)
def test_dropdown_flush_reuses_shared_model_ready_promise(self):
src = _read_static("panels.js")
body = _extract_function_body(src, "function _refreshModelDropdownsAfterProviderChange(")
ensure_pos = body.index("typeof window._ensureModelDropdownReady")
reset_pos = body.index("window._modelDropdownReady=null", ensure_pos)
call_pos = body.index("window._ensureModelDropdownReady()", reset_pos)
assert ensure_pos < reset_pos < call_pos
def test_dropdown_flush_is_resilient_to_missing_modules(self):
"""If commands.js or ui.js failed to load, the providers panel must
still update the dropdown flush is best-effort (#1539)."""
@@ -50,6 +50,13 @@ def test_load_dir_keeps_workspace_panel_open_when_clearing_preview():
)
def test_load_dir_ignores_stale_session_results():
block = _function_block(WORKSPACE_JS, "loadDir")
assert "const sessionId=S.session.session_id" in block
assert "encodeURIComponent(sessionId)" in block
assert "if(!S.session||S.session.session_id!==sessionId)return;" in block
def test_file_preview_breadcrumb_uses_directory_navigation_for_root():
block = _function_block(WORKSPACE_JS, "renderFileBreadcrumb")
assert "loadDir('.')" in block, "The preview root breadcrumb should navigate to the workspace root."
@@ -4,7 +4,8 @@ Regression coverage for shared compression-anchor visibility helpers (#2028).
from pathlib import Path
from api.compression_anchor import visible_messages_for_anchor
from api.compression_anchor import is_context_compression_marker, visible_messages_for_anchor
from api.streaming import _compression_summary_from_messages, _is_context_compression_marker
def test_legacy_duplicate_anchor_helpers_are_removed():
@@ -57,3 +58,42 @@ def test_visible_messages_for_anchor_keeps_manual_user_messages_simple():
[user_tool_metadata, user_attachment, assistant_tool_metadata],
auto_compression=True,
) == [user_tool_metadata, user_attachment, assistant_tool_metadata]
def test_context_compression_marker_detection_is_prefix_and_role_scoped():
real_marker = {
"role": "assistant",
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted.",
}
preserved_tasks_marker = {
"role": "user",
"content": "[Your active task list was preserved across context compression] - [ ] follow up",
}
tool_noise = {
"role": "tool",
"content": "{\"description\": \"Troubleshoot frequent context compression indicators\"}",
}
user_discussion = {
"role": "user",
"content": "Why do I see context compression after every message?",
}
assert is_context_compression_marker(real_marker)
assert is_context_compression_marker(preserved_tasks_marker)
assert _is_context_compression_marker(real_marker)
assert not is_context_compression_marker(tool_noise)
assert not is_context_compression_marker(user_discussion)
def test_compression_summary_ignores_tool_output_that_mentions_compression():
marker = {
"role": "assistant",
"content": "[CONTEXT COMPACTION — REFERENCE ONLY] Keep this handoff as reference.",
}
skill_tool_output = {
"role": "tool",
"content": "{\"name\": \"hermes-webui-operations\", \"content\": \"Troubleshooting frequent context compression indicators...\"}",
}
assert _compression_summary_from_messages([marker, skill_tool_output]) == marker["content"]
assert _compression_summary_from_messages([skill_tool_output]) is None
+2 -2
View File
@@ -63,8 +63,8 @@ def test_context_indicator_surfaces_cache_hit_rate():
def test_cache_usage_labels_are_localized():
src = (ROOT / "static" / "i18n.js").read_text()
assert src.count("usage_cache_hit_detail:") == 11
assert src.count("usage_cached_percent:") == 11
assert src.count("usage_cache_hit_detail:") == 12
assert src.count("usage_cached_percent:") == 12
assert "usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)'" in src
assert "usage_cached_percent: '{0}% cached'" in src
+1 -1
View File
@@ -31,7 +31,7 @@ def test_theme_command_help_mentions_current_theme_and_skin_values():
"system/dark/light",
"default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast",
)
for locale in ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "fr"):
for locale in ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "fr", "tr"):
value = _literal_value(_locale_block(locale), "cmd_theme")
for fragment in required_fragments:
assert fragment in value, f"{locale} cmd_theme missing {fragment!r}: {value!r}"
+3 -1
View File
@@ -77,7 +77,9 @@ def test_session_pin_cap_has_backend_and_frontend_guards():
assert 'function _pinnedSessionCount()' in SESSIONS_JS
assert 'function _getPinnedSessionsLimit()' in SESSIONS_JS
assert 'function _pinnedSessionsLimit()' not in SESSIONS_JS
assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();' in SESSIONS_JS
assert 'const pinLimitReached=!session.pinned&&_pinnedSessionCount()>=_getPinnedSessionsLimit();' not in SESSIONS_JS
assert 'if(pinLimitReached)' not in SESSIONS_JS
assert "await api('/api/session/pin'" 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,154 @@
"""Regression tests for issue #2565: reasoning display bugs.
Issue 1: reasoningText accumulates across turns within a single SSE stream.
- reasoningText must be reset at each turn boundary (tool and interim_assistant
events) so the done event only persists the current turn's reasoning.
Issue 2: ui.js display prefers m.reasoning over m.reasoning_content.
- The rendering path must prefer m.reasoning_content (the clean per-turn value
from the backend) over m.reasoning (which can be corrupted by Issue 1).
Both fixes are needed: Issue 2 alone cannot cover providers that stream reasoning
events without populating reasoning_content on the final API message.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel):
return (REPO / rel).read_text(encoding='utf-8')
# ── Issue 1: reasoningText reset at turn boundaries ──────────────────────────
class TestReasoningTextResetOnTool:
"""reasoningText must be reset alongside liveReasoningText in the tool
listener so multi-tool-turn sessions don't accumulate reasoning across
turns."""
def _tool_listener_body(self):
"""Extract the full tool listener body between the tool and
tool_complete addEventListener calls."""
src = read('static/messages.js')
tool_start = src.find("source.addEventListener('tool'")
assert tool_start >= 0, "tool listener not found"
tool_complete_start = src.find(
"source.addEventListener('tool_complete'", tool_start + 1,
)
assert tool_complete_start >= 0, "tool_complete listener not found"
return src[tool_start:tool_complete_start]
def test_reasoning_text_reset_in_tool_listener(self):
body = self._tool_listener_body()
assert "reasoningText=''" in body, (
"reasoningText must be reset to '' inside the tool listener "
"(Issue 1: accumulated reasoning from prior turns was assigned "
"to the last assistant message on the done event)"
)
def test_live_reasoning_text_also_reset_in_tool_listener(self):
body = self._tool_listener_body()
assert "liveReasoningText=''" in body, (
"liveReasoningText must also be reset in the tool listener"
)
class TestReasoningTextResetOnInterimAssistant:
"""reasoningText must be reset at the interim_assistant boundary — the
other turn boundary where the previous turn's reasoning closes out.
Without this, providers that emit reasoning before an interim_assistant
event will still co-mingle reasoning across turns."""
def test_reasoning_text_reset_in_interim_assistant_listener(self):
src = read('static/messages.js')
m = re.search(
r"source\.addEventListener\('interim_assistant'\s*,\s*(?:e|ev)\s*=>\s*\{(.*?)\n\s*\}\);",
src, re.DOTALL,
)
assert m, "interim_assistant listener not found in messages.js"
body = m.group(1)
assert "reasoningText=''" in body, (
"reasoningText must be reset to '' inside the interim_assistant "
"listener (Issue 1: turn boundary where prior reasoning closes)"
)
def test_live_reasoning_text_reset_in_interim_assistant_listener(self):
src = read('static/messages.js')
m = re.search(
r"source\.addEventListener\('interim_assistant'\s*,\s*(?:e|ev)\s*=>\s*\{(.*?)\n\s*\}\);",
src, re.DOTALL,
)
assert m
body = m.group(1)
assert "liveReasoningText=''" in body, (
"liveReasoningText must be reset in the interim_assistant listener"
)
# ── Issue 2: reasoning_content preference on read ────────────────────────────
class TestReasoningContentPreference:
"""The rendering path in ui.js must prefer m.reasoning_content (the clean
per-turn value from the backend) over m.reasoning (which can be corrupted
by Issue 1's accumulation bug)."""
def test_reasoning_content_checked_before_reasoning(self):
src = read('static/ui.js')
assert 'm.reasoning_content' in src, (
"ui.js must reference m.reasoning_content so the clean per-turn "
"value from the backend is used for thinking card display"
)
def test_reasoning_content_preferred_in_thinking_text_fallback(self):
src = read('static/ui.js')
lines = src.splitlines()
for line in lines:
if 'thinkingText' in line and 'm.reasoning' in line:
if 'm.reasoning_content' not in line and 'reasoning_content' not in line:
if 'Array.isArray' not in line:
raise AssertionError(
f"Line references m.reasoning without checking "
f"m.reasoning_content first: {line.strip()}"
)
def test_reasoning_content_has_priority_over_reasoning(self):
"""The fallback expression must evaluate reasoning_content first."""
src = read('static/ui.js')
m = re.search(
r"thinkingText\s*=\s*(m\.reasoning_content\s*\|\|\s*m\.reasoning)",
src,
)
assert m, (
"thinkingText assignment must use m.reasoning_content || m.reasoning "
"so the clean backend value takes priority over the potentially "
"corrupted frontend-accumulated value"
)
# ── Cross-cutting: done event still has the persist-on-done guard ────────────
class TestDoneEventReasoningPersist:
"""The done event's reasoning persistence guard must still exist —
the reset fixes reduce the blast radius but the guard prevents double-write
when the backend already populated .reasoning."""
def test_done_event_has_reasoning_guard(self):
src = read('static/messages.js')
assert '!lastAsst.reasoning' in src, (
"done event must guard reasoningText persistence with "
"!lastAsst.reasoning to avoid overwriting backend-populated values"
)
def test_done_event_persists_reasoning_text(self):
src = read('static/messages.js')
assert 'lastAsst.reasoning=reasoningText' in src, (
"done event must still persist reasoningText to lastAsst.reasoning "
"for providers that stream reasoning events without populating "
"reasoning_content on the final API message"
)
+74
View File
@@ -0,0 +1,74 @@
"""Regression tests for issue #2572 CSRF rejection diagnostics."""
import hmac
import io
import json
import time
from types import SimpleNamespace
import api.auth as auth
import api.routes as routes
class _FakeHandler:
def __init__(self, headers=None, body=b"{}"):
self.headers = headers or {}
self.client_address = ("127.0.0.1", 12345)
self.rfile = io.BytesIO(body)
self.wfile = io.BytesIO()
self.status = None
self.sent_headers = {}
def send_response(self, status):
self.status = status
def send_header(self, key, value):
self.sent_headers[key] = value
def end_headers(self):
pass
def _signed_cookie(raw_token: str) -> str:
sig = hmac.new(auth._signing_key(), raw_token.encode(), "sha256").hexdigest()
auth._sessions[raw_token] = time.time() + 60
return f"{raw_token}.{sig}"
def _json_body(handler: _FakeHandler) -> dict:
return json.loads(handler.wfile.getvalue().decode("utf-8"))
def test_origin_mismatch_csrf_rejection_has_diagnostic_error(monkeypatch):
monkeypatch.setattr(auth, "is_auth_enabled", lambda: False)
handler = _FakeHandler(
{
"Origin": "https://evil.example",
"Host": "127.0.0.1:8787",
}
)
routes.handle_post(handler, SimpleNamespace(path="/api/providers/delete"))
assert handler.status == 403
assert _json_body(handler)["error"] == "Cross-origin mismatch - check reverse proxy headers"
def test_token_mismatch_csrf_rejection_has_reload_error(monkeypatch):
cookie = _signed_cookie("z" * 64)
monkeypatch.setattr(auth, "is_auth_enabled", lambda: True)
try:
handler = _FakeHandler(
{
"Origin": "http://127.0.0.1:8787",
"Host": "127.0.0.1:8787",
"Cookie": f"{auth.COOKIE_NAME}={cookie}",
}
)
routes.handle_post(handler, SimpleNamespace(path="/api/providers/delete"))
assert handler.status == 403
assert _json_body(handler)["error"] == "Session expired - reload the page"
finally:
auth._sessions.pop("z" * 64, None)
+33
View File
@@ -0,0 +1,33 @@
from pathlib import Path
WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8")
SESSIONS_JS = Path("static/sessions.js").read_text(encoding="utf-8")
MESSAGES_JS = Path("static/messages.js").read_text(encoding="utf-8")
INDEX_HTML = Path("static/index.html").read_text(encoding="utf-8")
STYLE_CSS = Path("static/style.css").read_text(encoding="utf-8")
CHANGELOG = Path("CHANGELOG.md").read_text(encoding="utf-8")
def test_workspace_artifacts_tab_collects_session_files_and_previews_them():
assert 'id="workspaceArtifactsTab"' in INDEX_HTML
assert 'id="workspaceArtifacts"' in INDEX_HTML
assert "function collectSessionArtifacts()" in WORKSPACE_JS
assert "function _artifactCandidatesFromToolCall(tc)" in WORKSPACE_JS
assert "ARTIFACT_IGNORE_RE" in WORKSPACE_JS
assert "node_modules" in WORKSPACE_JS and "__pycache__" in WORKSPACE_JS
assert "function renderSessionArtifacts()" in WORKSPACE_JS
assert "function scheduleRenderSessionArtifacts()" in WORKSPACE_JS
assert "function openArtifactPath(path)" in WORKSPACE_JS
assert "openFile(rel);" in WORKSPACE_JS
assert "Prose mentions" in WORKSPACE_JS
assert "/(?:created|wrote|updated|edited|saved|modified)" not in WORKSPACE_JS
assert "panel.dataset.activeTab = _workspacePanelActiveTab" in WORKSPACE_JS
assert "renderSessionArtifacts();" in SESSIONS_JS
assert "typeof scheduleRenderSessionArtifacts==='function'" in MESSAGES_JS
assert "S.toolCalls=d.session.tool_calls.map" in MESSAGES_JS
assert ".workspace-artifact-item" in STYLE_CSS
def test_changelog_mentions_workspace_artifacts_tab():
unreleased = CHANGELOG.split("## [v0.51.103]", 1)[0]
assert "Artifacts tab" in unreleased
+2 -2
View File
@@ -50,8 +50,8 @@ def test_panels_round_trip_and_hot_apply_hide_suggestions():
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
assert js.count("settings_label_hide_suggestions:") == 12
assert js.count("settings_desc_hide_suggestions:") == 12
changelog = CHANGELOG.read_text(encoding="utf-8")
assert "#2679" in changelog
assert "hide_empty_state_suggestions" in changelog
@@ -0,0 +1,178 @@
"""Regression tests for #2713 — flush pending render before segment reset.
During live streaming with tool calls, the rAF-throttled render callback could
be orphaned when _resetAssistantSegment() cleared assistantBody before the
pending callback fired. The fix introduces _flushPendingSegmentRender() which
synchronously writes any pending segment text to the DOM before the segment is
sealed.
These tests use static analysis (same pattern as test_streaming_race_fix.py)
to pin the structural invariants so a future refactor cannot silently re-break
the flush guarantee.
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel):
return (REPO / rel).read_text(encoding="utf-8")
class TestFlushHelperExists:
"""_flushPendingSegmentRender must exist and have the right shape."""
def test_flush_helper_declared(self):
src = read("static/messages.js")
assert "function _flushPendingSegmentRender()" in src, (
"_flushPendingSegmentRender helper must be declared in messages.js"
)
def test_flush_helper_guards_on_assistant_body(self):
src = read("static/messages.js")
m = re.search(
r"function _flushPendingSegmentRender\(\)\{.*?\n \}",
src,
re.DOTALL,
)
assert m, "_flushPendingSegmentRender not found"
fn = m.group(0)
assert "assistantBody" in fn, (
"_flushPendingSegmentRender must guard on assistantBody"
)
def test_flush_helper_guards_on_render_pending(self):
src = read("static/messages.js")
m = re.search(
r"function _flushPendingSegmentRender\(\)\{.*?\n \}",
src,
re.DOTALL,
)
assert m
fn = m.group(0)
assert "_renderPending" in fn, (
"_flushPendingSegmentRender must guard on _renderPending"
)
def test_flush_helper_cancels_pending_raf(self):
src = read("static/messages.js")
m = re.search(
r"function _flushPendingSegmentRender\(\)\{.*?\n \}",
src,
re.DOTALL,
)
assert m
fn = m.group(0)
assert "_cancelAnimationFramePendingStreamRender()" in fn, (
"_flushPendingSegmentRender must cancel the pending rAF"
)
def test_flush_helper_uses_smd_write(self):
src = read("static/messages.js")
m = re.search(
r"function _flushPendingSegmentRender\(\)\{.*?\n \}",
src,
re.DOTALL,
)
assert m
fn = m.group(0)
assert "_smdWrite(" in fn, (
"_flushPendingSegmentRender must write via _smdWrite for smd path"
)
def test_flush_helper_has_render_md_fallback(self):
src = read("static/messages.js")
m = re.search(
r"function _flushPendingSegmentRender\(\)\{.*?\n \}",
src,
re.DOTALL,
)
assert m
fn = m.group(0)
assert "renderMd" in fn, (
"_flushPendingSegmentRender must have renderMd fallback"
)
def test_flush_helper_has_esc_fallback(self):
src = read("static/messages.js")
m = re.search(
r"function _flushPendingSegmentRender\(\)\{.*?\n \}",
src,
re.DOTALL,
)
assert m
fn = m.group(0)
assert "esc(" in fn, (
"_flushPendingSegmentRender must have esc() fallback"
)
def _extract_handler(src, event_name):
"""Extract a full SSE handler body by matching balanced indentation.
Finds `source.addEventListener('<event_name>'` and captures through the
matching ` });` closing (4-space indent, matching the addEventListener
call site inside _wireSSE).
"""
start_pattern = f"source.addEventListener('{event_name}'"
start = src.index(start_pattern)
# Find the closing ` });` that ends this handler at 6-space indent level
# (the handler bodies are indented 6 spaces inside _wireSSE)
end_marker = "\n });"
pos = start
while True:
idx = src.index(end_marker, pos + 1)
# Confirm the next line after `});` starts a new addEventListener or
# is at the same or lower indent. Accept first match after the handler
# body has at least some content.
if idx > start + len(start_pattern) + 20:
return src[start : idx + len(end_marker)]
pos = idx
class TestToolHandlerFlush:
"""The tool SSE handler must call _flushPendingSegmentRender before reset."""
def test_tool_handler_calls_flush(self):
src = read("static/messages.js")
fn = _extract_handler(src, "tool")
assert "_flushPendingSegmentRender()" in fn, (
"tool handler must call _flushPendingSegmentRender() before "
"_resetAssistantSegment()"
)
def test_tool_handler_flush_before_reset(self):
src = read("static/messages.js")
fn = _extract_handler(src, "tool")
flush_pos = fn.index("_flushPendingSegmentRender()")
reset_pos = fn.index("_resetAssistantSegment()")
assert flush_pos < reset_pos, (
"_flushPendingSegmentRender must be called BEFORE "
"_resetAssistantSegment in the tool handler"
)
class TestInterimAssistantHandlerFlush:
"""The interim_assistant handler must call _flushPendingSegmentRender."""
def test_interim_handler_calls_flush(self):
src = read("static/messages.js")
fn = _extract_handler(src, "interim_assistant")
assert "_flushPendingSegmentRender()" in fn, (
"interim_assistant handler must call _flushPendingSegmentRender() "
"before _resetAssistantSegment()"
)
def test_interim_handler_flush_before_last_reset(self):
"""The flush must precede the final _resetAssistantSegment that seals
the segment for new content (not the early alreadyStreamed branch)."""
src = read("static/messages.js")
fn = _extract_handler(src, "interim_assistant")
flush_pos = fn.index("_flushPendingSegmentRender()")
# Find the _resetAssistantSegment call that comes AFTER the flush
reset_pos = fn.index("_resetAssistantSegment()", flush_pos)
assert flush_pos < reset_pos, (
"_flushPendingSegmentRender must be called BEFORE the final "
"_resetAssistantSegment in the interim_assistant handler"
)
@@ -0,0 +1,96 @@
"""Regression coverage for #2720: Bedrock models must appear in the WebUI model picker."""
from __future__ import annotations
import builtins
import api.config as config
def _force_env_fallback(monkeypatch):
"""Force get_available_models() down its explicit env-var fallback path."""
real_import = builtins.__import__
def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name in ("hermes_cli.models", "hermes_cli.auth"):
raise ImportError(name)
return real_import(name, globals, locals, fromlist, level)
monkeypatch.setattr(builtins, "__import__", fake_import)
def _run_available_models_with_cfg(monkeypatch, tmp_path, cfg):
old_cfg = dict(config.cfg)
old_mtime = config._cfg_mtime
monkeypatch.setattr(config, "_models_cache_path", tmp_path / "models_cache.json")
monkeypatch.setattr(config, "_get_config_path", lambda: tmp_path / "missing-config.yaml")
monkeypatch.setattr("api.profiles.get_active_hermes_home", lambda: tmp_path, raising=False)
config.cfg.clear()
config.cfg.update(cfg)
config._cfg_mtime = 0.0
config.invalidate_models_cache()
try:
return config.get_available_models()
finally:
config.cfg.clear()
config.cfg.update(old_cfg)
config._cfg_mtime = old_mtime
config.invalidate_models_cache()
def test_bedrock_in_provider_display():
"""_PROVIDER_DISPLAY must have a human-readable label for 'bedrock'."""
assert "bedrock" in config._PROVIDER_DISPLAY, (
"_PROVIDER_DISPLAY is missing 'bedrock' — the group header in the model picker "
"will fall back to 'Bedrock' (title-cased id) instead of 'AWS Bedrock'"
)
assert config._PROVIDER_DISPLAY["bedrock"] == "AWS Bedrock"
def test_bedrock_in_provider_models():
"""_PROVIDER_MODELS must have a static fallback list for 'bedrock'."""
assert "bedrock" in config._PROVIDER_MODELS, (
"_PROVIDER_MODELS is missing 'bedrock' — the group builder falls to the "
"else/auto-detected branch where an empty model list silently drops the group"
)
assert len(config._PROVIDER_MODELS["bedrock"]) > 0, (
"_PROVIDER_MODELS['bedrock'] must have at least one static fallback model"
)
def test_bedrock_static_models_have_required_fields():
"""Every static bedrock model entry must have both 'id' and 'label'."""
for model in config._PROVIDER_MODELS["bedrock"]:
assert "id" in model and model["id"], f"Missing id in bedrock model entry: {model}"
assert "label" in model and model["label"], f"Missing label in bedrock model entry: {model}"
def test_bedrock_aws_credentials_detected_in_env_fallback(monkeypatch, tmp_path):
"""AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY must trigger bedrock group (no hermes_cli)."""
_force_env_fallback(monkeypatch)
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}})
groups = {group["provider_id"]: group for group in result["groups"]}
assert "bedrock" in groups, (
"bedrock group missing from model picker even with AWS_ACCESS_KEY_ID and "
"AWS_SECRET_ACCESS_KEY set — env-var fallback path does not detect bedrock (#2720)"
)
assert groups["bedrock"]["provider"] == "AWS Bedrock"
assert len(groups["bedrock"]["models"]) > 0
def test_bedrock_missing_secret_key_not_detected(monkeypatch, tmp_path):
"""Only AWS_ACCESS_KEY_ID (without the secret) must NOT trigger bedrock group."""
_force_env_fallback(monkeypatch)
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE")
monkeypatch.delenv("AWS_SECRET_ACCESS_KEY", raising=False)
result = _run_available_models_with_cfg(monkeypatch, tmp_path, {"model": {}})
groups = {group["provider_id"]: group for group in result["groups"]}
assert "bedrock" not in groups, (
"bedrock must not appear when only AWS_ACCESS_KEY_ID is set without the secret"
)
@@ -0,0 +1,287 @@
"""
Regression test for #2762 — state_sync writes to wrong profile's state.db
when profile is switched via WebUI cookie.
Root cause: ``_get_state_db()`` relied on TLS-based
``get_active_hermes_home()`` to pick the DB path. TLS gets set on the HTTP
thread by the cookie middleware, but the agent streaming worker thread that
calls ``sync_session_usage`` does NOT inherit that TLS, so the lookup falls
through to the process-global active profile and writes to the wrong DB.
Fix: ``_get_state_db(profile=...)`` accepts an explicit profile name and
resolves *that* profile's home directly via
``_resolve_profile_home_for_name``. Callers that know the session's profile
(e.g. ``sync_session_usage`` after streaming completes) pass it explicitly,
avoiding the TLS race.
These tests pin:
1. ``_get_state_db(profile='X')`` resolves X's home, not the active profile's.
2. ``sync_session_usage(..., profile='X')`` writes to X's state.db only,
even when the global active profile is set to Y.
3. ``sync_session_usage`` with no profile kwarg falls back to the old
TLS behavior (so existing callers don't regress).
"""
from __future__ import annotations
import sys
import sqlite3
from pathlib import Path
import pytest
# Make sure we can import the api package the same way the server does.
_REPO_ROOT = Path(__file__).resolve().parent.parent
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
@pytest.fixture()
def two_profile_homes(tmp_path, monkeypatch):
"""Stand up two minimal profile homes with state.db initialized via
``hermes_state.SessionDB`` itself (so the schema matches what the
production code expects `sync_session_usage` does a raw-SQL
UPDATE of `message_count`, which hand-rolled schemas could miss).
Per Copilot review on PR #2827.
"""
# Skip the fixture cleanly if the production package isn't importable
# in this env — same gate the tests below use.
pytest.importorskip("hermes_state")
from hermes_state import SessionDB
hiyuki_home = tmp_path / 'hiyuki'
maiko_home = tmp_path / 'maiko'
for home in (hiyuki_home, maiko_home):
home.mkdir(parents=True)
# Touch state.db then open via SessionDB so its own constructor
# runs whatever schema init / migration the production code
# would see at runtime. Then close immediately — each test
# opens its own handle through the production code path.
(home / 'state.db').touch()
SessionDB(home / 'state.db').close()
# Stub api.profiles to return our temp paths
import api.profiles as profiles_mod
def fake_resolve(name):
if name == 'hiyuki':
return hiyuki_home
if name == 'maiko':
return maiko_home
raise LookupError(name)
monkeypatch.setattr(profiles_mod, '_resolve_profile_home_for_name', fake_resolve)
# Active profile is hiyuki — the WRONG one for tests that pass profile='maiko'
monkeypatch.setattr(profiles_mod, 'get_active_hermes_home', lambda: hiyuki_home)
return {'hiyuki': hiyuki_home, 'maiko': maiko_home}
def _read_session(db_path: Path, session_id: str):
if not db_path.exists():
return None
conn = sqlite3.connect(db_path)
try:
# Real state.db schema (see api/state_sync.py + hermes_cli StateDB):
# `sessions` table has `id` as PRIMARY KEY (not session_id). Use real
# column names so the test queries the actual schema.
cur = conn.execute(
"SELECT id AS session_id, title, input_tokens, output_tokens "
"FROM sessions WHERE id = ?",
(session_id,),
)
row = cur.fetchone()
return row
finally:
conn.close()
def test_get_state_db_honors_explicit_profile_kwarg(two_profile_homes):
"""_get_state_db(profile='maiko') resolves to maiko's home, NOT
the active profile (hiyuki)."""
from api.state_sync import _get_state_db
# Some installs ship without the hermes_state package; the function
# returns None gracefully and there's nothing to assert.
try:
import hermes_state # noqa: F401
except ImportError:
pytest.skip("hermes_state package not available in this test env")
db = _get_state_db(profile='maiko')
if db is None:
pytest.skip("SessionDB could not open the test db (env issue)")
# We don't have a public accessor for the underlying path on SessionDB,
# but writing through it and reading the raw file should work.
db.ensure_session(session_id='probe-2762', source='webui', model='test')
try:
db.close()
except Exception:
pass
# maiko's state.db should have the row; hiyuki's should not.
assert _read_session(two_profile_homes['maiko'] / 'state.db', 'probe-2762') is not None, \
"session was not written to maiko's state.db"
assert _read_session(two_profile_homes['hiyuki'] / 'state.db', 'probe-2762') is None, \
"session leaked into hiyuki's state.db — TLS-fallback regressed"
def test_sync_session_usage_writes_only_to_named_profile(two_profile_homes):
"""sync_session_usage(..., profile='maiko') is the actual scenario from
the streaming worker thread post-#2762. The write must land in maiko's
state.db only, regardless of what the global active profile is."""
try:
import hermes_state # noqa: F401
except ImportError:
pytest.skip("hermes_state package not available in this test env")
from api.state_sync import sync_session_usage
sync_session_usage(
session_id='2762-regression',
input_tokens=42,
output_tokens=17,
estimated_cost=0.001,
model='test-model',
title='2762 regression test',
message_count=3,
profile='maiko',
)
maiko_row = _read_session(two_profile_homes['maiko'] / 'state.db', '2762-regression')
hiyuki_row = _read_session(two_profile_homes['hiyuki'] / 'state.db', '2762-regression')
assert maiko_row is not None, \
"sync_session_usage(profile='maiko') did not write to maiko's state.db"
assert hiyuki_row is None, \
"sync_session_usage(profile='maiko') leaked into hiyuki's state.db — #2762 regression"
def test_sync_session_usage_without_profile_kwarg_uses_active(two_profile_homes):
"""Backward compatibility: when called without a profile kwarg (the
pre-#2762 call shape), the function falls back to the active profile
(here: hiyuki). Existing callers should not regress."""
try:
import hermes_state # noqa: F401
except ImportError:
pytest.skip("hermes_state package not available in this test env")
from api.state_sync import sync_session_usage
sync_session_usage(
session_id='legacy-call-shape',
input_tokens=1,
output_tokens=2,
model='legacy',
title='legacy',
message_count=1,
)
hiyuki_row = _read_session(two_profile_homes['hiyuki'] / 'state.db', 'legacy-call-shape')
assert hiyuki_row is not None, \
"sync_session_usage() without profile= regressed: did not write to active profile's state.db"
def test_unknown_explicit_profile_returns_none_not_falls_back(two_profile_homes):
"""Copilot review of PR #2827: when ``profile`` is explicit and
resolution fails (e.g. typoed profile name, IO error), the
function MUST return None rather than silently fall back to
HERMES_HOME and write to the wrong DB. That fallback would
re-introduce the exact #2762 symptom (writes leaking into the
active profile).
The fixture's `fake_resolve` raises LookupError for any name
that isn't 'hiyuki' or 'maiko', so passing 'does-not-exist'
here exercises the failure path.
"""
try:
import hermes_state # noqa: F401
except ImportError:
pytest.skip("hermes_state package not available in this test env")
from api.state_sync import sync_session_usage
# Passing an unknown profile name MUST NOT cause a write to land in
# hiyuki (the active profile's home). If we leaked there, that's
# the exact bug we're guarding against.
sync_session_usage(
session_id='unknown-profile-probe',
input_tokens=99,
output_tokens=99,
model='probe',
title='probe',
message_count=1,
profile='does-not-exist',
)
hiyuki_row = _read_session(two_profile_homes['hiyuki'] / 'state.db', 'unknown-profile-probe')
maiko_row = _read_session(two_profile_homes['maiko'] / 'state.db', 'unknown-profile-probe')
assert hiyuki_row is None, (
"unknown explicit profile leaked write into hiyuki's state.db — #2762 regression"
)
assert maiko_row is None, (
"unknown explicit profile somehow ended up in maiko's state.db"
)
@pytest.mark.parametrize("bad_name", [
"../etc", # path traversal attempt
"Foo Bar", # space — invalid chars
"FOO", # uppercase — invalid per _PROFILE_ID_RE
"-leading-dash", # leading dash — invalid per regex (must start [a-z0-9])
"_underscore", # leading underscore — invalid per regex
"a" * 100, # too long (> 64 chars)
"", # empty string is handled by _is_root_profile, separate case
])
def test_invalid_profile_name_refused_not_falls_back(two_profile_homes, bad_name):
"""Per PR #2827 maintainer review: an invalid-but-non-malicious
profile name on the explicit-profile path must be REFUSED, not
quietly routed to the default state.db.
Before this defense, ``_resolve_profile_home_for_name`` would return
``_DEFAULT_HERMES_HOME`` for any name failing ``_PROFILE_ID_RE``
without raising which is the exact #2762 leak symptom with a
different trigger. The new regex check up-front turns that quiet
leak into an explicit "refuse + log + return None" so the
explicit-path contract is "write to the EXACT named profile, or
write nowhere."
The empty string is intentionally in the parametrize set because
we want to confirm it's refused — ``_is_root_profile('')`` returns
False (per ``api/profiles.py:216-217`` it short-circuits on falsy
input), so an empty explicit profile fails both the
``_is_root_profile`` check and the regex, and the contract refuses
the write. That's the expected behavior — an empty explicit name
is itself a bug at the caller, not "I want the default."
"""
try:
import hermes_state # noqa: F401
except ImportError:
pytest.skip("hermes_state package not available in this test env")
from api.state_sync import sync_session_usage
sync_session_usage(
session_id=f'invalid-name-probe-{abs(hash(bad_name))}',
input_tokens=99,
output_tokens=99,
model='probe',
title='probe',
message_count=1,
profile=bad_name,
)
sid = f'invalid-name-probe-{abs(hash(bad_name))}'
hiyuki_row = _read_session(two_profile_homes['hiyuki'] / 'state.db', sid)
maiko_row = _read_session(two_profile_homes['maiko'] / 'state.db', sid)
# All invalid names (including empty string) MUST be refused —
# no row should appear in either profile's state.db.
assert hiyuki_row is None, (
f"invalid profile name {bad_name!r} leaked write into hiyuki's "
"state.db — defense missed; #2762 regression"
)
assert maiko_row is None, (
f"invalid profile name {bad_name!r} somehow ended up in maiko's state.db"
)
+88
View File
@@ -0,0 +1,88 @@
import json
import pathlib
import re
import subprocess
import textwrap
REPO = pathlib.Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
ROUTES_PY = (REPO / "api" / "routes.py").read_text(encoding="utf-8")
def _extract_function(src: str, name: str) -> str:
marker = f"function {name}("
start = src.index(marker)
brace = src.index("{", start)
depth = 1
pos = brace + 1
while depth and pos < len(src):
ch = src[pos]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
pos += 1
assert depth == 0, f"could not extract {name}()"
return src[start:pos]
def _render(markdown: str) -> str:
js = textwrap.dedent(
r'''
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
const _PDF_EXTS=/\.pdf$/i;
const _SVG_EXTS=/\.svg$/i;
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
function t(k){ return k; }
function _mediaPlayerHtml(){ return ''; }
global.document={baseURI:'http://example.test/'};
'''
)
js += "\n" + _extract_function(UI_JS, "_matchBacktickFenceLine")
js += "\n" + _extract_function(UI_JS, "_isBacktickFenceClose")
js += "\n" + _extract_function(UI_JS, "renderMd")
js += textwrap.dedent(
r'''
const input=process.argv[1];
process.stdout.write(JSON.stringify(renderMd(input)));
'''
)
proc = subprocess.run(
["node", "-e", js, markdown],
cwd=REPO,
text=True,
capture_output=True,
timeout=30,
check=True,
)
return json.loads(proc.stdout)
def test_workspace_markdown_renders_mailto_and_tel_links():
html = _render("[email](mailto:foo@example.test) and [phone](tel:+15551212)")
assert '<a href="mailto:foo@example.test" target="_blank" rel="noopener">email</a>' in html
assert '<a href="tel:+15551212" target="_blank" rel="noopener">phone</a>' in html
def test_workspace_html_iframe_allows_links_to_escape_sandbox():
iframe = re.search(r'<iframe[^>]+id="previewHtmlIframe"[^>]*>', INDEX_HTML)
assert iframe, "previewHtmlIframe iframe not found"
sandbox = re.search(r'sandbox="([^"]+)"', iframe.group(0))
assert sandbox, "previewHtmlIframe must keep an explicit sandbox"
assert "allow-scripts" in sandbox.group(1)
assert "allow-popups" in sandbox.group(1)
assert "allow-popups-to-escape-sandbox" in sandbox.group(1)
def test_file_raw_inline_html_preview_injects_base_target_blank():
raw_handler = ROUTES_PY[ROUTES_PY.index("def _handle_file_raw") :]
assert '<base target="_blank">' in ROUTES_PY
assert "_serve_inline_html_preview" in raw_handler
assert "html_inline_ok" in raw_handler
+18
View File
@@ -0,0 +1,18 @@
import json
from server import Handler
def test_log_request_handles_malformed_request_without_path(capsys):
"""Malformed request lines can call log_request before path is assigned."""
handler = Handler.__new__(Handler)
handler.command = None
Handler.log_request(handler, "400")
line = capsys.readouterr().out.strip()
assert line.startswith("[webui] ")
record = json.loads(line.removeprefix("[webui] "))
assert record["method"] == "-"
assert record["path"] == "-"
assert record["status"] == 400
@@ -0,0 +1,37 @@
"""Coverage for cron/gateway guidance in the Tasks panel and Docker docs."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
INDEX_HTML = ROOT / "static" / "index.html"
PANELS_JS = ROOT / "static" / "panels.js"
DOCKER_DOC = ROOT / "docs" / "docker.md"
def test_tasks_panel_has_gateway_notice_container():
html = INDEX_HTML.read_text(encoding="utf-8")
assert 'id="cronGatewayNotice"' in html
assert "detail-alert" in html
def test_cron_panel_loads_gateway_status_for_scheduling_guidance():
panels = PANELS_JS.read_text(encoding="utf-8")
assert "function _cronGatewayNoticeHtml" in panels
assert "function loadCronGatewayNotice" in panels
assert "api('/api/gateway/status')" in panels
assert "Gateway not configured" in panels
assert "Gateway not running" in panels
assert "scheduled jobs require the Hermes gateway daemon" in panels
assert "loadCronGatewayNotice()" in panels
def test_docker_docs_explain_single_container_cron_gateway_boundary():
docs = DOCKER_DOC.read_text(encoding="utf-8")
assert "single-container setup runs the WebUI only" in docs
assert "scheduled jobs require the Hermes gateway daemon" in docs
assert "Gateway not configured" in docs
assert "docker-compose.two-container.yml" in docs
@@ -0,0 +1,70 @@
"""Regression checks for #2821 session pin/unpin state sync."""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ROUTES_PY = (ROOT / "api" / "routes.py").read_text(encoding="utf-8")
SESSIONS_JS = (ROOT / "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_field_helper_reads_dicts_and_objects():
from api.routes import _session_field
class SessionLike:
session_id = "obj-1"
pinned = True
archived = False
assert _session_field({"session_id": "dict-1", "pinned": True}, "pinned", False) is True
assert _session_field({"session_id": "dict-1"}, "archived", False) is False
assert _session_field(SessionLike(), "session_id", None) == "obj-1"
assert _session_field(SessionLike(), "missing", "fallback") == "fallback"
def test_pin_limit_snapshot_counts_index_dict_entries():
assert "_session_field(existing, \"session_id\", None)" in ROUTES_PY
assert "_session_field(existing, \"pinned\", False)" in ROUTES_PY
assert "_session_field(existing, \"archived\", False)" in ROUTES_PY
start = ROUTES_PY.find("persisted_pinned_ids = {")
assert start != -1, "persisted pin snapshot not found"
end = ROUTES_PY.find("with LOCK:", start)
assert end != -1, "persisted pin snapshot should be computed before LOCK"
persisted_snapshot = ROUTES_PY[start:end]
assert 'getattr(existing, "pinned", False)' not in persisted_snapshot
assert 'getattr(existing, "archived", False)' not in persisted_snapshot
def test_pin_action_does_not_short_circuit_on_stale_client_count():
body = _function_block(SESSIONS_JS, "_openSessionActionMenu")
assert "const pinLimitReached=" not in body
assert "if(pinLimitReached)" not in body
assert "_pinnedSessionCount()>=_getPinnedSessionsLimit()" not in body
assert "await api('/api/session/pin'" in body
def test_pin_action_refreshes_session_list_after_pin_failure():
body = _function_block(SESSIONS_JS, "_openSessionActionMenu")
catch_idx = body.find("}catch(err){")
assert catch_idx != -1, "Pin/unpin action must have an error path"
catch_block = body[catch_idx:body.find("}", catch_idx + len("}catch(err){")) + 1]
assert "showToast(t('session_pin_failed')+err.message)" in catch_block
assert "await renderSessionList()" in catch_block
@@ -0,0 +1,71 @@
"""Regression coverage for #2823 large Markdown workspace previews."""
from pathlib import Path
WORKSPACE_JS = Path("static/workspace.js").read_text(encoding="utf-8")
def _open_file_block() -> str:
marker = "async function openFile(path){"
start = WORKSPACE_JS.find(marker)
assert start != -1, "openFile() not found in workspace.js"
end = WORKSPACE_JS.find("\nfunction downloadFile", start)
assert end != -1, "downloadFile() marker not found after openFile()"
return WORKSPACE_JS[start:end]
def _markdown_branch() -> str:
block = _open_file_block()
start = block.find("} else if(MD_EXTS.has(ext)){")
assert start != -1, "Markdown preview branch not found in openFile()"
end = block.find("} else if(HTML_EXTS.has(ext)){", start)
assert end != -1, "HTML preview branch marker not found after Markdown branch"
return block[start:end]
def test_large_markdown_preview_limits_are_source_controlled():
assert "MD_PREVIEW_RICH_RENDER_MAX_BYTES = 64 * 1024" in WORKSPACE_JS
assert "MD_PREVIEW_RICH_RENDER_MAX_LINES = 1500" in WORKSPACE_JS
assert "function shouldRenderMarkdownPreviewAsPlainText(content)" in WORKSPACE_JS
def test_large_markdown_fallback_sets_raw_content_before_size_gate():
branch = _markdown_branch()
raw_pos = branch.find("_previewRawContent = data.content")
gate_pos = branch.find("shouldRenderMarkdownPreviewAsPlainText(data.content)")
fallback_pos = branch.find("showPreview('code')")
rich_pos = branch.find("showPreview('md')")
assert raw_pos != -1, "Markdown preview must retain raw text for Edit mode"
assert gate_pos != -1, "Markdown preview must guard rich rendering by size/line count"
assert fallback_pos != -1, "Large Markdown preview must fall back to plain text"
assert rich_pos != -1, "Small Markdown preview must still use rich Markdown mode"
assert raw_pos < gate_pos < fallback_pos < rich_pos
def test_large_markdown_fallback_uses_code_view_without_rich_render_or_katex():
branch = _markdown_branch()
gate_pos = branch.find("if(shouldRenderMarkdownPreviewAsPlainText(data.content)){")
fallback_end = branch.find("return;", gate_pos)
assert gate_pos != -1 and fallback_end != -1, "Large Markdown fallback block not found"
fallback = branch[gate_pos:fallback_end]
compact = fallback.replace(" ", "")
assert "$('previewCode').textContent=data.content" in compact
assert "setStatus(" in fallback
assert "renderMd(" not in fallback
assert "renderKatexBlocks" not in fallback
def test_small_markdown_still_renders_and_runs_katex_after_render():
branch = _markdown_branch()
fallback_end = branch.find("return;")
assert fallback_end != -1, "Large Markdown fallback must return before rich rendering"
rich = branch[fallback_end:]
render_pos = rich.find("$('previewMd').innerHTML=renderMd(data.content)")
katex_pos = rich.rfind("renderKatexBlocks")
assert render_pos != -1, "Small Markdown files must still rich-render with renderMd()"
assert katex_pos != -1, "Small Markdown file previews must still trigger KaTeX rendering"
assert katex_pos > render_pos
+82 -1
View File
@@ -5,6 +5,7 @@ from api.routes import (
_handle_mcp_servers_list,
_handle_mcp_server_update,
_handle_mcp_server_delete,
_handle_mcp_server_toggle,
_mask_secrets,
_parse_mcp_enabled,
_server_summary,
@@ -60,7 +61,7 @@ class TestMcpList:
assert status == 200
payload = _json_payload(h)
assert payload['servers'] == []
assert payload['toggle_supported'] is False
assert payload['toggle_supported'] is True
assert payload['reload_required'] is True
@patch('api.routes._mcp_runtime_status_by_name')
@@ -307,3 +308,83 @@ class TestStripMaskedValues:
def test_empty_dicts(self):
assert _strip_masked_values({}, {}) == {}
assert _strip_masked_values({"k": "v"}, {}) == {"k": "v"}
class TestMcpToggle:
"""PATCH /api/mcp/servers/<name> — enable/disable."""
@patch('api.routes.reload_config')
@patch('api.routes._save_yaml_config_file')
@patch('api.routes._get_config_path', return_value='/tmp/test.yaml')
@patch('api.routes.get_config')
def test_disable_server(self, mock_cfg, mock_path, mock_save, mock_reload):
mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run'}}}
h = _make_handler()
h.command = 'PATCH'
_handle_mcp_server_toggle(h, 'myserver', {'enabled': False})
assert mock_save.called
saved = mock_save.call_args[0][1]
assert saved['mcp_servers']['myserver']['enabled'] is False
assert mock_reload.called
@patch('api.routes.reload_config')
@patch('api.routes._save_yaml_config_file')
@patch('api.routes._get_config_path', return_value='/tmp/test.yaml')
@patch('api.routes.get_config')
def test_enable_server(self, mock_cfg, mock_path, mock_save, mock_reload):
mock_cfg.return_value = {'mcp_servers': {'myserver': {'command': 'run', 'enabled': False}}}
h = _make_handler()
h.command = 'PATCH'
_handle_mcp_server_toggle(h, 'myserver', {'enabled': True})
saved = mock_save.call_args[0][1]
assert saved['mcp_servers']['myserver']['enabled'] is True
@patch('api.routes.get_config')
def test_nonexistent_server_returns_404(self, mock_cfg):
mock_cfg.return_value = {'mcp_servers': {}}
h = _make_handler()
h.command = 'PATCH'
_handle_mcp_server_toggle(h, 'ghost', {'enabled': True})
status = h.send_response.call_args[0][0]
assert status == 404
def test_empty_name_rejected(self):
h = _make_handler()
h.command = 'PATCH'
_handle_mcp_server_toggle(h, '', {'enabled': True})
status = h.send_response.call_args[0][0]
assert status == 400
def test_missing_enabled_field_rejected(self):
h = _make_handler()
h.command = 'PATCH'
_handle_mcp_server_toggle(h, 'myserver', {})
status = h.send_response.call_args[0][0]
assert status == 400
@patch('api.routes.reload_config')
@patch('api.routes._save_yaml_config_file')
@patch('api.routes._get_config_path', return_value='/tmp/test.yaml')
@patch('api.routes.get_config')
def test_response_payload(self, mock_cfg, mock_path, mock_save, mock_reload):
mock_cfg.return_value = {'mcp_servers': {'srv': {'url': 'http://localhost'}}}
h = _make_handler()
h.command = 'PATCH'
_handle_mcp_server_toggle(h, 'srv', {'enabled': False})
body = h.wfile.write.call_args[0][0]
payload = json.loads(body.decode('utf-8'))
assert payload == {'ok': True, 'name': 'srv', 'enabled': False}
@patch('api.routes.reload_config')
@patch('api.routes._save_yaml_config_file')
@patch('api.routes._get_config_path', return_value='/tmp/test.yaml')
@patch('api.routes.get_config')
def test_url_encoded_name(self, mock_cfg, mock_path, mock_save, mock_reload):
"""Names with special characters must be URL-decoded."""
mock_cfg.return_value = {'mcp_servers': {'my server': {'command': 'x'}}}
h = _make_handler()
h.command = 'PATCH'
_handle_mcp_server_toggle(h, 'my%20server', {'enabled': False})
saved = mock_save.call_args[0][1]
assert 'my server' in saved['mcp_servers']
assert saved['mcp_servers']['my server']['enabled'] is False
+2 -1
View File
@@ -24,7 +24,8 @@ def test_mcp_panel_renders_status_badges_tool_counts_and_empty_error_states():
assert "mcp-tool-count" in js
assert "mcp-empty-state" in js
assert "mcp-error-state" in js
assert "mcp_toggle_followup" in js
assert "toggleMcpServer" in js
assert "mcp-toggle-btn" in js
assert "api('/api/mcp/servers')" in js
assert "mcp-delete-btn" not in js
assert "showMcpAddForm" not in js
+45
View File
@@ -0,0 +1,45 @@
"""Regression coverage for live Activity timeline UX.
The live Activity disclosure should surface observable run telemetry instead of a
blank Thinking placeholder while preserving the quiet tool/thinking metadata
family.
"""
import pathlib
REPO = pathlib.Path(__file__).parent.parent
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
def test_live_activity_group_has_observable_baseline_events():
assert "function _ensureLiveActivityBaseline(group)" in UI_JS
assert "Run started" in UI_JS
assert "Observable activity will appear here as the agent works." in UI_JS
assert "Model: ${modelLabel}" in UI_JS
assert "_ensureLiveActivityBaseline(group);" in UI_JS
def test_empty_thinking_placeholder_becomes_status_row_not_raw_thinking_card():
assert "data-activity-event-id=\"thinking-placeholder\"" in UI_JS
assert "Waiting on model" in UI_JS
assert "No tool activity has been reported yet." in UI_JS
assert "Waiting on tool result" in UI_JS
assert "_thinkingActivityNode(thinkingText, false)" in UI_JS
def test_tool_events_update_activity_timeline_and_summary():
assert "Tool finished: ${toolName}" in UI_JS
assert "Running tool: ${toolName}" in UI_JS
assert "No recent activity for ${_formatActiveElapsedTimer(idleAge)}" in UI_JS
assert "Activity · Running" in UI_JS
assert "Working for ${label}" in UI_JS
def test_activity_status_rows_have_quiet_metadata_styling():
assert ".agent-activity-status{" in STYLE_CSS
assert "grid-template-columns:18px minmax(0,1fr) auto" in STYLE_CSS
assert ".agent-activity-status-detail" in STYLE_CSS
assert ".agent-activity-status-time" in STYLE_CSS
assert ".agent-activity-status-error .agent-activity-status-label{color:var(--error);}" in STYLE_CSS
+19
View File
@@ -665,6 +665,11 @@ def test_100dvh_viewport_height():
"style.css must use 100dvh for correct mobile viewport height (100vh hides content under address bar)"
def test_viewport_disables_page_zoom_for_native_pwa_shell():
"""Installed PWA launches should not rubber-band into browser-style page zoom."""
assert 'name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"' in HTML
def test_pwa_safe_area_top_stays_scoped_to_installed_modes():
"""The PWA shell should not opt into cover-mode geometry for every browser surface."""
assert 'viewport-fit=cover' not in HTML
@@ -701,6 +706,20 @@ def test_safe_area_variables_available_for_pwa_shell():
)
def test_pwa_startup_classes_have_native_shell_affordances():
"""The JS-startup fallback classes should mirror browser display-mode CSS.
iOS and embedded webviews do not always evaluate display-mode media queries
the same way as Chromium. pwa-startup.js adds classes early, so CSS should
provide the same native-feel affordances through those classes.
"""
assert ".pwa-standalone" in CSS
assert ".pwa-standalone .app-titlebar-reload" in CSS
assert "overscroll-behavior:none" in CSS
assert ".pwa-offline .app-titlebar::after" in CSS
assert "pwa-title-resume" in CSS
def test_composer_touch_target_size():
"""Send button and composer inputs must have minimum 44px touch targets on mobile.
+300 -14
View File
@@ -3,32 +3,318 @@ 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().
session model later through loadSession(). The boot fix must not make browser
model-state writes pointless or let later model-list refreshes reset a live
in-page selection.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
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")
NODE = shutil.which("node")
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
_DRIVER_SRC = r"""
const fs = require('fs');
const ui = fs.readFileSync(process.argv[2], 'utf8');
function extractFunc(name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\(');
const start = ui.search(re);
if (start < 0) throw new Error(name + ' not found');
let openParen = ui.indexOf('(', start);
let i = openParen + 1;
let parenDepth = 1;
while (parenDepth > 0 && i < ui.length) {
if (ui[i] === '(') parenDepth++;
else if (ui[i] === ')') parenDepth--;
i++;
}
i = ui.indexOf('{', i);
let depth = 1;
i++;
while (depth > 0 && i < ui.length) {
if (ui[i] === '{') depth++;
else if (ui[i] === '}') depth--;
i++;
}
return ui.slice(start, i);
}
const calls = {syncModelChip: 0, renderModelDropdown: 0, positionModelDropdown: 0, liveFetches: []};
let modelSelect;
let apiModels;
function makeSelect(options, initialValue) {
const sel = {id: 'modelSelect', options: [], selectedIndex: -1, selectedOptions: []};
Object.defineProperty(sel, 'value', {
get() { return this._value || ''; },
set(v) {
this._value = v;
const idx = this.options.findIndex(o => o.value === v);
this.selectedIndex = idx;
this.selectedOptions = idx >= 0 ? [this.options[idx]] : [];
}
});
Object.defineProperty(sel, 'innerHTML', {
get() { return ''; },
set(_v) {
this.options = [];
this.value = '';
}
});
sel.querySelector = function(_selector) { return this.options[0] || null; };
sel.appendChild = function(node) {
const incoming = node && node.tagName === 'OPTGROUP' ? node.children : [node];
for (const opt of incoming || []) {
opt.parentElement = opt.parentElement || node;
this.options.push(opt);
if (this.selectedIndex < 0) this.value = opt.value;
else if (this._value === opt.value) this.value = opt.value;
}
};
for (const item of options || []) {
const group = {tagName: 'OPTGROUP', dataset: {provider: item.provider || ''}, children: []};
const opt = {tagName: 'OPTION', value: item.value, textContent: item.label || item.value, title: '', dataset: {}, parentElement: group};
group.children.push(opt);
sel.appendChild(group);
}
sel.value = initialValue || '';
return sel;
}
function $(id) {
if (id === 'modelSelect') return modelSelect;
if (id === 'composerModelDropdown') return {classList: {contains(){ return false; }}};
return null;
}
function t(key) { return key; }
function getModelLabel(v) { return v; }
function syncModelChip() { calls.syncModelChip++; }
function renderModelDropdown() { calls.renderModelDropdown++; }
function _positionModelDropdown() { calls.positionModelDropdown++; }
function _redirectIfUnauth() { return false; }
function _fetchLiveModels(provider, _sel) { calls.liveFetches.push(provider); }
const document = {
baseURI: 'http://127.0.0.1/hermes/',
createElement(tag) {
const upper = tag.toUpperCase();
if (upper === 'OPTGROUP') {
return {
tagName: 'OPTGROUP',
label: '',
dataset: {},
children: [],
appendChild(opt) { opt.parentElement = this; this.children.push(opt); },
};
}
return {tagName: upper, value: '', textContent: '', title: '', dataset: {}, parentElement: null};
},
};
const localStorage = {getItem(){return null;}, setItem(){}, removeItem(){}};
const window = {_defaultModel: null, _activeProvider: null, _configuredModelBadges: {}};
let _dynamicModelLabels = {};
let _liveModelFetchPending = new Set();
let _liveModelCache = {};
for (const name of [
'_getOptionProviderId', '_providerFromModelValue', '_modelStateForSelect',
'_captureModelDropdownSelection', '_findModelInDropdown', '_refreshOpenModelDropdown',
'_applyModelToDropdown', '_reconcileModelDropdownSelection', 'populateModelDropdown'
]) {
eval(extractFunc(name));
}
const args = JSON.parse(process.argv[3]);
apiModels = args.apiModels;
modelSelect = makeSelect(args.initialOptions, args.initialValue);
var S = {session: args.session || null};
fetch = async function(url) {
const href = String(url);
if (href.includes('api/models')) {
return {json: async () => apiModels};
}
throw new Error('unexpected fetch ' + href);
};
populateModelDropdown(args.opts || {}).then(() => {
process.stdout.write(JSON.stringify({
selectValue: modelSelect.value,
selectedProvider: modelSelect.selectedOptions[0] ? _getOptionProviderId(modelSelect.selectedOptions[0]) : null,
defaultModel: window._defaultModel,
activeProvider: window._activeProvider,
calls,
}));
}).catch(err => {
console.error(err && err.stack || err);
process.exit(1);
});
"""
@pytest.fixture(scope="module")
def populate_driver_path(tmp_path_factory):
p = tmp_path_factory.mktemp("model_default_driver") / "driver.js"
p.write_text(_DRIVER_SRC, encoding="utf-8")
return str(p)
def test_boot_settings_applies_default_without_deleting_browser_model_state():
snippet = _boot_default_apply_snippet()
assert "Fresh page boot must prefer the profile/server default" in snippet
assert "if(sel&&typeof _applyModelToDropdown==='function')" in snippet
assert "if(sel&&!savedState&&typeof _applyModelToDropdown==='function')" not in BOOT_JS
assert "_clearPersistedModelState" not in snippet
assert "localStorage.removeItem('hermes-webui-model')" not in snippet
assert "localStorage.removeItem('hermes-webui-model-state')" not in snippet
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 test_boot_model_dropdown_explicitly_requests_profile_default_precedence():
assert "populateModelDropdown({preferProfileDefaultOnFreshBoot:true})" in BOOT_JS
# #2726 invariant: boot path must keep profile/server default ahead of stale
# browser-persisted state when a default exists. Post-#2716 cherry-pick onto
# post-stage-batch11 master uses `stateToApply` pattern rather than the
# original `allowBootSavedModelOverride` variable name. Semantics preserved —
# check for the actual gate predicate that's equivalent.
assert (
"const allowBootSavedModelOverride=!window._defaultModel" in BOOT_JS
or "!window._defaultModel?savedState:null" in BOOT_JS
), "boot.js missing the !window._defaultModel gate for saved-state override"
def _populate_default_guard_snippet() -> str:
marker = "// Set default model from server on fresh/blank boot."
def test_populate_model_dropdown_reconciles_selection_after_rebuild():
assert "const previousSelection=_captureModelDropdownSelection(sel);" in UI_JS
assert "_reconcileModelDropdownSelection(sel,data,previousSelection,opts);" in UI_JS
snippet = _reconcile_selection_snippet()
assert "preferProfileDefaultOnFreshBoot" in snippet
assert "return _applyModelToDropdown(data.default_model,sel,data.active_provider||null);" in snippet
assert "return _applyModelToDropdown(activeSession.model,sel,activeSession.model_provider||null);" in snippet
assert "return _applyModelToDropdown(previousState.model,sel,previousState.model_provider||null);" in snippet
assert "_readPersistedModelState()" not in snippet
assert "localStorage.getItem('hermes-webui-model')" not in snippet
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_non_boot_model_refresh_preserves_current_in_page_selection(populate_driver_path):
got = _run_populate_driver(
populate_driver_path,
initial_value="@expensive:gpt-5.5",
opts={},
session=None,
)
assert got["selectValue"] == "@expensive:gpt-5.5"
assert got["selectedProvider"] == "expensive"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_boot_model_refresh_prefers_profile_default_over_stale_selection(populate_driver_path):
got = _run_populate_driver(
populate_driver_path,
initial_value="@expensive:gpt-5.5",
opts={"preferProfileDefaultOnFreshBoot": True},
session=None,
)
assert got["selectValue"] == "@safe:gpt-4o-mini"
assert got["selectedProvider"] == "safe"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_session_model_wins_over_boot_default_and_previous_selection(populate_driver_path):
got = _run_populate_driver(
populate_driver_path,
initial_value="@expensive:gpt-5.5",
opts={"preferProfileDefaultOnFreshBoot": True},
session={"model": "@work:glm-5.1", "model_provider": "work"},
)
assert got["selectValue"] == "@work:glm-5.1"
assert got["selectedProvider"] == "work"
@pytest.mark.skipif(NODE is None, reason="node not on PATH")
def test_non_boot_refresh_does_not_reapply_default_when_previous_model_disappears(populate_driver_path):
got = _run_populate_driver(
populate_driver_path,
initial_value="@removed:gpt-old",
opts={},
session=None,
api_groups=[
{"provider": "Safe", "provider_id": "safe", "models": [{"id": "@safe:gpt-4o-mini", "label": "GPT-4o mini"}]},
],
initial_options=[
{"provider": "removed", "value": "@removed:gpt-old", "label": "Old"},
],
)
# The old value is gone, so the browser/select fallback can land on the
# refreshed first option. The important invariant is that the profile default
# is not reapplied as a special policy outside fresh boot.
assert got["selectValue"] == "@safe:gpt-4o-mini"
assert got["selectedProvider"] == "safe"
def _run_populate_driver(
driver_path: str,
*,
initial_value: str,
opts: dict,
session: dict | None,
api_groups: list[dict] | None = None,
initial_options: list[dict] | None = None,
):
groups = api_groups or [
{"provider": "Safe", "provider_id": "safe", "models": [{"id": "@safe:gpt-4o-mini", "label": "GPT-4o mini"}]},
{"provider": "Expensive", "provider_id": "expensive", "models": [{"id": "@expensive:gpt-5.5", "label": "GPT-5.5"}]},
{"provider": "Work", "provider_id": "work", "models": [{"id": "@work:glm-5.1", "label": "GLM-5.1"}]},
]
payload = {
"initialValue": initial_value,
"initialOptions": initial_options
or [
{"provider": "expensive", "value": "@expensive:gpt-5.5", "label": "GPT-5.5"},
{"provider": "safe", "value": "@safe:gpt-4o-mini", "label": "GPT-4o mini"},
{"provider": "work", "value": "@work:glm-5.1", "label": "GLM-5.1"},
],
"apiModels": {
"active_provider": "safe",
"default_model": "@safe:gpt-4o-mini",
"configured_model_badges": {},
"groups": groups,
},
"opts": opts,
"session": session,
}
assert NODE is not None
result = subprocess.run(
[NODE, driver_path, str(REPO / "static" / "ui.js"), json.dumps(payload)],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise RuntimeError(f"node driver failed:\nSTDOUT={result.stdout}\nSTDERR={result.stderr}")
return json.loads(result.stdout)
def _boot_default_apply_snippet() -> str:
marker = "// Fresh page boot must prefer the profile/server default"
start = BOOT_JS.index(marker)
return BOOT_JS[start - 120 : start + 700]
def _reconcile_selection_snippet() -> str:
marker = "function _reconcileModelDropdownSelection"
start = UI_JS.index(marker)
return UI_JS[start : start + 500]
end = UI_JS.index("function _providerQualifiedModelValueForSelect", start)
return UI_JS[start:end]
+4 -2
View File
@@ -88,9 +88,11 @@ class TestLoadSessionIdleOverlap:
"The idle path should rely on renderMessages()'s consolidated "
"post-render pass instead of running a second highlight pass."
)
assert "await" in block and "_dirP" in block, (
"loadDir() result should still be stored and awaited."
assert "_dirP" in block and "await _dirP" not in block, (
"loadDir() should refresh the workspace without blocking "
"session-load completion."
)
assert "_dirP.catch" in block
break
assert found, (
+2 -2
View File
@@ -112,5 +112,5 @@ def test_rtl_in_config_defaults_and_writable_keys():
def test_rtl_localized_in_all_locales():
js = I18N.read_text(encoding="utf-8")
# Count occurrences — should match the 11 locale blocks
assert js.count("settings_label_rtl:") == 11
assert js.count("settings_desc_rtl:") == 11
assert js.count("settings_label_rtl:") == 12
assert js.count("settings_desc_rtl:") == 12
+59 -2
View File
@@ -16,6 +16,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MANIFEST = ROOT / "static" / "manifest.json"
SW = ROOT / "static" / "sw.js"
PWA_STARTUP = ROOT / "static" / "pwa-startup.js"
BOOT = ROOT / "static" / "boot.js"
INDEX = ROOT / "static" / "index.html"
ROUTES = ROOT / "api" / "routes.py"
AUTH = ROOT / "api" / "auth.py"
@@ -117,7 +119,7 @@ class TestServiceWorker:
"""
src = SW.read_text(encoding="utf-8")
assert "Shell assets: network-first with cache fallback" in src
assert "fetch(event.request).then((response)" in src
assert "fetch(new Request(event.request, { cache: 'no-store' })).then((response)" in src
assert "caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))" in src
assert ".catch(() => caches.match(event.request)" in src
assert "if (cached) return cached;" not in src, (
@@ -281,10 +283,65 @@ class TestIndexHtmlIntegration:
marker = "// Shell assets: network-first with cache fallback"
assert marker in src
block = src[src.find(marker):src.find(marker) + 900]
assert "fetch(event.request).then" in block
assert "fetch(new Request(event.request, { cache: 'no-store' })).then" in block
assert "caches.match(event.request)" in block
assert "caches.match(event.request).then((cached)" not in block[:250]
def test_index_loads_pwa_startup_helper_early(self):
"""The installed-app shell should classify standalone/offline mode before
the main UI bundle hydrates, so native chrome and safe-area affordances
are present on first paint.
"""
src = INDEX.read_text(encoding="utf-8")
preload_pos = src.find('href="static/pwa-startup.js?v=__WEBUI_VERSION__"')
script_pos = src.find('src="static/pwa-startup.js?v=__WEBUI_VERSION__"')
ui_pos = src.find('static/ui.js?v=__WEBUI_VERSION__')
assert preload_pos != -1, "index.html must preload the PWA startup helper"
assert script_pos != -1, "index.html must load the PWA startup helper"
assert ui_pos != -1, "index.html must load the main UI bundle"
assert preload_pos < ui_pos and script_pos < ui_pos, (
"pwa-startup.js must run before ui.js so standalone/offline classes "
"are available before the app shell paints"
)
def test_sw_precaches_pwa_startup_helper(self):
src = SW.read_text(encoding="utf-8")
assert "pwa-startup.js' + VQ" in src or 'pwa-startup.js" + VQ' in src, (
"sw.js SHELL_ASSETS must pre-cache pwa-startup.js with the same "
"version query used by index.html"
)
def test_manifest_has_native_launch_fields(self):
data = json.loads(MANIFEST.read_text(encoding="utf-8"))
assert data.get("id") == "./"
assert data.get("scope") == "./"
assert data.get("start_url") == "./?source=pwa"
assert "standalone" in data.get("display_override", []), (
"manifest.display_override should preserve standalone as a native "
"launch fallback"
)
shortcuts = data.get("shortcuts") or []
assert any(shortcut.get("url") == "./?source=pwa&action=new-chat" for shortcut in shortcuts), (
"manifest should expose a native shortcut for starting a new chat"
)
def test_pwa_startup_detects_standalone_and_install_events(self):
src = PWA_STARTUP.read_text(encoding="utf-8")
assert "pwa-standalone" in src
assert "pwa-browser" in src
assert "beforeinstallprompt" in src
assert "appinstalled" in src
assert "HermesPWA" in src
assert "launchAction" in src
assert "promptInstall" in src
def test_pwa_new_chat_shortcut_is_handled_at_boot(self):
src = BOOT.read_text(encoding="utf-8")
assert "pwaLaunchAction" in src
assert "launchAction()" in src
assert "pwaLaunchAction==='new-chat'" in src
assert "await newSession(true)" in src
def test_index_route_url_encodes_asset_version(self):
src = ROUTES.read_text(encoding="utf-8")
idx = src.find('parsed.path in ("/", "/index.html")')
+2 -2
View File
@@ -88,5 +88,5 @@ def test_quota_chip_panels_round_trip():
def test_quota_chip_localized_in_all_locales():
js = I18N.read_text(encoding="utf-8")
assert js.count("settings_label_quota_chip:") == 11, "11 locales expected"
assert js.count("settings_desc_quota_chip:") == 11, "11 locales expected"
assert js.count("settings_label_quota_chip:") == 12, "12 locales expected"
assert js.count("settings_desc_quota_chip:") == 12, "12 locales expected"
+80 -10
View File
@@ -409,11 +409,31 @@ def test_chat_start_route_selects_adapter_only_when_flag_enabled():
start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)]
assert "runtime_adapter_enabled()" in start_body
assert "runtime_adapter_runner_enabled()" in start_body
assert "build_runtime_adapter(" in start_body
assert "legacy_adapter_factory=_legacy_adapter_factory" in start_body
assert "runner_client_factory=_runtime_runner_client_factory" in start_body
assert "LegacyJournalRuntimeAdapter" in start_body
assert "_start_chat_stream_for_session(" in start_body
assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in start_body, "route should use runtime_adapter_enabled(), not inline env checks"
def test_runner_local_chat_start_selection_does_not_fallback_to_legacy():
routes = importlib.import_module("api.routes")
src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
start_idx = src.index("def _handle_chat_start")
start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)]
flag_branch = "if runtime_adapter_enabled() or runtime_adapter_runner_enabled():"
assert flag_branch in start_body
assert "except NotImplementedError as exc:" in start_body
assert 'return j(handler, {"error": str(exc)}, status=501)' in start_body
assert "runner-local chat backend is not configured" in src
adapter_branch = start_body[start_body.index(flag_branch):start_body.index("else:", start_body.index(flag_branch))]
assert "_start_chat_stream_for_session(" in adapter_branch, "legacy-journal delegate should still call the legacy path"
assert "runtime_adapter_runner_enabled()" in adapter_branch
def test_chat_start_adapter_path_preserves_legacy_response_shape():
"""The RuntimeAdapter seam must be invisible to /api/chat/start callers.
@@ -422,17 +442,53 @@ def test_chat_start_adapter_path_preserves_legacy_response_shape():
"""
routes = importlib.import_module("api.routes")
src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
start_idx = src.index("def _handle_chat_start")
start_body = src[start_idx:src.index("def _resolve_chat_workspace_with_recovery", start_idx)]
branch_start = start_body.index("if runtime_adapter_enabled():")
branch_end = start_body.index("else:", branch_start)
adapter_branch = start_body[branch_start:branch_end]
helper_idx = src.index("def _chat_start_response_from_run_start")
helper_body = src[helper_idx:src.index("def _runtime_adapter_goal_action", helper_idx)]
assert 'response.setdefault("stream_id", result.stream_id)' in adapter_branch
assert 'response.setdefault("session_id", result.session_id)' in adapter_branch
assert 'response.setdefault("run_id", result.run_id)' not in adapter_branch
assert 'response.setdefault("status", result.status)' not in adapter_branch
assert 'response.setdefault("active_controls", result.active_controls)' not in adapter_branch
assert '"stream_id",' in helper_body
assert '"session_id",' in helper_body
assert 'response.setdefault("stream_id", result.stream_id)' in helper_body
assert 'response.setdefault("session_id", result.session_id)' in helper_body
assert '"run_id",' not in helper_body
assert '"status",' not in helper_body
assert '"active_controls",' not in helper_body
def test_chat_start_response_from_run_start_filters_adapter_internal_fields():
routes = importlib.import_module("api.routes")
runtime = importlib.import_module("api.runtime_adapter")
response = routes._chat_start_response_from_run_start(
runtime.RunStartResult(
run_id="runner-internal-1",
session_id="s1",
stream_id="runner-stream-1",
status="running",
active_controls=["cancel"],
payload={
"stream_id": "runner-stream-1",
"session_id": "s1",
"pending_started_at": 123.0,
"turn_id": "turn-1",
"title": "Demo",
"effective_model": "gpt-5.5",
"effective_model_provider": "openai-codex",
"run_id": "runner-internal-1",
"status": "running",
"active_controls": ["cancel"],
},
)
)
assert response == {
"stream_id": "runner-stream-1",
"session_id": "s1",
"pending_started_at": 123.0,
"turn_id": "turn-1",
"title": "Demo",
"effective_model": "gpt-5.5",
"effective_model_provider": "openai-codex",
}
def test_rfc_distinguishes_goal_routing_from_queue_route_staging():
@@ -485,6 +541,7 @@ def test_rfc_defines_slice4d_supervised_runner_route_gate():
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 "Status as of 2026-05-23: shipped in v0.51.108 via #2744" 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
@@ -496,6 +553,19 @@ def test_rfc_defines_slice4d_supervised_runner_route_gate():
assert "WebUI remains the rich workbench while\n only execution ownership moves" in rfc
def test_rfc_defines_slice4e_runner_chat_start_route_selection_harness():
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 4e: Default-off runner chat-start route-selection harness" in rfc
assert "route `/api/chat/start` through `build_runtime_adapter(...)`" in rfc
assert "`legacy-direct` stays default" in rfc
assert "`legacy-journal`\ncontinues to delegate to the legacy in-process stream path" in rfc
assert "`runner-local`\ndoes not silently fall back to legacy" in rfc
assert "return a bounded not-configured error for `runner-local`" in rfc
assert "`run_id`, `status`, and\n `active_controls` remain internal" in rfc
assert "no supervised runner process yet" in rfc
def test_runner_runtime_adapter_passes_explicit_start_payload_without_env_mutation(monkeypatch):
runtime = importlib.import_module("api.runtime_adapter")
captured = []
+2 -2
View File
@@ -41,9 +41,9 @@ def test_service_worker_uses_network_first_for_page_navigation():
"""Page navigations must hit the server before cache so expired auth redirects work."""
navigate_idx = SW_SRC.find("event.request.mode === 'navigate'")
assert navigate_idx != -1, "service worker must special-case page navigations"
fetch_idx = SW_SRC.find("fetch(event.request)", navigate_idx)
fetch_idx = SW_SRC.find("fetch(new Request(event.request, { cache: 'no-store' }))", navigate_idx)
cache_idx = SW_SRC.find("caches.match", navigate_idx)
assert fetch_idx != -1, "navigation branch must try the live server first"
assert fetch_idx != -1, "navigation branch must try the live server first while bypassing HTTP cache"
assert cache_idx != -1, "navigation branch may use cached shell only as offline fallback"
assert fetch_idx < cache_idx, (
"navigation requests must be network-first, not cache-first, so auth redirects "
+10 -10
View File
@@ -123,8 +123,8 @@ def test_all_sessions_backfills_last_message_at_for_legacy_index_rows():
assert persisted[0].get("last_message_at") == 100.0
def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch):
"""Index pruning should not reacquire the session lock for every row."""
def test_all_sessions_prune_batches_persisted_id_snapshot(monkeypatch):
"""Index pruning should not probe each backing file through the helper."""
index_file = models.SESSION_INDEX_FILE
entries = [
{
@@ -152,22 +152,22 @@ def test_all_sessions_prune_reuses_in_memory_id_snapshot(monkeypatch):
"archived": False,
},
]
for entry in entries:
(models.SESSION_DIR / f"{entry['session_id']}.json").write_text(
"{}",
encoding="utf-8",
)
_write_index_file(index_file, entries)
seen = []
def _assert_not_called(session_id, in_memory_ids=None):
raise AssertionError("all_sessions should batch persisted ids before pruning")
def _assert_snapshot_used(session_id, in_memory_ids=None):
assert in_memory_ids is not None, "all_sessions should snapshot SESSIONS once before pruning"
seen.append(session_id)
return True
monkeypatch.setattr(models, "_index_entry_exists", _assert_snapshot_used)
monkeypatch.setattr(models, "_index_entry_exists", _assert_not_called)
monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None)
rows = models.all_sessions()
assert [row["session_id"] for row in rows] == ["sess_a", "sess_b"]
assert seen == ["sess_a", "sess_b"]
# ── 6. test_incremental_patch_correctness ─────────────────────────────────
+50 -1
View File
@@ -62,12 +62,61 @@ def test_boot_does_not_block_session_restore_on_model_catalog():
assert "if(s.default_model){" in src
assert "window._defaultModel=s.default_model;" in src
assert "const _hydrateBootModelDropdown=()=>populateModelDropdown().then" in src
assert "const _hydrateBootModelDropdown=()=>populateModelDropdown({preferProfileDefaultOnFreshBoot:true}).then" in src
assert "window._modelDropdownReady=null;" in src
assert "window._ensureModelDropdownReady=_startBootModelDropdown;" in src
assert "await populateModelDropdown()" not in src
def test_boot_primes_model_catalog_without_awaiting_it():
"""The boot-time prime must NOT await the model-catalog hydration before
rendering the session list. A later awaited hydration inside the saved-
session restore path at ``if(S.session) await _startBootModelDropdown();``
is intentional that one re-applies the saved session's model after the
live catalog hydrates so the chip never shows a stale static default
(see comment in static/boot.js next to the saved-session restore).
"""
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
ensure_pos = src.index("window._ensureModelDropdownReady=_startBootModelDropdown;")
prime_pos = src.index("Promise.resolve(_startBootModelDropdown()).catch(()=>{});", ensure_pos)
session_restore_pos = src.index("await renderSessionList();", prime_pos)
assert ensure_pos < prime_pos < session_restore_pos
# No await on the boot-prime path itself: between ensure_pos and the first
# session_restore await, the dropdown is fired-and-forgotten.
boot_prelude = src[ensure_pos:session_restore_pos]
assert "await _startBootModelDropdown()" not in boot_prelude, (
"Boot prelude must not await _startBootModelDropdown — the prime is "
"fire-and-forget so the sidebar can render before /api/models returns."
)
assert "await populateModelDropdown()" not in boot_prelude
def test_failed_boot_model_catalog_prime_is_retryable():
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
# #2726 parameterized the call: populateModelDropdown({preferProfileDefaultOnFreshBoot:true})
# Match either signature shape — empty args (legacy) OR opts arg (post-#2726).
candidates = [
"const _hydrateBootModelDropdown=()=>populateModelDropdown().then",
"const _hydrateBootModelDropdown=()=>populateModelDropdown({preferProfileDefaultOnFreshBoot:true}).then",
]
start = -1
for needle in candidates:
try:
start = src.index(needle)
break
except ValueError:
continue
assert start >= 0, "boot.js missing _hydrateBootModelDropdown wrapper around populateModelDropdown()"
end = src.index("const _startBootModelDropdown=()=>", start)
block = src[start:end]
assert "window._modelDropdownReady=null;" in block
assert "throw e;" in block
def test_boot_primes_visible_default_model_without_catalog_fetch():
src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
default_block_start = src.index("if(s.default_model){")
+2 -2
View File
@@ -99,8 +99,8 @@ 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 >= 12, f"Expected ≥12 locales, found {label_count}"
assert desc_count >= 12, f"Expected ≥12 locales, found {desc_count}"
assert label_count == desc_count, \
f"Label ({label_count}) and desc ({desc_count}) counts must match"
+31 -8
View File
@@ -13,6 +13,29 @@ def get_text(path):
return r.read().decode(), r.status
def _find_global_selector(css, selector):
"""Find the GLOBAL (unscoped) occurrence of a selector in style.css.
Skin-scoped rules of the form ``:root[data-skin="..."] .selector{...}``
can appear earlier in the file than the global ``.selector{...}`` rule,
so a naive ``css.find(".selector{")`` would match the wrong block.
This walks every occurrence and returns the first one whose preceding
context on the same line does NOT include ``:root[data-skin=``.
See references/skin-scoped-css-test-trap.md.
"""
pos = 0
while True:
idx = css.find(selector, pos)
if idx == -1:
return -1
line_start = css.rfind('\n', 0, idx) + 1
line_prefix = css[line_start:idx]
if ':root[data-skin=' not in line_prefix:
return idx
pos = idx + 1
# ── index.html ────────────────────────────────────────────────────────────
@@ -86,7 +109,7 @@ def test_send_btn_is_circle():
"""send-btn must use border-radius:50% for the circle shape."""
css, status = get_text("/static/style.css")
assert status == 200
send_idx = css.find('.send-btn{')
send_idx = _find_global_selector(css, '.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
@@ -96,7 +119,7 @@ def test_send_btn_is_circle():
def test_send_btn_fixed_dimensions():
"""send-btn must have explicit width and height (icon-circle, not text-padded)."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
send_idx = _find_global_selector(css, '.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
@@ -107,7 +130,7 @@ def test_send_btn_fixed_dimensions():
def test_send_btn_no_old_padding():
"""send-btn must not use text padding layout (old pill style removed)."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
send_idx = _find_global_selector(css, '.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
@@ -118,7 +141,7 @@ def test_send_btn_no_old_padding():
def test_send_btn_accent_background():
"""send-btn background must use the accent color variable."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
send_idx = _find_global_selector(css, '.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
@@ -128,7 +151,7 @@ def test_send_btn_accent_background():
def test_send_btn_has_transition():
"""send-btn must have transition for smooth hover/active states."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
send_idx = _find_global_selector(css, '.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
@@ -138,7 +161,7 @@ def test_send_btn_has_transition():
def test_send_btn_has_box_shadow():
"""send-btn must have a box-shadow glow effect."""
css, _ = get_text("/static/style.css")
send_idx = css.find('.send-btn{')
send_idx = _find_global_selector(css, '.send-btn{')
brace_open = css.find('{', send_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
@@ -148,7 +171,7 @@ def test_send_btn_has_box_shadow():
def test_send_btn_hover_has_scale():
"""send-btn:hover must use transform:scale for a satisfying hover effect."""
css, _ = get_text("/static/style.css")
hover_idx = css.find('.send-btn:hover{')
hover_idx = _find_global_selector(css, '.send-btn:hover{')
brace_open = css.find('{', hover_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
@@ -158,7 +181,7 @@ def test_send_btn_hover_has_scale():
def test_send_btn_active_shrinks():
"""send-btn:active must scale down slightly for tactile press feedback."""
css, _ = get_text("/static/style.css")
active_idx = css.find('.send-btn:active{')
active_idx = _find_global_selector(css, '.send-btn:active{')
brace_open = css.find('{', active_idx)
brace_close = css.find('}', brace_open)
rule = css[brace_open:brace_close]
+12 -2
View File
@@ -57,12 +57,22 @@ def test_load_session_clears_saved_stale_404_and_rethrows_to_boot():
"""A missing saved session should be removed and let boot show the empty state."""
block = _load_session_error_block()
assert "e.status===404" in block, "loadSession must keep a 404-specific branch"
assert "localStorage.getItem('hermes-webui-session')===sid" in block, (
"loadSession must only clear the saved active session key"
# PR #2808 (#2798): boot-time 404 cleanup is now gated on `!currentSid` alone
# (the request was for the saved active session), not on the additional
# `localStorage.getItem('hermes-webui-session')===sid` equality check.
# The previous gate failed when the stale sid came from /session/{id} URL
# while localStorage was empty (post state-reset).
assert "!currentSid" in block, (
"loadSession must keep the !currentSid gate so click-into 404s don't "
"wipe the saved active-session key"
)
assert "localStorage.removeItem('hermes-webui-session')" in block, (
"loadSession must clear stale saved session IDs on 404"
)
assert "history.replaceState" in block, (
"loadSession must strip stale /session/{id} from the URL so a refresh "
"doesn't re-trigger the 404 loop"
)
assert "_loadingSessionId = null" in block, (
"loadSession must clear the in-flight load marker before rethrowing"
)
@@ -0,0 +1,272 @@
"""Regression tests for static-asset compression + cache headers in _serve_static.
Pre-fix shape:
/static/* served raw bytes with `Cache-Control: no-store` and no
`Content-Encoding`. A page reload over a slow link re-downloaded the
full ~2.4 MB shell on every visit, even though every reference in
static/index.html and static/sw.js carries `?v=__WEBUI_VERSION__`
fingerprinting that already guarantees a fresh URL on redeploy.
Fix: _serve_static now negotiates gzip when the client opts in, emits
weak ETags for conditional GETs, and sends `max-age=31536000, immutable`
when the request URL carries a `?v=` fingerprint (`max-age=300`
otherwise). Bytes + headers are cached in-process and invalidated on
(size, mtime) change so a redeploy is picked up without a restart.
These tests pin both halves header policy AND the cache-invalidation
contract so future refactors of _serve_static cannot silently
re-introduce no-store or break the gzip/304 path.
"""
import gzip
from types import SimpleNamespace
from urllib.parse import urlparse
class _FakeHandler:
"""Minimal request handler stand-in matching tests/test_session_static_assets.py."""
def __init__(self, request_headers=None):
self.status = None
self.sent_headers = []
self.body = bytearray()
self.wfile = self
self.headers = dict(request_headers or {})
def send_response(self, status):
self.status = status
def send_header(self, name, value):
self.sent_headers.append((name, value))
def end_headers(self):
pass
def write(self, data):
self.body.extend(data)
def header(self, name):
for key, value in self.sent_headers:
if key.lower() == name.lower():
return value
return None
def _make_static_file(static_root, name, content):
path = static_root / name
path.write_bytes(content if isinstance(content, bytes) else content.encode("utf-8"))
return path
def _serve(routes, path, query="", request_headers=None):
"""Invoke _serve_static via the real urllib parse path."""
parsed = urlparse(f"http://x{path}{('?' + query) if query else ''}")
h = _FakeHandler(request_headers)
routes._serve_static(h, parsed)
return h
def _patch_static_root(monkeypatch, static_root):
"""Force _serve_static to read from a temp directory and clear its cache."""
from api import routes
monkeypatch.setattr(
routes, "_serve_static",
lambda handler, parsed, _root=static_root, _orig=routes._serve_static: _orig(handler, parsed),
)
# Tests redirect by writing files to the real static dir's parent layout
# via a fixture; instead we monkeypatch the module-level Path computation.
# _serve_static derives static_root from `Path(__file__).parent.parent / "static"`,
# so we monkeypatch __file__ via a closure that re-resolves with our temp tree.
# Simpler: patch the cache and call the real function with a parsed path that
# resolves under the real static dir. We use the fixture below instead.
# ── Fixture: build a tiny isolated static tree and rebind paths ───────────
import pytest
@pytest.fixture
def isolated_static(tmp_path, monkeypatch):
"""Stand up an isolated static/ tree and rebind _serve_static to use it.
Yields the static_root Path so tests can drop files into it.
"""
from api import routes
static_root = tmp_path / "static"
static_root.mkdir()
# Patch the cache so cross-test state cannot leak.
monkeypatch.setattr(routes, "_STATIC_CACHE", {}, raising=True)
# _serve_static derives static_root from Path(__file__).parent.parent.
# Rebind by monkeypatching Path resolution: we wrap the function so the
# caller-visible signature is unchanged.
original = routes._serve_static
def wrapped(handler, parsed):
# Trick: temporarily monkeypatch Path so the function sees our temp tree.
import api.routes as ar
orig_file = ar.__file__
# Place a sentinel api/routes.py "next to" tmp_path so the relative
# walk lands in our static_root.
fake_api_dir = tmp_path / "api"
fake_api_dir.mkdir(exist_ok=True)
fake_routes = fake_api_dir / "routes.py"
if not fake_routes.exists():
fake_routes.write_text("# stub for path resolution\n")
monkeypatch.setattr(ar, "__file__", str(fake_routes))
try:
return original(handler, parsed)
finally:
monkeypatch.setattr(ar, "__file__", orig_file)
monkeypatch.setattr(routes, "_serve_static", wrapped)
yield static_root
# ── Tests ─────────────────────────────────────────────────────────────────
def test_plain_get_returns_raw_bytes_with_etag(isolated_static):
from api import routes
payload = b"console.log('hello');\n" * 200 # > 1 KB so gzip-eligible
_make_static_file(isolated_static, "ui.js", payload)
h = _serve(routes, "/static/ui.js")
assert h.status == 200
assert h.header("Content-Type") == "application/javascript; charset=utf-8"
assert h.header("Content-Encoding") is None # no gzip without Accept-Encoding
assert h.header("ETag") is not None and h.header("ETag").startswith('W/"')
assert h.header("Cache-Control") == "public, max-age=300" # no fingerprint
assert bytes(h.body) == payload
def test_gzip_negotiated_when_client_accepts(isolated_static):
from api import routes
payload = (b"a" * 50_000)
_make_static_file(isolated_static, "ui.js", payload)
h = _serve(routes, "/static/ui.js", request_headers={"Accept-Encoding": "gzip, deflate"})
assert h.status == 200
assert h.header("Content-Encoding") == "gzip"
assert h.header("Vary") == "Accept-Encoding"
assert gzip.decompress(bytes(h.body)) == payload
assert int(h.header("Content-Length")) == len(h.body) < len(payload)
def test_fingerprinted_url_gets_immutable_cache(isolated_static):
from api import routes
_make_static_file(isolated_static, "ui.js", b"x" * 2000)
h = _serve(routes, "/static/ui.js", query="v=abc1234")
assert h.header("Cache-Control") == "public, max-age=31536000, immutable"
def test_empty_fingerprint_value_gets_short_cache(isolated_static):
"""Only a non-empty version token is an immutable-cache fingerprint."""
from api import routes
_make_static_file(isolated_static, "ui.js", b"x" * 2000)
h = _serve(routes, "/static/ui.js", query="v=")
assert h.header("Cache-Control") == "public, max-age=300"
def test_unfingerprinted_url_gets_short_cache(isolated_static):
from api import routes
_make_static_file(isolated_static, "ui.js", b"x" * 2000)
h = _serve(routes, "/static/ui.js")
assert h.header("Cache-Control") == "public, max-age=300"
def test_conditional_get_returns_304(isolated_static):
from api import routes
_make_static_file(isolated_static, "ui.js", b"hello world\n" * 100)
first = _serve(routes, "/static/ui.js", query="v=abc")
etag = first.header("ETag")
assert etag is not None
second = _serve(routes, "/static/ui.js", query="v=abc",
request_headers={"If-None-Match": etag})
assert second.status == 304
assert second.header("ETag") == etag
assert second.header("Cache-Control") == "public, max-age=31536000, immutable"
assert second.header("Vary") == "Accept-Encoding"
assert bytes(second.body) == b""
def test_etag_changes_when_file_changes(isolated_static):
"""Cache must invalidate when (size, mtime) changes — guards redeploy correctness."""
import time
from api import routes
f = _make_static_file(isolated_static, "ui.js", b"v1" * 1000)
first = _serve(routes, "/static/ui.js")
etag_v1 = first.header("ETag")
# Touch with a later mtime (1 s granularity matches the ETag formula).
time.sleep(1.1)
f.write_bytes(b"v2-different-content" * 50)
second = _serve(routes, "/static/ui.js")
etag_v2 = second.header("ETag")
assert etag_v1 != etag_v2
# Old ETag now produces a 200, not a stale 304.
third = _serve(routes, "/static/ui.js", request_headers={"If-None-Match": etag_v1})
assert third.status == 200
def test_etag_changes_for_same_size_edits_within_same_second(isolated_static):
"""The cache signature must keep sub-second mtime precision."""
import os
from api import routes
f = _make_static_file(isolated_static, "ui.js", b"a" * 2048)
second = 1_900_000_000
os.utime(f, ns=(second * 1_000_000_000, second * 1_000_000_000))
first = _serve(routes, "/static/ui.js")
etag_v1 = first.header("ETag")
f.write_bytes(b"b" * 2048)
os.utime(f, ns=(second * 1_000_000_000 + 123_000_000,
second * 1_000_000_000 + 123_000_000))
second_response = _serve(routes, "/static/ui.js")
assert second_response.header("ETag") != etag_v1
assert bytes(second_response.body) == b"b" * 2048
def test_image_is_not_gzipped(isolated_static):
"""Already-compressed binary types must skip gzip to avoid wasted CPU."""
from api import routes
# 4 KB of pseudo-PNG (real header doesn't matter, only the MIME does)
_make_static_file(isolated_static, "favicon.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 4000)
h = _serve(routes, "/static/favicon.png", request_headers={"Accept-Encoding": "gzip"})
assert h.status == 200
assert h.header("Content-Encoding") is None
assert h.header("Content-Type") == "image/png"
def test_tiny_file_is_not_gzipped(isolated_static):
"""Files under 1 KB skip gzip — framing overhead exceeds savings."""
from api import routes
_make_static_file(isolated_static, "tiny.js", b"export {};\n")
h = _serve(routes, "/static/tiny.js", request_headers={"Accept-Encoding": "gzip"})
assert h.status == 200
assert h.header("Content-Encoding") is None
def test_path_traversal_still_rejected(isolated_static):
"""Sandbox check from the original implementation must remain intact."""
from api import routes
_make_static_file(isolated_static, "ui.js", b"ok")
# Try to break out of static/ — must 404, not serve external files.
h = _serve(routes, "/static/../api/routes.py")
assert h.status == 404
+15
View File
@@ -450,6 +450,21 @@ class TestDoneEventSmd:
"the possibly-stale _scrollPinned flag."
)
def test_done_handler_prefers_message_tool_metadata_for_settled_render(self):
"""If final messages already contain tool metadata, renderMessages()
should derive anchored settled cards from those messages.
Falling back to session-level tool_calls unconditionally can hide cards
after pagination/windowing because those anchors may not line up with
the active message array.
"""
fn = self.get_fn()
assert fn, "'done' handler not found"
done_before_render = fn[:fn.index("renderMessages({preserveScroll:true})")]
assert "const hasMessageToolMetadata=S.messages.some" in done_before_render
assert "!hasMessageToolMetadata&&d.session.tool_calls&&d.session.tool_calls.length" in done_before_render
assert "S.toolCalls=hasMessageToolMetadata?[]:S.toolCalls.map" in done_before_render
# ── 7. apperror event: smd parser ends cleanly ───────────────────────────────
+20 -8
View File
@@ -114,19 +114,31 @@ class TestReconnectAccumulatorPreservation:
"""
def test_wire_sse_does_not_reset_accumulators(self):
"""Regression guard: _wireSSE must not contain a literal
accumulator-reset statement. Preserves pre-reconnect content so
the user sees the full response across a drop+reconnect."""
"""Regression guard: the _wireSSE preamble (before any event
listeners are attached) must not contain a literal accumulator-
reset statement. Preserves pre-reconnect content so the user
sees the full response across a drop+reconnect.
Turn-boundary resets inside event listeners (tool,
interim_assistant) are intentional (#2565) and not covered by
this guard they prevent reasoning from accumulating across
multi-turn agent sessions."""
src = read('static/messages.js')
m = re.search(r'function _wireSSE\(source\)\{.*?\n \}', src, re.DOTALL)
assert m, "_wireSSE not found"
fn = m.group(0)
assert "assistantText=''" not in fn and 'assistantText = ""' not in fn, (
"_wireSSE must NOT reset assistantText — the server does not replay "
"events on reconnect, so the reset would wipe valid pre-drop content"
# Check only the preamble before the first addEventListener — this is
# the reconnect path where resets would cause data loss.
first_listener = fn.find("source.addEventListener(")
assert first_listener > 0, "no addEventListener in _wireSSE"
preamble = fn[:first_listener]
assert "assistantText=''" not in preamble and 'assistantText = ""' not in preamble, (
"_wireSSE preamble must NOT reset assistantText — the server does "
"not replay events on reconnect, so the reset would wipe valid "
"pre-drop content"
)
assert "reasoningText=''" not in fn and 'reasoningText = ""' not in fn, (
"_wireSSE must NOT reset reasoningText on reconnect"
assert "reasoningText=''" not in preamble and 'reasoningText = ""' not in preamble, (
"_wireSSE preamble must NOT reset reasoningText on reconnect"
)
def test_closure_initialises_accumulators_empty(self):
+70 -6
View File
@@ -1,4 +1,9 @@
import os
import subprocess
import threading
import time
import pytest
import api.terminal as terminal
@@ -27,7 +32,19 @@ class _FakeProc:
return 0
def test_terminal_shell_uses_parent_death_signal_preexec(monkeypatch, tmp_path):
def test_terminal_shell_does_not_use_pdeathsig_preexec(monkeypatch, tmp_path):
"""Regression for #2853.
The previous implementation passed a ``preexec_fn`` that called
``prctl(PR_SET_PDEATHSIG, SIGTERM)``. Because that signal is *per-thread*
and WebUI's ``ThreadingHTTPServer`` spawns a new thread for every HTTP
request, the PTY shell registered the request-handler thread as its
parent and was killed within ~10 ms of being created on Linux.
The fix is to spawn the shell without ``preexec_fn`` at all. Graceful
shutdown remains covered by ``atexit.register(close_all_terminals)`` and
the explicit ``close_terminal`` paths.
"""
captured = {}
proc = _FakeProc()
@@ -40,15 +57,59 @@ def test_terminal_shell_uses_parent_death_signal_preexec(monkeypatch, tmp_path):
monkeypatch.setattr(terminal.threading, "Thread", _DummyThread)
monkeypatch.setattr(terminal, "_set_size", lambda *args, **kwargs: None)
term = terminal.start_terminal("term-preexec", tmp_path)
term = terminal.start_terminal("term-no-preexec", tmp_path)
try:
assert term.proc is proc
assert captured["kwargs"]["preexec_fn"] is terminal._terminal_shell_preexec_fn
assert "preexec_fn" not in captured["kwargs"], (
"preexec_fn must not be set — the PR_SET_PDEATHSIG implementation "
"killed every Linux user's terminal (#2853). See module-level note."
)
assert captured["kwargs"]["start_new_session"] is True
assert captured["kwargs"]["stdin"] == captured["kwargs"]["stdout"] == captured["kwargs"]["stderr"]
finally:
terminal.close_terminal("term-preexec")
terminal.close_terminal("term-no-preexec")
@pytest.mark.skipif(
not hasattr(os, "openpty") or os.name != "posix",
reason="PTY-spawn test requires a POSIX host",
)
def test_pty_shell_survives_when_spawning_thread_exits(tmp_path):
"""End-to-end regression for #2853.
Spawn a real PTY shell via ``start_terminal`` from inside a worker thread
that then exits. The shell must remain alive after the spawning thread
joins, otherwise we've regressed back to the PR_SET_PDEATHSIG behaviour
that killed every Linux user's embedded terminal.
"""
sid = "term-thread-survival"
holder: dict = {}
def worker():
try:
holder["term"] = terminal.start_terminal(sid, tmp_path)
except Exception as exc: # pragma: no cover - surface in assertion
holder["error"] = exc
t = threading.Thread(target=worker)
t.start()
t.join(timeout=5)
assert not t.is_alive(), "spawn worker thread should have exited"
assert "error" not in holder, holder.get("error")
term = holder["term"]
try:
# Give the kernel a beat — if PR_SET_PDEATHSIG were re-introduced the
# shell would receive SIGTERM right about now.
time.sleep(0.5)
assert term.proc.poll() is None, (
"PTY shell exited after the spawning thread joined — likely a "
"PR_SET_PDEATHSIG regression (#2853). "
f"exit_code={term.proc.poll()!r}"
)
finally:
terminal.close_terminal(sid)
def test_close_terminal_waits_again_after_sigkill(monkeypatch):
@@ -96,8 +157,11 @@ def test_close_all_terminals_closes_snapshot(monkeypatch):
def test_terminal_module_registers_graceful_shutdown_reaper():
"""atexit is still the reap path; pdeathsig must NOT be re-introduced."""
src = terminal.Path(terminal.__file__).read_text()
assert "atexit.register(close_all_terminals)" in src
assert "preexec_fn=_terminal_shell_preexec_fn" in src
assert "libc.prctl(1, signal.SIGTERM)" in src
# The PR_SET_PDEATHSIG implementation broke every Linux user (#2853);
# guard against accidentally bringing it back.
assert "preexec_fn=_terminal_shell_preexec_fn" not in src
assert "libc.prctl(1, signal.SIGTERM)" not in src
+155
View File
@@ -0,0 +1,155 @@
from collections import Counter
from pathlib import Path
import re
REPO = Path(__file__).resolve().parent.parent
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def extract_locale_block(src: str, locale_key: str) -> str:
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
assert start_match, f"{locale_key} locale block not found"
start = start_match.end() - 1
depth = 0
in_single = False
in_double = False
in_backtick = False
escape = False
for i in range(start, len(src)):
ch = src[i]
if escape:
escape = False
continue
if in_single:
if ch == "\\":
escape = True
elif ch == "'":
in_single = False
continue
if in_double:
if ch == "\\":
escape = True
elif ch == '"':
in_double = False
continue
if in_backtick:
if ch == "\\":
escape = True
elif ch == "`":
in_backtick = False
continue
if ch == "'":
in_single = True
continue
if ch == '"':
in_double = True
continue
if ch == "`":
in_backtick = True
continue
if ch == "{":
depth += 1
continue
if ch == "}":
depth -= 1
if depth == 0:
return src[start + 1 : i]
raise AssertionError(f"{locale_key} locale block braces are not balanced")
def locale_keys(src: str, locale_key: str) -> list[str]:
key_pattern = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*:", re.MULTILINE)
return key_pattern.findall(extract_locale_block(src, locale_key))
def test_turkish_locale_block_exists():
src = read(REPO / "static" / "i18n.js")
tr_block = extract_locale_block(src, "tr")
assert tr_block
assert "_lang: 'tr'" in tr_block
assert "_label: 'Türkçe'" in tr_block
assert "_speech: 'tr-TR'" in tr_block
def test_turkish_locale_includes_representative_translations():
src = read(REPO / "static" / "i18n.js")
tr_block = extract_locale_block(src, "tr")
expected = [
"settings_title: 'Ayarlar'",
"settings_label_language: 'Dil'",
"login_title: 'Oturum aç'",
"approval_heading: 'Onay gerekli'",
"tab_chat: 'Sohbet'",
"tab_tasks: 'Görevler'",
"tab_profiles: 'Agent profilleri'",
"empty_title: 'Hangi konuda yardımcı olabilirim?'",
"onboarding_title: 'Hermes Web Kullanıcı Arayüzüne Hoş Geldiniz'",
]
for entry in expected:
assert entry in tr_block
def test_turkish_settings_detail_descriptions_are_translated():
src = read(REPO / "static" / "i18n.js")
tr_block = extract_locale_block(src, "tr")
expected = [
"settings_desc_workspace_panel_open: 'Etkinleştirildiğinde, çalışma alanı / dosya tarayıcı paneli her yeni oturumda otomatik olarak açılır. Yine de istediğiniz zaman manuel olarak kapatabilirsiniz.'",
"settings_desc_notifications: 'Uygulama arka plandayken bir yanıt tamamlandığında bir sistem bildirimi gösterin.'",
"settings_desc_token_usage: 'Her Asistan yanıtının altında giriş/çıkış jeton sayılarını gösterir. /usage ile de değiştirilebilir.'",
"settings_desc_sidebar_density: 'Oturum listesinin sol kenar çubuğunda ne kadar meta veri göstereceğini kontrol eder.'",
"settings_desc_auto_title_refresh: 'Oturum başlıklarını en son konuşmaya göre otomatik olarak yeniden oluşturarak konuşma ilerledikçe başlıkların alakalı kalmasını sağlar. LLM başlık oluşturma modeli yapılandırması gerektirir.'",
"settings_desc_external_sessions: 'Oturum listesinde CLI, Telegram, Discord, Slack ve diğer kanallardan gelen konuşmaları gösterin. İçe aktarmak ve devam etmek için tıklayın.'",
"settings_desc_sync_insights: 'WebUI belirteci kullanımını state.db\\'ye yansıtır, böylece hermes /insights tarayıcı oturum verilerini içerir. Varsayılan olarak kapalıdır.'",
"settings_desc_check_updates: 'WebUI veya Agent\\'ın daha yeni sürümleri mevcut olduğunda bir banner gösterin. Periyodik olarak bir arka plan git getirme işlemi çalıştırır.'",
"settings_desc_bot_name: 'Yalnızca varsayılan profil için kullanılır. Diğer profiller kendi profil adlarını kullanır.'",
"settings_desc_password: 'Ayarlamak veya değiştirmek için yeni bir şifre girin. Geçerli ayarı korumak için boş bırakın.'",
]
for entry in expected:
assert entry in tr_block
def test_turkish_locale_matches_english_key_coverage():
src = read(REPO / "static" / "i18n.js")
en_keys = set(locale_keys(src, "en"))
tr_keys = set(locale_keys(src, "tr"))
assert sorted(en_keys - tr_keys) == []
assert sorted(tr_keys - en_keys) == []
def test_turkish_locale_has_no_duplicate_keys():
src = read(REPO / "static" / "i18n.js")
keys = locale_keys(src, "tr")
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
assert not duplicates, f"Turkish locale has duplicate keys: {duplicates}"
def test_turkish_locale_keys_use_standard_indentation():
src = read(REPO / "static" / "i18n.js")
tr_block = extract_locale_block(src, "tr")
badly_indented = [
line.strip()
for line in tr_block.splitlines()
if re.match(r"^\s{1,3}[a-zA-Z0-9_]+\s*:", line)
]
assert badly_indented == []
def test_turkish_locale_has_no_double_escaped_unicode_sequences():
"""JSON-style double escapes (\\\\u2026) render literal backslash-u in the UI."""
src = read(REPO / "static" / "i18n.js")
tr_block = extract_locale_block(src, "tr")
for bad in ("\\\\u2026", "\\\\u2192", "\\\\u2713"):
assert bad not in tr_block, f"Turkish locale must not contain {bad!r}"
+333
View File
@@ -79,6 +79,54 @@ def test_check_repo_fetch_failure_without_tags_is_not_up_to_date(tmp_path):
assert info['error'] == 'fetch failed: network unavailable'
def test_check_for_updates_can_skip_agent_repo(tmp_path):
"""Ignoring Agent updates should still check WebUI but avoid touching Agent git."""
webui_path = tmp_path / 'webui'
agent_path = tmp_path / 'agent'
webui_path.mkdir()
agent_path.mkdir()
seen = []
def fake_check_repo(path, name):
seen.append(name)
return {'name': name, 'behind': 2 if name == 'webui' else 9}
cache_defaults = {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}
with patch.dict(updates._update_cache, cache_defaults, clear=True), \
patch.object(updates, 'REPO_ROOT', webui_path), \
patch.object(updates, '_AGENT_DIR', agent_path), \
patch.object(updates, '_check_repo', side_effect=fake_check_repo):
result = updates.check_for_updates(force=True, include_agent=False)
assert seen == ['webui']
assert result['webui']['behind'] == 2
assert result['agent'] == {'name': 'agent', 'behind': 0, 'ignored': True}
assert result['include_agent'] is False
def test_update_cache_is_scoped_by_agent_inclusion(tmp_path):
"""Toggling Agent update checks must not reuse a stale opposite-mode cache."""
(tmp_path / '.git').mkdir()
calls = []
def fake_check_repo(path, name):
calls.append(name)
return {'name': name, 'behind': len(calls)}
with patch.dict(updates._update_cache, {'webui': None, 'agent': None, 'checked_at': 0, 'include_agent': True}, clear=True), \
patch.object(updates, 'REPO_ROOT', tmp_path), \
patch.object(updates, '_AGENT_DIR', tmp_path), \
patch.object(updates, '_check_repo', side_effect=fake_check_repo):
ignored = updates.check_for_updates(force=True, include_agent=False)
included = updates.check_for_updates(force=False, include_agent=True)
assert ignored['agent']['ignored'] is True
assert included['agent']['name'] == 'agent'
assert included['agent'].get('ignored') is not True
assert calls == ['webui', 'webui', 'agent']
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:
@@ -260,3 +308,288 @@ def test_check_repo_recovers_from_remote_retag(tmp_path):
assert info.get('stale_check') is not True, (
'fetch with --force should have succeeded, not marked stale'
)
# ---------------------------------------------------------------------------
# #2653 — Update check reports "Up to date" while the repo is hundreds of
# commits past the latest tag (agent cadence bug).
#
# When current_tag == latest_tag (behind==0 from the release check) but HEAD
# has moved past that tag (git describe --tags --always returns a -N-gSHA
# suffix), _check_repo_release must return None so the branch check runs and
# reports the real commit gap.
# ---------------------------------------------------------------------------
def test_check_repo_release_falls_through_when_head_is_past_tag(tmp_path):
"""_check_repo_release returns None when behind==0 but HEAD is past the tag.
Simulates the hermes-agent case: latest tag == current tag (v2026.5.16)
but git describe shows 608 commits past it. The release check must
not report 'Up to date'; it should fall through so the branch check
counts the real gap.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.16', True
# HEAD is 608 commits past the tag — describe includes a suffix.
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16-608-g1d22b9c2d', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
result = updates._check_repo_release(tmp_path, 'test-repo')
assert result is None, (
'_check_repo_release should return None when HEAD is past the latest tag '
'so the branch check can report the real commit gap (#2653)'
)
def test_check_repo_release_not_affected_when_head_exactly_on_tag(tmp_path):
"""_check_repo_release works normally when HEAD is exactly on the latest tag."""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16\nv2026.5.10', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.16', True
# No -N-gSHA suffix: HEAD is exactly on the tag.
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-agent.git', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
result = updates._check_repo_release(tmp_path, 'agent')
assert result is not None
assert result['behind'] == 0
assert result['current_version'] == 'v2026.5.16'
assert result['latest_version'] == 'v2026.5.16'
def test_check_repo_branch_check_runs_for_post_tag_commits(tmp_path):
"""End-to-end: when HEAD is past latest tag, _check_repo uses branch check.
Mirrors the exact scenario in issue #2653 where Agent: v2026.5.16-593-g...
was displayed alongside 'Up to date' in Settings.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['fetch', 'origin', '--tags', '--force']:
return '', True
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.16', True
# HEAD is 608 commits past the tag.
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16-608-g1d22b9c2d', True
# Branch-check path follows: rev-parse upstream, default branch, rev-list.
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return '', False
if args == ['symbolic-ref', 'refs/remotes/origin/HEAD']:
return 'refs/remotes/origin/master', True
if args[:2] == ['rev-list', '--count']:
return '608', True
# merge-base and short SHA lookups for compare URL
if args[0] == 'merge-base':
return 'abc1234' * 5, True
if args[:2] == ['rev-parse', '--short']:
return 'abc1234', True
if args == ['remote', 'get-url', 'origin']:
return 'https://github.com/nesquena/hermes-agent.git', True
return '', True
with patch.object(updates, '_run_git', side_effect=fake_git):
info = updates._check_repo(tmp_path, 'agent')
assert info is not None
assert info['behind'] == 608, (
f"expected behind=608 (branch check result), got {info['behind']!r} (#2653)"
)
assert info.get('release_based') is not True, (
'post-tag HEAD should use branch check, not release-based check'
)
# ---------------------------------------------------------------------------
# Regression tests for #2846: _select_apply_compare_ref must mirror the
# check-side decision about whether to advance to the latest tag or to the
# upstream branch. Pre-fix, the check correctly fell through to the branch
# count when HEAD was past the latest tag, but apply still aimed at the tag —
# so clicking "Update Now" no-op'd, restarted the server, and the banner
# re-appeared with the same N commits.
# ---------------------------------------------------------------------------
def test_select_apply_compare_ref_uses_tag_when_head_is_on_tag(tmp_path):
"""HEAD == latest tag → apply path advances to the tag (unchanged)."""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16\nv2026.5.10', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
ref = updates._select_apply_compare_ref(tmp_path)
assert ref == 'v2026.5.16'
def test_select_apply_compare_ref_falls_through_when_head_is_past_tag(tmp_path):
"""HEAD past latest tag → apply path advances to origin/<branch>, not the tag.
Mirrors the issue #2846 repro: hermes-agent has tag v2026.5.16, master is
608 commits ahead, the banner correctly reports 608 commits available
(post-#2758), but pre-fix apply ran `git pull --ff-only v2026.5.16` — a
no-op and the banner reappeared after restart.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
# HEAD's nearest tag is v2026.5.16; HEAD is 608 commits past it.
return 'v2026.5.16', True
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16-608-g1d22b9c2d', True
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return 'origin/main', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
ref = updates._select_apply_compare_ref(tmp_path)
assert ref == 'origin/main', (
'apply path must advance to the upstream branch when HEAD is past the '
'latest tag, otherwise Update Now no-ops and the banner loops (#2846)'
)
def test_select_apply_compare_ref_no_tags_uses_upstream(tmp_path):
"""No `v*` tags → apply path uses the configured upstream (unchanged)."""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return '', True
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return 'origin/feat/foo', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
ref = updates._select_apply_compare_ref(tmp_path)
assert ref == 'origin/feat/foo'
def test_select_apply_compare_ref_no_tags_no_upstream_uses_default_branch(tmp_path):
"""No tags and no upstream → fall back to origin/<default-branch>."""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return '', True
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return '', False
if args == ['symbolic-ref', 'refs/remotes/origin/HEAD']:
return 'refs/remotes/origin/main', True
raise AssertionError(f'unexpected git args: {args!r}')
with patch.object(updates, '_run_git', side_effect=fake_git):
ref = updates._select_apply_compare_ref(tmp_path)
assert ref == 'origin/main'
def test_check_and_apply_paths_agree_when_head_is_past_tag(tmp_path):
"""Check and apply paths must agree: both fall through to origin/<branch>.
The bug class in #2846 (and #2653 before it) was the two paths drifting
apart check said "you're 608 behind origin/main", apply said "advance
to v2026.5.16". This test pins the symmetry so they can't drift again.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--abbrev=0']:
return 'v2026.5.16', True
if args == ['describe', '--tags', '--always']:
return 'v2026.5.16-608-g1d22b9c2d', True
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return 'origin/main', True
return '', True
with patch.object(updates, '_run_git', side_effect=fake_git):
check_result = updates._check_repo_release(tmp_path, 'agent')
apply_ref = updates._select_apply_compare_ref(tmp_path)
# Check side falls through (release check returns None → branch check runs)
assert check_result is None, (
'_check_repo_release should fall through when HEAD is past the latest '
'tag (#2653)'
)
# Apply side picks the same branch the check would have reported against
assert apply_ref == 'origin/main', (
'_select_apply_compare_ref must mirror the check-side fall-through '
'when HEAD is past the latest tag (#2846)'
)
def test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_exists(tmp_path):
"""Case D — HEAD on older tag + commits + newer tag exists → advance to newer tag.
Pre-Opus-#2855-fix: the check side correctly reported "behind by N" and
suggested `latest_tag`, but the apply side's predicate consulted
`_head_is_past_latest_tag(path, latest_tag)` which returned True (because
`git describe --tags --always` returns `v.older-N-g...` `latest_tag`).
So the apply side fell through to `origin/<branch>` and the pull landed
PAST the advertised tag silent drift between check ("advance to
v2026.5.16") and apply ("pulled to whatever origin/main is now").
Fix: the apply-side predicate now uses `current_tag` (HEAD's nearest tag)
AND requires `behind == 0`, exactly mirroring the check-side rule.
"""
(tmp_path / '.git').mkdir()
def fake_git(args, cwd, timeout=10):
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
return 'v2026.5.16\nv2026.5.10', True
if args == ['describe', '--tags', '--abbrev=0']:
# HEAD's nearest reachable tag (older one)
return 'v2026.5.10', True
if args == ['describe', '--tags', '--always']:
# HEAD has 3 commits past v2026.5.10
return 'v2026.5.10-3-gabcdef12', True
if args == ['rev-parse', '--abbrev-ref', '@{upstream}']:
return 'origin/main', True
return '', True
with patch.object(updates, '_run_git', side_effect=fake_git):
apply_ref = updates._select_apply_compare_ref(tmp_path)
# User is genuinely behind v2026.5.16 (the newer published tag) — apply
# MUST advance to the tag, NOT fall through to origin/<branch>.
assert apply_ref == 'v2026.5.16', (
'case D: HEAD on older tag with commits + newer tag exists. Apply '
'should advance to the newer tag, not silently fall through to '
'origin/<branch>. Regression for Opus-flagged drift in #2855.'
)
@@ -467,6 +467,85 @@ def test_metadata_fast_path_excludes_state_db_rows_filtered_by_reconciliation(mo
assert session["last_message_at"] == 1001.0
def test_api_session_reload_drops_stale_cached_user_tail_after_saved_assistant(monkeypatch, tmp_path):
import api.models as models
import api.routes as routes
sid = "webui_reconcile_cached_user_tail"
_install_test_session(
monkeypatch,
tmp_path,
sid,
[
{"role": "user", "content": "please audit phase c", "timestamp": 1000.0},
{"role": "assistant", "content": "final audit complete", "timestamp": 1001.0},
],
)
_make_state_db(
tmp_path / "state.db",
sid,
[
{"role": "user", "content": "please audit phase c", "timestamp": 1000.0},
{"role": "assistant", "content": "final audit complete", "timestamp": 1001.0},
],
)
cached = models.Session.load(sid)
cached.messages.append(
{
"role": "user",
"content": "please audit phase c",
"timestamp": 1002.0,
}
)
cached.pending_user_message = None
cached.active_stream_id = None
models.SESSIONS[sid] = cached
handler = _GetHandler(f"/api/session?session_id={sid}&messages=1&resolve_model=0")
routes.handle_get(handler, urlparse(handler.path))
assert handler.status == 200
messages = handler.response_json["session"]["messages"]
assert messages[-1]["role"] == "assistant"
assert messages[-1]["content"] == "final audit complete"
assert handler.response_json["session"]["message_count"] == 2
def test_metadata_fast_path_matches_reconciliation_for_restamped_replays(monkeypatch, tmp_path):
"""#2716 invariant: metadata-only /api/session uses merge_session_messages_append_only
(not a raw state.db COUNT) so restamped replay rows don't make sidebar polling think
the transcript is always newer than the loaded conversation."""
import api.routes as routes
sid = "webui_reconcile_metadata_replay"
_install_test_session(
monkeypatch,
tmp_path,
sid,
[
{"role": "user", "content": "old user", "timestamp": 1000.0},
{"role": "assistant", "content": "old assistant", "timestamp": 1001.0},
],
)
_make_state_db(
tmp_path / "state.db",
sid,
[
{"role": "user", "content": "old user", "timestamp": 1002.0},
],
)
handler = _GetHandler(f"/api/session?session_id={sid}&messages=0&resolve_model=0")
routes.handle_get(handler, urlparse(handler.path))
assert handler.status == 200
session = handler.response_json["session"]
assert session["messages"] == []
assert session["message_count"] == 2
assert session["last_message_at"] == 1001.0
def test_state_db_reconciliation_preserves_tool_metadata(monkeypatch, tmp_path):
import api.routes as routes