Merge pull request #2608 from nesquena/release/stage-388

release: v0.51.95 (Release BS / stage-388 / 5-PR batch — live tool dedup + browser dashboard links + messaging dedup + Geist Contrast skin + SSE diagnostics)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-05-19 17:40:44 -07:00
commit 9c983e693a
24 files changed
+647 -120

No files matched your search

+17 -44
View File
@@ -3,6 +3,19 @@
## [Unreleased]
## [v0.51.95] — 2026-05-20 — Release BS (stage-388 — 5-PR batch — live tool callback event dedup + browser-only dashboard links + messaging transcript merge alignment + Geist Contrast skin + SSE runtime diagnostics)
### Fixed
- **PR #2598** by @AJV20 — Surface live tool activity when Hermes Agent reports tools through its dedicated `tool_start_callback` / `tool_complete_callback` path, so browser chat shows the existing running tool cards instead of appearing idle until the final answer. The legacy `on_tool` callback path now early-returns for `tool.started` and `tool.completed` events when the structured callback path is already wired, preventing the same tool event from being emitted twice to the SSE stream.
- **PR #2533** by @AJV20 — Allow Settings → System to save public browser-only Official Hermes Dashboard links (for reverse-proxy URLs) without treating them as server-side probe targets. URL sanitization runs against the configured link before save; the dashboard probe is skipped for browser-only links.
- **PR #2607** by @AJV20 — Deduplicate messaging/CLI session transcript rows when the sidecar and state store encode the same no-id message with equivalent timestamps in different formats (e.g. `"10.0"` vs `10`), preventing repeated visible chat rows after session reconstruction. The messaging-display merge now reuses `api.models._session_message_merge_key(...)` instead of an ad-hoc dedup key, aligning with the existing append-only merge path.
### Added
- **PR #2521** by @intellectronica — Add the Geist Contrast skin to the appearance picker. New light + dark variant pair with a high-contrast yellow-on-black accent and Geist editorial typography. Default unchanged — opt-in via Settings → Appearance → Skin → Geist Contrast. Slash command `/theme geist-contrast` now resolves correctly because the lookup matches against `skin.value` rather than `skin.name`. Documented in `THEMES.md` with a forward-compatible skin count (no hard-coded value).
- **PR #2524** by @AJV20 — Add non-sensitive SSE stream runtime diagnostics to deep health checks (`/health?deep=1`), including active stream count, subscriber totals, and offline buffered-event counts for stuck or slow WebUI chat investigations. Read-only telemetry; existing surfaces unchanged.
## [v0.51.94] — 2026-05-19 — Release BR (stage-387 — 10-PR full sweep batch — Slice 4b runner adapter facade + folder zip download + partial recovery marker dedupe + browser api() client-side timeout + auto-compression card rotation finish + composer draft rollback fix + metadata count reconciliation + active-session refresh on external sidecar updates + indexed context metadata + gateway-queues approval peek)
### Fixed
@@ -34,7 +47,6 @@
- **PR #2587** by @AJV20 — Allow a still-running stream that was mistakenly marked interrupted by stale-pending recovery to replace its own recovery marker when it later finishes, while continuing to block stale writeback after any newer turn appends transcript content. Three new tests in `tests/test_session_sidecar_repair.py` cover the supersede-allowed and the two refuse cases.
- **PR #2588** by @Michaelyklam (refs #2569) — Preserve the configured provider when choosing a configured model from the composer picker. `_getOptionProviderId()` now reads `data-provider` from temporary `<option data-custom="1">` rows (created by `selectModelFromDropdown` for configured models outside the native catalog), so the next send routes through the correct provider instead of falling back to whatever provider was already active.
### Changed
- **PR #2581** by @LumenYoung (refs #2194) — First recovery slice from the closed reconciliation PR #2194. Routes streaming session reconstruction and sidebar metadata through the reconciled state.db/session-summary path with a metadata-only fast path for sidebar polls and a single-snapshot reuse on the streaming hot path. Includes the reviewer-requested `_new_turn_context_from_messages` extraction so both legacy and streaming paths share the `_drop_checkpointed_current_user_from_context` + casual-fresh-chat suppression behavior (refs #1217 / #2308). 923 LOC across `api/models.py`, `api/routes.py`, `api/streaming.py`, `static/sessions.js` + four new test files; second-pass agent diff review LGTM after the streaming-path regression was caught and fixed.
@@ -94,6 +106,10 @@
- **PR #2511** by @franksong2702 (refs #2502 / #2503) — Update the `docs/ui-ux/` demo appearance controls to initialize as `class="dark" data-skin="slate"` instead of the deprecated `data-theme`-only buttons and legacy theme names. Brings the demo pages in line with the live Theme + Skin contract referenced from the new `docs/CONTRACTS.md` so contributors following the contract-index path don't land on stale demos.
- **PR #2509** by @Michaelyklam (refs #1925) — Advance the runtime-adapter RFC after the Slice 3b approval/clarify seam shipped in v0.51.89. The RFC now marks Slice 3b as shipped and defines the next Slice 3c queue/continue + goal control gate: route those controls through `RuntimeAdapter.queue_message(...)` / `update_goal(...)` only after pinning stable response contracts, bounded unavailable-control behavior, replayable lifecycle/status evidence, ordering/idempotency expectations, and explicit non-goals for runner/sidecar ownership or a WebUI-owned queue/goal scheduler. Docs + adapter-seam regression test only — no runtime/control routing changes in this PR.
### Added
- **Geist Contrast skin** — Add a new Geist-inspired `geist-contrast` skin with neutral monochrome surfaces, restrained selected/sidebar states, and dark-mode `#FFF175` primary accents with black foreground on solid accent controls.
## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index)
### Changed
@@ -150,7 +166,6 @@
- **PR #2490** by @nesquena-hermes — Add a Docker runtime smoke gate (`.github/workflows/docker-smoke.yml`) triggered on PRs and pushes to `master` that modify `Dockerfile`, `docker_init.bash`, `docker-compose*.yml`, `.dockerignore`, or `.env.docker.example`. Validates every compose file parses (`docker compose config`), then matrix-runs the single, two-container, and three-container variants end-to-end: rebuilds the local `Dockerfile` and re-tags it as `ghcr.io/nesquena/hermes-webui:latest` so the multi-container variants exercise PR-level changes rather than the previously-released registry image, `docker compose up -d --wait`s with a 120s health window, probes `/health`, and greps startup logs for known-bad signatures (`EROFS`, `Traceback`, `PermissionError`, `error_exit`, `!! ERROR`, `!! Exiting script`, `groupmod: cannot`, `usermod: cannot`, `Failed to set`). Closes the source-only-test gap that let v0.51.84's `:ro`-mount × `chown -h ... {} +` startup regression reach review with 5800+ green pytests. Workflow runs with `permissions: contents: read`, uses per-run project names and a pre-flight orphan reaper for safe concurrency, and unconditionally tears down all volumes/networks in an `EXIT` trap. Two new source-level invariants in `tests/test_docker_docs_and_readonly.py` pin the staging path so the underlying `:ro`-incompatible call doesn't regress.
## [v0.51.86] — 2026-05-17 — Release BJ (stage-379 — 4-PR review-bypass batch — memory-provider session lifecycle + cross-provider /model alias + RuntimeAdapter cancel seam + Fork-from-here messaging coord)
### Fixed
@@ -163,7 +178,6 @@
- **PR #2479** by @Michaelyklam (refs #1925) — Route Stop Generation through the default-off `RuntimeAdapter.cancel_run(...)` seam when `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` is enabled. Implements the first code slice of the Slice 3a cancel-control gate accepted in #2469 / v0.51.85. The default `legacy-direct` path still calls `cancel_stream(...)` directly; the adapter branch preserves the existing `{ok, cancelled, stream_id}` JSON response contract. No new cancellation registry, runner, sidecar, approval/clarify, queue/goal, or cached-agent state is introduced — adapter remains a pure protocol translator.
## [v0.51.85] — 2026-05-17 — Release BI (stage-378 — 3-PR batch — workspace-prefix display leakage fix + release-tag update banner + Slice 3a cancel-control gate RFC)
### Fixed
@@ -420,7 +434,6 @@
### Added
- **PR #2319** by @Michaelyklam — Chat file uploads now land in a session-scoped attachment inbox instead of cluttering the active workspace root. By default uploads are stored under `~/.hermes/webui/attachments/<session_id>/`; operators can override the root with `HERMES_WEBUI_ATTACHMENT_DIR`, and the agent still receives the absolute uploaded file path for context. Archive extraction stays workspace-scoped (it's an explicit workspace operation). README updated to document the new default location. **Stage-361 maintainer fix applied inline**: Opus advisor caught that `_build_native_multimodal_message` at `api/streaming.py:787` required uploads to be under `workspace_root`, which would have silently dropped every image upload for vision-capable models once the inbox moved outside the workspace. The fix adds `_attachment_root()` (from `api/upload.py`) as a second allowed location, with 3 regression tests covering the new code path AND verifying the original workspace + cross-root rejection paths still work.
### Fixed
@@ -447,7 +460,6 @@
### Added
- **PR #2287** by @mslovy (refs #2284) — Upload size limit is now runtime-configurable via the `HERMES_WEBUI_MAX_UPLOAD_MB` environment variable. Previously the effective 20 MB cap was hard-coded across multiple layers. Server-side upload limit moves to runtime config; browser-side preflight check stays aligned with the effective backend limit; archive extraction guard continues to scale with the same configured cap. New `_env_mb_bytes()` helper in `api/config.py` parses `HERMES_WEBUI_MAX_UPLOAD_MB`.
- **PR #2291** by @linuxid10t — New "Nous Research" skin option in the Settings → Appearance picker, inspired by [nousresearch.com](https://nousresearch.com). Monospace typography, steel blue (#4682B4) accent, cool gray text, sharp 1-2px corners, thin dashed borders, technical aesthetic.
@@ -458,7 +470,6 @@
### Fixed
- **PR #2275** by @ai-ag2026 — CLI/messaging continuation sessions (sessions stitched from a `parent_session_id` chain) now return their full transcript instead of an empty list. Pre-fix, `get_cli_session_messages()` called `_is_continuation_session()` while walking the parent chain, but `api/models.py` didn't import that helper. The exception was swallowed by `except Exception: return []`, so valid external sessions could fall through silently. Adds regression coverage that a stitched continuation chain returns a non-empty transcript.
- **PR #2277** by @eleboucher — Rootless container runtimes (k8s `runAsNonRoot: true`, OpenShift restricted SCC, `docker --user`, rootless Podman) no longer hit a cascade of permission errors at startup. Pre-fix, the rootless branch skipped the root init phase entirely, but root init also did rsync, `/uv_cache` permissions, `~/hermeswebui` home directory creation, and `/workspace` writability. `docker_init.bash` now distinguishes "no root init available" from "root init available but skipped", running the work that doesn't need root in the rootless branch too.
@@ -789,7 +800,6 @@
- **PR #2116** by @starship-s — OpenAI Codex provider quota card no longer reports "unavailable" when Codex chat requests actually work. Runtime requests authenticate via the modern `agent.credential_pool`, but the account-usage probe only tried the legacy singleton Codex token path. Adds a Codex-only credential-pool fallback inside the existing isolated `_account_usage_subprocess`: when `agent.account_usage.fetch_account_usage()` returns no available snapshot, the fallback selects the active `openai-codex` credential-pool entry, derives the Codex usage endpoint from the runtime base URL (handles `/backend-api/codex` → `/wham/usage` and custom bases → `/api/codex/usage`), and serializes the existing snapshot shape expected by the WebUI. Stays inside the child process so active Hermes profile context remains isolated; legacy unavailable diagnostics preserved when the pool fallback can't produce a usable result; non-Codex providers unchanged. Returns only quota display data — never credential labels, access tokens, or raw exception strings. 151-line regression suite covers the success path, both URL-resolution branches, and the unavailable-fallthrough case.
### Stage-342 maintainer fixes
- **`api/worktrees.py:_run_git` default timeout 5s → 2s** — Opus SHOULD-FIX from stage-342 review: PR #2109's new `/api/session/worktree/status` endpoint runs up to four `git` subprocess calls per request, each defaulting to a 5-second timeout. Worst case 20 seconds per polling request piling up on the `ThreadingHTTPServer` thread pool is risky given today's `_cron_env_lock` near-miss on production 8787. Status probes should fail fast — a worktree that takes longer than 2 seconds to enumerate is already in trouble, and the client can retry. Mechanical 1-LOC default-arg change; all four call sites already pass `cwd` positionally and rely on the default. ~1 LOC.
@@ -814,7 +824,6 @@
- **`api/streaming.py` short-circuit observability** — Opus SHOULD-FIX from stage-341 review: PR #2107's new `_title_should_skip_remaining_attempts` short-circuit `break` was silent in both the aux and agent title-generation paths. Added a `logger.debug` call before each `break` so production logs surface why the prompt-iteration loop exited early (nesquena flagged this as non-blocking; landed as polish in the same release). Also expanded the function's docstring to document the membership criterion explicitly so future additions (`llm_safety_blocked`, `llm_oauth_quota`, etc.) have a clear inclusion test. ~16 LOC.
## [v0.51.47] — 2026-05-11 — Release W (4-PR contributor batch — per-cron toast toggle + Italian locale + stale-gateway agent-health fix + CI/console hygiene)
### Added
@@ -895,7 +904,6 @@
- **`api/providers.py:1049`** — Custom provider entries that slugify to an empty string were silently dropped, which made misconfigurations hard to diagnose. `logger.warning()` now surfaces the bad config entry. ~4 LOC; pure observability change.
## [v0.51.44] — 2026-05-11 — Release T (5-PR contributor batch — security + worktree sessions + LM Studio + onboarding docs + transcript dedup, plus comprehensive test-suite network isolation)
### Added
@@ -1022,7 +1030,6 @@
- Test isolation: at least one test in `test_update_banner_fixes.py` or `test_updates.py` triggers a real `os.execv` that re-executes the entire pytest suite. Suite still passes (~5108 each loop) but full run takes 4× the time. Worth a targeted fix in the next maintenance batch.
## [v0.51.40] — 2026-05-11 — Release P (4-PR contributor batch — quota subprocess hardening + env-lock prewarm + cron one-shot warning + Xiaomi env key)
### Fixed
@@ -1329,7 +1336,6 @@ Three duplicate-PR clusters consolidated to one canonical PR each, with `Co-auth
- **Race between endless-scroll prefetch and Start-jump's `_ensureAllMessagesLoaded`** — with both opt-ins ON, an in-flight prefetch (started by 1.5x-viewport trigger) racing with `jumpToSessionStart` → `_ensureAllMessagesLoaded` could produce duplicate messages if the prefetch resolves last. Narrow window, but the fix is to gate `_ensureAllMessagesLoaded` on the existing `_loadingOlder` flag. **Resolved in Unreleased — see #1937 entry above; final fix uses generation-token + mutex rather than the originally-suggested flag gate, which would not have closed the race.**
- **#1928 locale parity** — `session_jump_*` and `settings_*_session_jump_buttons` keys are English literals in ja/ru/es/de/zh/zh-Hant/pt/ko. Default-OFF + English fallback works, but breaks the locale-parity standard set by #1929 and #1891 in the same release.
### Added (1 PR)
- **PR #1919** by @franksong2702 — Persist login rate limit attempts (closes #1910). Stores failed-login buckets in `STATE_DIR/.login_attempts.json` instead of in-process memory, so password-auth deployments keep the same failed-attempt window across restarts. Atomic temp+rename writes, `0600` permissions, prunes expired entries on load. If the file is missing, malformed, or unwritable, the auth path falls back to current in-memory behavior with debug-level logging — no infinite-loop risk.
@@ -1360,7 +1366,6 @@ Three duplicate-PR clusters consolidated to one canonical PR each, with `Co-auth
- v0.51.28 carry-overs verified preserved (no in-batch changes to `api/routes.py:_strip_workspace_prefix`, `api/streaming.py:evaluate_goal_after_turn`, `api/profiles.py:_profiles_match`, `tests/test_mcp_server.py` module-restoration logic).
- Pre-stamp re-fetch of all 6 PR heads: no contributor force-push during Opus window.
### Added (2 PRs)
- **PR #1895** by @samuelgudi — MCP server Option A rewrite (#1616). Replaces the fragile MCP integration with a clean `mcp_server.py` (567 LOC) implementing project CRUD, session listing, and session mutations (rename/move) over Hermes's HTTP API. Imports `api.models` / `api.profiles` canonically rather than carrying duplicate slug-matching helpers. Relocates `_profiles_match` from `api/routes.py` into `api/profiles.py` as the single source of truth (mcp_server.py and api/routes.py both now import the canonical helper — re-introducing a local copy in either module trips a parity test immediately). Adds env-aware WEBUI_URL (`HERMES_WEBUI_HOST` / `HERMES_WEBUI_PORT`). New behaviour: `delete_project` REFUSES to touch session JSONs when `HERMES_WEBUI_PASSWORD` is unset, returning `{ok:true, unassigned_sessions:0, warning:"…"}` instead — preventing data-loss when an MCP client tries to delete a project on an unauthenticated server. 53-test coverage in `tests/test_mcp_server.py` (914 LOC) including HTTP wire-format integration tests, profile-scoped isolation, legacy untagged row visibility, and `--profile foo` CLI ordering regression. Closes #1616.
@@ -1392,7 +1397,6 @@ Three duplicate-PR clusters consolidated to one canonical PR each, with `Co-auth
- **Goal hook fires on unrelated turns** — while a goal is `active`, every completed assistant turn runs `evaluate_goal_after_turn` and ticks `state.turns_used += 1`, even on user messages unrelated to the goal. UX surprise but not bug-broken; consider gating on `user_initiated` or a goal-context flag.
- **English-only runtime strings in goal UI** — `messages.js:889` ("Evaluating goal progress…"), `commands.js:651` ("Working toward goal…"), `messages.js:914` ("Continuing toward goal…" toast); also backend strings in `goals.py` (`status_line`, "⊙ Goal set …", "⏸ Goal paused …", "↻ Continuing …"). The `cmd_goal` autocomplete description IS localized across all 9 locales — only the runtime status strings are missed.
### Fixed (4 PRs)
- **PR #1916** by @Michaelyklam — Make Kanban detail view scrollable. The app shell sets `body { overflow: hidden }`, so the Kanban main view must own vertical scrolling. Pre-fix, a selected task with a long body could push the board below the viewport with no way to reach it. Fix: add `overflow-y: auto` to `main.main.showing-kanban > #mainKanban` (one CSS property + regression test). Closes #1915.
@@ -2113,7 +2117,6 @@ Closes #1362, #1731.
Closes #1714, #1715, #1716.
## [v0.51.5] — 2026-05-05 — 4-PR full-sweep batch
### Added
@@ -2154,7 +2157,6 @@ Closes #1714, #1715, #1716.
Closes #693, #1690, #1710.
## [v0.51.5] — 2026-05-05 — single-PR hotfix (#1707)
### Fixed
@@ -2195,7 +2197,6 @@ Closes #693, #1690, #1710.
@Michaelyklam filed PR #1706 with a functional Node-driven regression test (extracts the real paste handler and asserts two pasted image items become two pending attachments) replacing my own #1705 which used static-source-string assertions. Same code fix, better test approach. Closed #1705 and absorbed #1706 into stage-301.
## [v0.51.3] — 2026-05-04 — 3-PR follow-up batch (#1671, #1673, #1676) + test-fragility fix
### Added
@@ -2241,7 +2242,6 @@ PRs #1671, #1673, #1676 had been UX-gated in the v0.51.1 sweep, then on second-l
UX label cleared, Aron stand-down comments deleted on all 3, all 3 swept into this batch.
## [v0.51.2] — 2026-05-04 — 3-PR follow-up batch (deferred from v0.51.1) + sidebar scroll hotfix
### Fixed
@@ -2286,7 +2286,6 @@ All 3 PRs branched off pre-Kanban-v1 master, producing multi-region conflicts in
Both #1664 and #1662 rebased branches were force-pushed back to @Michaelyklam's fork via maintainer write access (preserving `Co-authored-by:` attribution). #1587 stayed local since the maintainer token doesn't have write access to franksong2702's fork.
## [v0.51.1] — 2026-05-04 — 11-PR contributor batch from @Michaelyklam
### Added — 11 PRs from a single overnight burst, all per-PR Phase-0 fit-screened
@@ -2348,7 +2347,6 @@ Both #1664 and #1662 rebased branches were force-pushed back to @Michaelyklam's
This release ships a contributor-burst pattern (17 PRs from @Michaelyklam in 51 minutes overnight). Despite the volume, per-PR claim-vs-diff verification showed no AI-tells, all PR descriptions matched their diffs, all `closes #N` references pointed at real open issues, and security-relevant code paths (file-system reads, outbound HTTP, PID handling, schema redaction) check out under independent review. Eleven PRs landed cleanly in this batch; the remaining six were either deferred for conflict resolution or already in held-state with maintainer-review labels.
## [v0.51.0] — 2026-05-04 — Kanban v1
### Added — Kanban v1: complete first-party Kanban for Hermes (closes #1645, #1646, #1647, #1649, #1654, #1655, #1660, #1675)
@@ -2676,7 +2674,6 @@ Closes #1360, #1451, #1463, #1618, #1619 (5 issues).
Closes #1578, #1583, #1584, #1595, #1613, #1620.
## [v0.50.291] — 2026-05-04
### Fixed (1 PR — "What's new?" link 404 — closes #1579)
@@ -2783,7 +2780,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
- **PR rebase verified clean** (REBASE-DEFAULT rule applied). All 3 PR branches were on or near current master; rebase was no-op.
- **#1571 post-review fix combination**: contributor's `df03055` (post-review tightening) was on `pull/1571/head` while reviewer's `d83e1d8` (test-skip-on-missing-agent) was on `origin/fix/scheduled-jobs-profile-isolation`. Cherry-picked the test-skip commit onto the contributor branch to combine both fixes before merging into stage.
## [v0.50.287] — 2026-05-03
### Fixed (1 PR — closes another vector for the pending-message-loss class)
@@ -2801,7 +2797,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
- Browser sanity (HTTP API checks against port 8789): 11/11 endpoints verified.
- Pre-release Opus advisor: SHIP AS-IS — all 5 verification questions resolved (race-window bounded, lock ordering safe, no deadlock, frontend integration clean, test isolation robust against assertion failures).
## [v0.50.286] — 2026-05-03
### Fixed (1 PR — closes #1560)
@@ -2820,7 +2815,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
- All modified JS files (`static/i18n.js`, `static/panels.js`) pass `node -c` syntax check.
- PR rebase verified clean: `git diff origin/master --stat` shows ONLY the 6 files PR #1561 touches (no spurious deletions of v0.50.284/v0.50.285 test files that the older PR base would have dropped).
## [v0.50.285] — 2026-05-03
### Fixed (1 PR — same-day hotfix-of-hotfix)
@@ -2841,7 +2835,6 @@ Two stale source-string assertions were broken by #1591's compact() and messages
v0.50.284 promised that "the first server start after deploying v0.50.284 will auto-restore any session that was wiped between deploys." That promise was broken in production by the `_index.json` shape mismatch — the recovery silently never fired. Affected users (the original reporter on v0.50.282 with the 1000+ message session that disappeared) had `<sid>.json.bak` files on disk but those files would never be processed. Same-day hotfix restores the promise.
## [v0.50.284] — 2026-05-03
### Fixed (2 PRs — P0 streaming hotfix batch — closes #1533, #1558)
@@ -2871,7 +2864,6 @@ v0.50.284 promised that "the first server start after deploying v0.50.284 will a
- Browser sanity (HTTP API checks against port 8789): 11 endpoints verified.
- Pre-release Opus advisor: SHIP AS-IS — all 5 verification questions cleared (conflict-resolution order, deadlock risk none, Layer 3 backup interaction, startup self-heal vs concurrent saves, test-isolation fix correctness). Two SHOULD-FIX items absorbed in-release.
## [v0.50.283] — 2026-05-03
### Fixed (8 PRs — full sweep batch — closes #1426, #1481, #1512, #1468, #1424, #1457, #1401)
@@ -2908,7 +2900,6 @@ v0.50.284 promised that "the first server start after deploying v0.50.284 will a
- `static/i18n.js`: pt locale `reveal_in_finder` / `reveal_failed` translations added (Opus-flagged, 2 LOC).
- `tests/test_minimax_provider.py::test_minimax_fallback_provider_label` — scoped to direct-MiniMax routes (filter by `minimax/` prefix, exclude `:free`) since #1548's `minimax/minimax-m2.5:free` correctly carries `provider='OpenRouter'` (it routes via OpenRouter, not direct MiniMax).
## [v0.50.282] — 2026-05-03
### Fixed (1 PR — closes #1538, #1539)
@@ -3149,7 +3140,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **MIME map expansion** — adds `ttf` (`font/ttf`), `otf` (`font/otf`), and `wasm` (`application/wasm`) to the served-MIME table. `.wasm` specifically would fail to instantiate in Chrome served as `text/plain`; the others are ergonomic for older font formats. (`api/extensions.py`)
- **5 regression tests** in `tests/test_pr1445_opus_followups.py` pin the new invariants: quadruple-encoded `..` collapses correctly, the same URL is now rejected by the validator, URL list caps at the configured max with a warning log, rejected URLs log exactly once per process, and the expanded MIME map serves `.ttf`/`.otf`/`.wasm` with the correct Content-Type without charset suffixes for binary types. (`tests/test_pr1445_opus_followups.py`)
## [v0.50.264] — 2026-05-02
### Added
@@ -3222,7 +3212,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
### Changed (Opus pre-release advisor)
- **`HERMES_HOME_MODE` semantic asymmetry warning** — Opus advisor caught a footgun in my initial draft: `HERMES_HOME_MODE` means **different things** in the WebUI vs. the agent image. WebUI's `HERMES_HOME_MODE` is a credential-FILE mode threshold (e.g. `0640` allows group bits on `.env`), but the agent's `HERMES_HOME_MODE` is the HERMES_HOME *directory* mode (default `0700`). `0640` on a directory has no owner-execute bit, so the agent can't traverse its own home directory and bricks. My initial draft recommended `HERMES_HOME_MODE=0640` as the example value in agent service blocks — corrected to `0750` (group-traversable) for multi-container setups. All three surfaces now match: compose files (per-service comments), `.env.docker.example` (multi-container warning section), `docs/docker.md` (failure mode #2 callout). 3 new regression tests pin the asymmetry: `test_agent_service_does_not_recommend_invalid_home_mode`, `test_compose_files_warn_about_home_mode_asymmetry`, `test_env_docker_example_warns_about_home_mode_asymmetry`. (`docker-compose.two-container.yml`, `docker-compose.three-container.yml`, `.env.docker.example`, `docs/docker.md`, `tests/test_v050260_docker_invariants.py`)
## [v0.50.259] — 2026-05-01
### Fixed
@@ -3231,7 +3220,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
### Changed (Opus pre-release advisor)
- **Same FD-leak fix applied to LRU eviction path** — `SESSION_AGENT_CACHE.popitem(last=False)` was dropping the evicted agent on the floor with `evicted_sid, _ = ...`. The agent's `_session_db` would only release its FDs when GC eventually finalized the agent — which on a long-running server may be never. Now captures the evicted entry, calls `_evicted_agent._session_db.close()` explicitly. Same shape as #1421's fix on the cached-agent reuse path. 5 regression tests in `test_v050259_sessiondb_fd_leak.py` cover both paths plus `SessionDB.close()` idempotency. (`api/streaming.py`, `tests/test_v050259_sessiondb_fd_leak.py`)
## [v0.50.258] — 2026-05-01
### Fixed
@@ -3240,7 +3228,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
### Changed (Opus pre-release advisor)
- **Login redirect URL encoding fix — multi-param queries no longer truncated** — the original PR #1419 implementation built the outer `?next=` parameter via `quote(path, safe='/:@!$&\'()*+,;=')` which kept `?` and `&` literal. Two problems: (a) paths with multi-param queries (e.g. `/api/sessions?limit=50&offset=0`) round-tripped as `/api/sessions?limit=50` because the inner `&` terminated the outer `next` value, (b) attacker-controlled paths with embedded `&next=...` injected a second top-level `next` parameter (browsers parse first-match, Python parse_qs parses last-match — parser-divergence footgun even though `_safeNextPath()` rejects the actual exploit). Fix encodes the entire `path?query` blob with `safe='/'` so `?`, `&`, `=` all percent-encode. The outer `next` then holds exactly one path-with-query string. 6 regression tests in `test_v050258_opus_followups.py` pin the round-trip behavior across simple paths, single-query paths, multi-param queries, and attacker-injection neutralization. (`api/auth.py`, `tests/test_v050258_opus_followups.py`)
## [v0.50.257] — 2026-05-01
### Added
@@ -3262,7 +3249,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **CRITICAL: per-session toolset override (#493) was non-functional** — `_run_agent_streaming` called `_session_meta.get('enabled_toolsets')` on the result of `Session.load_metadata_only()`, which returns a Session **instance** (not a dict). The `AttributeError` was swallowed by the surrounding `except Exception:` block, so the user's toolset chip silently no-op'd every time and the agent always ran with the global toolsets. Caught by Opus pre-release advisor on the empirical streaming path (CI green, contributor tests green — would have shipped non-functional). Fix uses `getattr(_session_meta, 'enabled_toolsets', None)`. Source-level negative-pattern test prevents the dict-access shape from returning. (`api/streaming.py`, `tests/test_v050257_opus_followups.py`)
## [v0.50.256] — 2026-05-01
### Fixed
@@ -3316,7 +3302,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **`popstate` handler refuses to switch sessions mid-stream** — Opus pre-release follow-up. Mirrors the same `S.busy` guard the cross-tab storage handler had. A user mid-stream who absent-mindedly hits browser Back used to lose their active turn (PR #1392 introduced the popstate listener without the guard). Now shows a toast and stays on the current session. 1 regression test in `test_v050254_opus_followups.py`. (`static/sessions.js`)
### Added
- **Messaging sessions get a WebUI handoff path without exposing every raw channel segment** — Weixin and Telegram sessions imported from Hermes Agent are now treated as messaging-source conversations: sidebar results keep only the latest visible session per channel, preserve source metadata through compact/import paths, and avoid destructive/duplicating menu actions that would imply WebUI owns the external channel history. Messaging sessions with enough external conversation rounds show a composer-docked handoff prompt; clicking it generates a transcript card summary for the user without inserting a fake command bubble. This is PR2 for the #1013 channel-handoff direction and intentionally does not cover the separate CLI Session follow-up. (`api/models.py`, `api/routes.py`, `static/index.html`, `static/messages.js`, `static/sessions.js`, `static/style.css`, `static/ui.js`, `tests/test_gateway_sync.py`, `tests/test_issue1013_handoff_dock.py`) @franksong2702 — refs #1013
@@ -3335,7 +3320,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **Strip 9 orphan `wiki_*` i18n keys** — Opus pre-release follow-up. Commit `52bfcea` (#1342) leaked `wiki_panel_title`, `wiki_panel_desc`, `wiki_status_label`, `wiki_entry_count`, `wiki_last_modified`, `wiki_not_available`, `wiki_enabled`, `wiki_disabled`, `wiki_toggle_failed` across all 8 locales (72 lines total) from a different branch — zero references outside `i18n.js`. Stripped, with regression test pinning that they don't return. (`static/i18n.js`, `tests/test_v050253_opus_followups.py`)
## [v0.50.252] — 2026-05-01
### Fixed
@@ -3359,7 +3343,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **Reasoning trace, tool calls, and partial output preserved on Stop/Cancel** — three distinct data-loss paths fixed: §A reasoning text accumulated in a thread-local `_reasoning_text` was invisible to `cancel_stream()` because it went out of scope when the thread was interrupted; §B live tool calls in thread-local `_live_tool_calls` were similarly lost; §C reasoning-only streams (no visible tokens) produced no partial assistant message because the thinking-block regex strip returned empty string and the `if _stripped:` guard skipped the append. The fix mirrors the existing `STREAM_PARTIAL_TEXT` pattern (#893) by adding two new shared dicts (`STREAM_REASONING_TEXT`, `STREAM_LIVE_TOOL_CALLS`) populated during streaming and read by `cancel_stream()`. The cancel path now appends the partial assistant message when content text, reasoning trace, OR tool calls exist (not just text). Eliminates "paid tokens disappeared" reports on Stop. 8 regression tests covering all three sections plus tools+text combinations. (`api/config.py`, `api/streaming.py`, `tests/test_issue1361_cancel_data_loss.py`) @bergeouss — PR #1375, fixes #1361
- **New profiles route sessions to the profile dir on first use, not back to default** — `get_hermes_home_for_profile()` had a `if profile_dir.is_dir(): return profile_dir; return _DEFAULT_HERMES_HOME` fallback. New profiles (no session yet, so no dir) routed every session back to default until the directory existed on disk — making profile switching silently broken for the first session of every new profile. Removed the `is_dir()` guard; the profile path is now returned unconditionally and the directory is created on first use by the agent/session layer. Path traversal is still blocked by the `_PROFILE_ID_RE` regex (`^[a-z0-9][a-z0-9_-]{0,63}$`); R19j tests were updated to pin that the regex is now the sole defense. R19c was tightened to assert the new behavior. 5 regression tests in `test_issue1195_session_profile_routing.py` covering existing-profile, non-existent-profile (the core fix), None, empty-string, and 'default' return paths. (`api/profiles.py`, `tests/test_issue798.py`, `tests/test_issue1195_session_profile_routing.py`) @bergeouss — PR #1373, fixes #1195
## [v0.50.250] — 2026-04-30
### Fixed
@@ -3620,7 +3603,6 @@ This release is the first under the May 2 2026 auto-rebase + auto-fix policy: co
- **Pasted/dragged images render inline** — image attachments now show as `<img>` with click-to-fullscreen instead of a paperclip badge. Hoisted `_IMAGE_EXTS` to module scope (was causing `ReferenceError` in `renderMessages`); added `avif` support. (`static/ui.js`) [#1109 @bergeouss] Closes #1095
- **Copy buttons on HTTP** — `_copyText()` helper checks `isSecureContext` and falls back to `execCommand('copy')` for plain-HTTP self-hosted installs. Silent failure in `addCopyButtons` fixed with error feedback. All 6 locales get `copy_failed` key. (`static/ui.js`, `static/i18n.js`) [#1107 @bergeouss] Closes #1096
## v0.50.220 — 2026-04-26
### Fixed
@@ -4343,7 +4325,6 @@ Co-authored by @bergeouss.
newly created ones). Added `autocomplete="off"` to the search input and an explicit
value-clear at boot before the first render. Closes #822. (#830)
## [v0.50.140] — 2026-04-22
### Fixed
@@ -4670,7 +4651,6 @@ Co-authored by @bergeouss.
### Fixed
- **Auto-title extraction improved for tool-heavy first turns** — sessions where the agent's first response involved tool calls (e.g. memory lookups, file reads) were generating poor titles because the title extractor skipped all assistant messages with `tool_calls`, even when those messages contained substantive visible text. The extractor now picks the first pure (non-tool-call) assistant reply as the title source, using `_looks_invalid_generated_title()` to distinguish meta-reasoning preambles from real agentic replies. Also fixes `_is_provisional_title()` to normalize whitespace before comparing, so CJK text truncated at 64 characters correctly re-triggers title updates. (Closes #639, PR #640 by @franksong2702)
## [v0.50.80] — 2026-04-18
### Fixed
@@ -4765,7 +4745,6 @@ Co-authored by @bergeouss.
- **`HERMES_WEBUI_SKIP_ONBOARDING=1` now works unconditionally** — previously the env var was gated on `chat_ready=True`, so hosting providers (e.g. Agent37) that set it but hadn't yet wired up a provider key would still see the wizard on every page load. The var is now honoured as a hard operator override regardless of `chat_ready`. If you set it, the wizard is gone. (Fixes skip-onboarding regression)
- **Onboarding wizard can no longer overwrite config or env files when `SKIP_ONBOARDING` is set** — `apply_onboarding_setup` now checks the env var first and refuses to touch `config.yaml` or `.env` if it is set. This is a belt-and-suspenders guard: even if a stale JS bundle somehow triggers the setup endpoint while `SKIP_ONBOARDING` is active, no files are written.
## [v0.50.64] — 2026-04-16
### Changed
@@ -4869,7 +4848,6 @@ calls `sel.onchange`), and uses `S.messages` not `S.session.messages`.
Total tests: 1272 (was 1268)
## [v0.50.47] fix/feat: batch fixes — root workspace, custom providers, cron cache, system theme
Synthesized from PRs #506, #507, #508, #509, #510, #514, #515, #519, #521.
@@ -4920,7 +4898,6 @@ via `window.matchMedia`. Changes:
Total tests: 1268 (was 1246)
## [v0.50.46] feat: clarify dialog flow and refresh recovery (#520)
Adds a full clarify dialog UX for interactive agent questions — modeled after
@@ -4975,7 +4952,6 @@ Total tests: 1246 (was 1209)
Co-authored-by: franksong2702
## [v0.50.45] fix: suppress N/A source_tag in session list (#429)
Feishu and WeChat sessions (and any session with an unrecognised or legacy
@@ -5188,7 +5164,6 @@ New i18n keys for password replacement/keep-existing states (en, es, de, zh, zh-
- Total tests: 1078 (was 1075)
## [v0.50.38] feat: mobile nav cleanup, Prism syntax highlighting, zh-CN/zh-Hant i18n
Three community contributions combined:
@@ -5240,7 +5215,6 @@ and could silently overwrite their working config.
- Total tests: 1063 (was 1055)
## [v0.50.36] fix: workspace list cleaner — allow own-profile paths, remove brittle string filter
Two bugs in `_clean_workspace_list()` caused workspace additions to silently disappear on the next `load_workspaces()` call, breaking `test_workspace_add_no_duplicate` and `test_workspace_rename` (and potentially causing real-world workspace list corruption):
@@ -5452,7 +5426,6 @@ The hardcoded lists in `_PROVIDER_MODELS` remain as credential-missing / network
- **`static/ui.js`**: `populateModelDropdown()` now calls `_fetchLiveModels()` in the background after rendering the static list. Live models that aren't already in the dropdown are appended to the provider's optgroup. Results are cached per session so only one fetch per provider per page load. Skips Anthropic and Google (unsupported). Falls back to static list silently if the fetch fails.
- 25 new tests in `tests/test_issues_373_374_375.py`; 949 tests total (up from 924)
## [v0.50.19] Fix UnicodeEncodeError when downloading files with non-ASCII filenames (PR #378)
- **Workspace file downloads no longer crash for Unicode filenames** (`api/routes.py`): Clicking a PDF or other file with Chinese, Japanese, Arabic, or other non-ASCII characters in its name caused a `UnicodeEncodeError` because Python's HTTP server requires header values to be latin-1 encodable. A new `_content_disposition_value(disposition, filename)` helper centralises `Content-Disposition` generation: it strips CR/LF (injection guard), builds an ASCII fallback for the legacy `filename=` parameter (non-ASCII chars replaced with `_`), and preserves the full UTF-8 name in `filename*=UTF-8''...` per RFC 5987. Both `attachment` and `inline` responses use it.
+1 -1
View File
@@ -469,7 +469,7 @@ Production data and real cron jobs are never touched. Current snapshot:
### Themes
- Appearance is split into two axes: Theme (`system`, `dark`, `light`) and Skin
(`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`, `charizard`,
`sienna`, `catppuccin`, `nous`)
`sienna`, `catppuccin`, `nous`, `geist-contrast` / Geist Contrast)
- Switch via Settings -> Appearance (instant live preview) or `/theme <theme-or-skin>`
- Persists across reloads (server-side in settings.json + localStorage for flicker-free loading)
- Skins use `data-skin` plus CSS variables; dark mode resolves through the
+4 -3
View File
@@ -4,7 +4,7 @@ Hermes Web UI splits **appearance** into two independent pickers:
- **Theme** — the mode: `System`, `Dark`, or `Light`. Drives the background,
text, surface, and chrome colors.
- **Skin** — the accent palette: ten named skins ship built-in. Drives only
- **Skin** — the accent palette: built-in skins ship as named keys. Drives only
the `--accent` family (active states, links, focus rings, primary actions).
You pick one of each and they combine, so the look adapts to your environment
@@ -15,13 +15,13 @@ without losing your favorite accent — pure CSS, no Python changes needed.
## Switching Appearance
**Settings panel:** Click the gear icon → **Appearance**. The **Theme** card
toggles Light/Dark/System; the **Skin** grid offers ten accent palettes.
toggles Light/Dark/System; the **Skin** grid offers the built-in accent palettes.
Preview is instant — the UI updates as you click.
**Slash command:** Type `/theme <name>` in the composer. The command accepts
both theme names (`system`, `dark`, `light`) and skin names (`default`, `ares`,
`mono`, `slate`, `poseidon`, `sisyphus`, `charizard`, `sienna`,
`catppuccin`, `nous`). It updates the matching axis and leaves the other one
`catppuccin`, `nous`, `geist-contrast`). It updates the matching axis and leaves the other one
alone.
**Persistence:** Both choices are stored in `localStorage` for flicker-free
@@ -57,6 +57,7 @@ absent for light. System mode tracks the OS preference at runtime.
| **Sienna** | Warm clay and sand earth palette. Soft and natural. |
| **Catppuccin** | Catppuccin Latte/Mocha palette with Mauve accent. |
| **Nous** | Steel-blue accent with dashed technical surfaces. |
| **Geist Contrast** (`geist-contrast`) | Geist-inspired monochrome surfaces with a restrained dark-mode `#FFF175` accent. |
Each skin defines paired light + dark variants so it reads cleanly on either
theme. The skin is applied as `data-skin="<name>"` on `<html>` (the default
+9
View File
@@ -4085,6 +4085,14 @@ class StreamChannel:
for q in subscribers:
q.put_nowait(item)
def diagnostic_snapshot(self) -> dict[str, int]:
"""Return non-sensitive stream observation counters for health checks."""
with self._lock:
return {
"subscriber_count": len(self._subscribers),
"offline_buffered_events": len(self._offline_buffer),
}
def create_stream_channel() -> StreamChannel:
return StreamChannel()
@@ -4279,6 +4287,7 @@ _SETTINGS_SKIN_VALUES = {
"sienna",
"catppuccin",
"nous",
"geist-contrast",
}
_SETTINGS_LEGACY_THEME_MAP = {
# Legacy full themes now map onto the closest supported theme + accent skin pair.
+49 -8
View File
@@ -12,7 +12,7 @@ import json
import logging
import os
import urllib.request
from urllib.parse import urlparse
from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__)
@@ -61,6 +61,41 @@ def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] |
return normalized_host, port, parsed.scheme, base
def normalize_dashboard_browser_url(raw_url: str | None) -> str:
"""Return a safe browser-only dashboard link URL.
Unlike the server-side probe target, this value is only returned to the
browser for navigation. It may point at a public reverse-proxy hostname, but
it still rejects credentials, paths, query strings, fragments, and non-HTTP
schemes so it cannot hide secrets or script URLs in config.
"""
raw = str(raw_url or "").strip()
if not raw:
return ""
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("invalid dashboard URL scheme")
if parsed.username or parsed.password:
raise ValueError("invalid dashboard URL credentials")
if not parsed.hostname:
raise ValueError("invalid dashboard URL host")
if parsed.params or parsed.query or parsed.fragment:
raise ValueError("invalid dashboard URL path")
path = parsed.path or ""
if path not in ("", "/"):
raise ValueError("invalid dashboard URL path")
try:
port = parsed.port
except ValueError as exc:
raise ValueError("invalid dashboard URL port") from exc
netloc = parsed.hostname.lower()
if port is not None:
if not (1 <= port <= 65535):
raise ValueError("invalid dashboard URL port")
netloc = f"{netloc}:{port}"
return urlunparse((parsed.scheme, netloc, "", "", "", ""))
def _looks_like_official_dashboard(payload: object) -> bool:
if not isinstance(payload, dict):
return False
@@ -132,8 +167,7 @@ def get_dashboard_config(config_data: dict | None = None) -> dict:
enabled = "auto"
raw_url = str(dashboard_cfg.get("url") or "").strip()
if raw_url:
# Normalize before echoing so the UI never displays unsafe/stale values.
_host, _port, _scheme, raw_url = normalize_dashboard_url(raw_url)
raw_url = normalize_dashboard_browser_url(raw_url)
return {"enabled": enabled, "url": raw_url}
@@ -143,9 +177,7 @@ def save_dashboard_config(payload: dict) -> dict:
if enabled not in _DASHBOARD_ENABLED_VALUES:
raise ValueError("invalid dashboard enabled mode")
raw_url = str((payload or {}).get("url", "") or "").strip()
normalized_url = ""
if raw_url:
_host, _port, _scheme, normalized_url = normalize_dashboard_url(raw_url)
normalized_url = normalize_dashboard_browser_url(raw_url) if raw_url else ""
from api import config as webui_config
@@ -186,9 +218,13 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:
raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or ""
try:
override = normalize_dashboard_url(raw_url)
browser_url = normalize_dashboard_browser_url(raw_url) if raw_url else ""
except ValueError:
return {"running": False, "enabled": enabled, "error": "invalid dashboard url"}
try:
override = normalize_dashboard_url(raw_url)
except ValueError:
override = None
targets: list[tuple[str, int, str, str]]
if override:
@@ -197,8 +233,10 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:
targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS]
if enabled == "always":
if browser_url and not override:
return {"running": True, "enabled": enabled, "url": browser_url, "browser_url": browser_url}
host, port, scheme, base = targets[0]
return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base}
return {"running": True, "enabled": enabled, "host": host, "port": port, "url": browser_url or base, "browser_url": browser_url or base}
if not _webui_bind_host_allows_auto_probe():
return {"running": False, "enabled": enabled}
@@ -207,5 +245,8 @@ def get_dashboard_status(config_data: dict | None = None) -> dict:
result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme)
if result.get("running"):
result["enabled"] = enabled
if browser_url:
result["browser_url"] = browser_url
result["url"] = browser_url
return result
return {"running": False, "enabled": enabled}
+47 -12
View File
@@ -1970,18 +1970,7 @@ def _merged_session_messages_for_display(session, cli_messages=None) -> list:
str(m.get("role") or ""),
str(m.get("content") or ""),
)):
message_identity = msg.get("id") or msg.get("message_id")
if message_identity:
key = ("message_id", str(message_identity))
else:
key = (
"legacy",
str(msg.get("role") or ""),
str(msg.get("content") or ""),
str(msg.get("timestamp") or ""),
str(msg.get("tool_call_id") or ""),
str(msg.get("tool_name") or msg.get("name") or ""),
)
key = _session_message_merge_key(msg)
if key in seen_message_keys:
continue
seen_message_keys.add(key)
@@ -2222,6 +2211,7 @@ from api.models import (
get_cli_session_messages,
get_state_db_session_messages,
merge_session_messages_append_only,
_session_message_merge_key,
ensure_cron_project,
is_cron_session,
)
@@ -3076,6 +3066,47 @@ def _streams_lock_health(timeout_seconds: float = 0.5) -> dict:
STREAMS_LOCK.release()
def _stream_runtime_diagnostics() -> dict:
"""Return non-sensitive SSE stream diagnostics for health/deep status.
The WebUI chat path can feel slow or stuck when streams are alive but no
browser is attached, or when many events are buffering offline. This helper
exposes counts only — stream ids plus subscriber/buffer sizes — and avoids
event payloads, prompts, tool arguments, or paths.
"""
streams = []
total_subscribers = 0
total_offline_buffered_events = 0
with STREAMS_LOCK:
items = list(STREAMS.items())
for stream_id, stream in items:
snapshot = {}
diagnostic_snapshot = getattr(stream, "diagnostic_snapshot", None)
if callable(diagnostic_snapshot):
try:
raw_snapshot = diagnostic_snapshot()
if isinstance(raw_snapshot, dict):
snapshot = raw_snapshot
except Exception:
snapshot = {}
subscriber_count = int(snapshot.get("subscriber_count") or 0)
offline_buffered_events = int(snapshot.get("offline_buffered_events") or 0)
total_subscribers += subscriber_count
total_offline_buffered_events += offline_buffered_events
streams.append({
"stream_id": str(stream_id),
"subscriber_count": subscriber_count,
"offline_buffered_events": offline_buffered_events,
})
streams.sort(key=lambda item: item["stream_id"])
return {
"active_streams": len(streams),
"total_subscribers": total_subscribers,
"total_offline_buffered_events": total_offline_buffered_events,
"streams": streams,
}
def _run_lifecycle_health() -> dict:
"""Return active worker-run state independent of SSE stream presence."""
# Import the module rather than relying only on imported scalar aliases so
@@ -3124,6 +3155,10 @@ def _deep_health_checks(stream_check: dict | None = None) -> tuple[dict, bool]:
checks: dict[str, dict] = {}
checks["streams_lock"] = stream_check if stream_check is not None else _streams_lock_health()
checks["stream_runtime"] = {
"status": "ok",
**_stream_runtime_diagnostics(),
}
if checks["streams_lock"].get("status") != "ok":
return checks, False
+74 -7
View File
@@ -3500,10 +3500,20 @@ def _run_agent_streaming(
# closes over it) never captures an unbound name even if this
# block is reordered later (Issue #765).
_checkpoint_activity = [0]
_live_tool_event_start_ids = set()
_live_tool_event_complete_ids = set()
def _tool_args_snapshot(args):
args_snap = {}
if isinstance(args, dict):
for k, v in list(args.items())[:4]:
s2 = str(v)
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
return args_snap
def _record_live_tool_start(tool_call_id, name, args):
if not tool_call_id or tool_call_id in _live_prompt_estimate_seen_ids:
return
return False
_live_prompt_estimate_seen_ids.add(tool_call_id)
_tool_call = {
'id': tool_call_id,
@@ -3518,10 +3528,11 @@ def _run_agent_streaming(
'content': '',
'tool_calls': [_tool_call],
}])
return True
def _record_live_tool_complete(tool_call_id, name, function_result):
if not tool_call_id:
return
return False
_result_text = _tool_result_snippet(function_result)
_bump_live_prompt_estimate([{
'role': 'tool',
@@ -3529,6 +3540,7 @@ def _run_agent_streaming(
'tool_call_id': tool_call_id,
'content': _result_text,
}])
return True
def on_tool(*cb_args, **cb_kwargs):
nonlocal _reasoning_text
@@ -3561,11 +3573,14 @@ def _run_agent_streaming(
_emit_metering()
return
args_snap = {}
if isinstance(args, dict):
for k, v in list(args.items())[:4]:
s2 = str(v)
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
args_snap = _tool_args_snapshot(args)
# Modern Hermes Agent builds can call both tool_progress_callback
# and the structured tool_start/tool_complete callbacks for the
# same tool. Prefer the structured path when it is supported so
# the browser receives one tid-tagged tool card per real call.
if event_type in (None, 'tool.started') and 'tool_start_callback' in _agent_params:
return
if event_type in (None, 'tool.started'):
_live_tool_calls.append({
@@ -3602,6 +3617,9 @@ def _run_agent_streaming(
pass
return
if event_type == 'tool.completed' and 'tool_complete_callback' in _agent_params:
return
if event_type == 'tool.completed':
for live_tc in reversed(_live_tool_calls):
if live_tc.get('done'):
@@ -3641,6 +3659,28 @@ def _run_agent_streaming(
def on_tool_start(tool_call_id, name, args):
try:
_record_live_tool_start(tool_call_id, name, args)
if tool_call_id and tool_call_id not in _live_tool_event_start_ids:
_live_tool_event_start_ids.add(tool_call_id)
_live_tool_calls.append({
'name': name,
'args': args if isinstance(args, dict) else {},
'tid': tool_call_id,
})
# Mirror to shared dict so cancel_stream() can persist it (#1361 §B)
if stream_id in STREAM_LIVE_TOOL_CALLS:
STREAM_LIVE_TOOL_CALLS[stream_id].append({
'name': name,
'args': args if isinstance(args, dict) else {},
'done': False,
'tid': tool_call_id,
})
put('tool', {
'event_type': 'tool.started',
'name': name,
'preview': None,
'args': _tool_args_snapshot(args),
'tid': tool_call_id,
})
_tool_stats = meter().get_stats()
_tool_stats['session_id'] = session_id
_tool_stats['usage'] = _live_usage_snapshot()
@@ -3651,6 +3691,33 @@ def _run_agent_streaming(
def on_tool_complete(tool_call_id, name, args, function_result):
try:
_record_live_tool_complete(tool_call_id, name, function_result)
if tool_call_id and tool_call_id not in _live_tool_event_complete_ids:
_live_tool_event_complete_ids.add(tool_call_id)
result_snippet = _tool_result_snippet(function_result)
for live_tc in reversed(_live_tool_calls):
if live_tc.get('done'):
continue
if live_tc.get('tid') == tool_call_id or (not live_tc.get('tid') and live_tc.get('name') == name):
live_tc['done'] = True
live_tc['snippet'] = result_snippet
break
if stream_id in STREAM_LIVE_TOOL_CALLS:
for shared_tc in reversed(STREAM_LIVE_TOOL_CALLS[stream_id]):
if shared_tc.get('done'):
continue
if shared_tc.get('tid') == tool_call_id or (not shared_tc.get('tid') and shared_tc.get('name') == name):
shared_tc['done'] = True
shared_tc['snippet'] = result_snippet
break
_checkpoint_activity[0] += 1
put('tool_complete', {
'event_type': 'tool.completed',
'name': name,
'preview': result_snippet,
'args': _tool_args_snapshot(args),
'tid': tool_call_id,
'is_error': False,
})
_tool_stats = meter().get_stats()
_tool_stats['session_id'] = session_id
_tool_stats['usage'] = _live_usage_snapshot()
+2 -2
View File
@@ -64,8 +64,8 @@ that slice.
Current appearance has a theme axis (`light`, `dark`, `system`) and a separate
skin axis (`default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`,
`charizard`, `sienna`, `catppuccin`, `nous`) in `static/boot.js` and
`static/style.css`. Do not follow stale `data-theme`-only theme guidance unless
`charizard`, `sienna`, `catppuccin`, `nous`, `geist-contrast`) in
`static/boot.js` and `static/style.css`. Do not follow stale `data-theme`-only theme guidance unless
the current code and tests prove that model still applies.
For UI or UX work, include before/after evidence, verify relevant responsive
+1 -1
View File
@@ -150,7 +150,7 @@ Current implementation has two appearance axes, sourced from `static/boot.js`:
`theme` is only `light`, `dark`, or `system` and resolves to the `.dark` class
for dark mode; `skin` is a separate axis applied with `data-skin` and currently
includes `default`, `ares`, `mono`, `slate`, `poseidon`, `sisyphus`,
`charizard`, `sienna`, `catppuccin`, and `nous`. `slate` is both an active skin
`charizard`, `sienna`, `catppuccin`, `nous`, and `geist-contrast` / Geist Contrast. `slate` is both an active skin
and a legacy theme-name migration target; `solarized`, `monokai`, `nord`, and
`oled` are legacy theme names mapped to current theme/skin pairs. Do not follow
stale `data-theme`-only guidance without first proving the current
+6 -4
View File
@@ -1201,9 +1201,10 @@ const _SKINS=[
{name:'Sienna', colors:['#D97757','#C06A49','#9A523A']},
{name:'Catppuccin',colors:['#CBA6F7','#B4BEFE','#8839EF']},
{name:'Nous', colors:['#4682B4','#3A6E9A','#2C5F88']},
{name:'Geist Contrast', value:'geist-contrast', colors:['#000000','#ffffff','#FFF175']},
];
const _VALID_THEMES=new Set((_THEMES||[]).map(t=>t.value));
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>s.name.toLowerCase()));
const _VALID_SKINS=new Set((_SKINS||[]).map(s=>(s.value||s.name).toLowerCase()));
const _LEGACY_THEME_MAP={
slate:{theme:'dark',skin:'slate'},
solarized:{theme:'dark',skin:'poseidon'},
@@ -1365,15 +1366,16 @@ function _buildSkinPicker(activeSkin){
if(!grid) return;
grid.innerHTML='';
for(const skin of _SKINS){
const key=skin.name.toLowerCase();
const key=(skin.value||skin.name).toLowerCase();
const btn=document.createElement('button');
btn.type='button';
btn.className='skin-pick-btn';
btn.dataset.skinVal=key;
btn.style.cssText='border:1px solid var(--border2);border-radius:8px;padding:8px 4px;text-align:center;cursor:pointer;background:none;transition:all .15s';
btn.onclick=()=>_pickSkin(skin.name);
btn.onclick=()=>_pickSkin(key);
const dots=skin.colors.map(c=>`<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${c}"></span>`).join('');
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${skin.name}</span>`;
const label=skin.label||skin.name;
btn.innerHTML=`<div style="display:flex;gap:3px;justify-content:center;margin-bottom:4px">${dots}</div><span style="font-size:11px;color:var(--text)">${label}</span>`;
grid.appendChild(btn);
}
_syncSkinPicker((activeSkin||'default').toLowerCase());
+1 -1
View File
@@ -664,7 +664,7 @@ async function cmdUsage(){
async function cmdTheme(args){
const themes=['system','dark','light'];
const skins=(_SKINS||[]).map(s=>s.name.toLowerCase());
const skins=(_SKINS||[]).map(s=>(s.value||s.name).toLowerCase());
const legacyThemes=Object.keys(_LEGACY_THEME_MAP||{});
const val=(args||'').toLowerCase().trim();
// Check if it's a theme
+11 -11
View File
@@ -188,7 +188,7 @@ const LOCALES = {
cmd_terminal: 'Open the workspace terminal',
cmd_new: 'Start a new chat session',
cmd_usage: 'Toggle token usage display on/off',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills',
available_commands: 'Available commands:',
@@ -1412,7 +1412,7 @@ const LOCALES = {
cmd_terminal: 'Apri il terminale del workspace',
cmd_new: 'Avvia una nuova sessione di chat',
cmd_usage: 'Attiva/disattiva visualizzazione uso token',
cmd_theme: 'Cambia aspetto (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Cambia aspetto (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: "Cambia personalità dell'agente",
cmd_skills: 'Elenca le skill Hermes disponibili',
available_commands: 'Comandi disponibili:',
@@ -2628,7 +2628,7 @@ const LOCALES = {
cmd_terminal: 'ワークスペースのターミナルを開く',
cmd_new: '新しいチャットセッションを開始',
cmd_usage: 'トークン使用量表示の ON/OFF を切り替え',
cmd_theme: '外観を切り替え (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: '外観を切り替え (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'エージェントのパーソナリティを切り替え',
cmd_skills: '利用可能な Hermes スキルを一覧表示',
available_commands: '利用可能なコマンド:',
@@ -3813,7 +3813,7 @@ const LOCALES = {
cmd_terminal: 'Открыть терминал рабочей области',
cmd_new: 'Начать новую сессию чата',
cmd_usage: 'Показать или скрыть использование токенов',
cmd_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Переключить внешний вид (тема: system/dark/light, скин: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Переключить личность агента',
cmd_skills: 'Показать доступные навыки Hermes',
available_commands: 'Доступные команды:',
@@ -4996,7 +4996,7 @@ const LOCALES = {
cmd_terminal: 'Abrir terminal del espacio de trabajo',
cmd_new: 'Iniciar una nueva sesión de chat',
cmd_usage: 'Activar o desactivar el uso de tokens',
cmd_theme: 'Cambiar apariencia (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Cambiar apariencia (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Cambiar la personalidad del agente',
cmd_skills: 'Listar las skills de Hermes disponibles',
available_commands: 'Comandos disponibles:',
@@ -6125,7 +6125,7 @@ const LOCALES = {
cmd_terminal: 'Workspace-Terminal öffnen',
cmd_new: 'Neue Chat-Sitzung starten',
cmd_usage: 'Token-Verbrauchsanzeige umschalten',
cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Darstellung wechseln (Theme: system/dark/light, Skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Agenten-Persönlichkeit wechseln',
cmd_skills: 'Verfügbare Hermes-Skills auflisten',
available_commands: 'Verfügbare Befehle:',
@@ -7305,7 +7305,7 @@ const LOCALES = {
cmd_terminal: '打开工作区 Terminal',
cmd_new: '新建聊天会话',
cmd_usage: '切换 token 用量显示',
cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: '切换外观(主题:system/dark/light,皮肤:default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: '切换 Agent 人设',
cmd_skills: '列出可用的 Hermes 技能',
available_commands: '可用命令:',
@@ -8422,7 +8422,7 @@ const LOCALES = {
cmd_terminal: '\u6253\u958b\u5de5\u4f5c\u5340 Terminal',
cmd_new: '\u65b0\u5efa\u804a\u5929\u6703\u8a71',
cmd_usage: '\u5207\u63db token \u7528\u91cf\u986f\u793a',
cmd_theme: '\u5207\u63db\u5916\u89c0\uff08\u4e3b\u984c\uff1asystem/dark/light\uff0c\u76ae\u819a\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous\uff09',
cmd_theme: '\u5207\u63db\u5916\u89c0\uff08\u4e3b\u984c\uff1asystem/dark/light\uff0c\u76ae\u819a\uff1adefault/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast\uff09',
cmd_personality: '\u5207\u63db Agent \u4eba\u8a2d',
cmd_skills: '\u5217\u51fa\u53ef\u7528\u7684 Hermes \u6280\u80fd',
available_commands: '\u53ef\u7528\u547d\u4ee4\uff1a',
@@ -9628,7 +9628,7 @@ const LOCALES = {
cmd_workspace: 'Trocar workspace por nome',
cmd_new: 'Iniciar nova sessão de chat',
cmd_usage: 'Alternar exibição de uso de tokens',
cmd_theme: 'Trocar aparência (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Trocar aparência (tema: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Trocar personalidade do agente',
cmd_skills: 'Listar skills disponíveis do Hermes',
available_commands: 'Comandos disponíveis:',
@@ -10730,7 +10730,7 @@ const LOCALES = {
cmd_terminal: '워크스페이스 터미널 열기',
cmd_new: '새 채팅 세션 시작',
cmd_usage: '토큰 사용량 표시 켜기/끄기',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Switch appearance (theme: system/dark/light, skin: default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Switch agent personality',
cmd_skills: 'List available Hermes skills',
available_commands: '사용 가능한 명령:',
@@ -11938,7 +11938,7 @@ const LOCALES = {
cmd_terminal: 'Ouvrez le terminal de l\'espace de travail',
cmd_new: 'Démarrer une nouvelle session de discussion',
cmd_usage: 'Activer/désactiver l\'affichage de l\'utilisation du jeton',
cmd_theme: 'Changer d\'apparence (thème : system/dark/light, skin : default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous)',
cmd_theme: 'Changer d\'apparence (thème : system/dark/light, skin : default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast)',
cmd_personality: 'Personnalité de l\'agent de commutation',
cmd_skills: 'Lister les compétences Hermès disponibles',
available_commands: 'Commandes disponibles :',
+2 -2
View File
@@ -17,7 +17,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Hermes">
<link rel="apple-touch-icon" sizes="512x512" href="static/apple-touch-icon.png">
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,nous:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1,catppuccin:1,nous:1,'geist-contrast':1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<!-- theme-color: surfaces the active app chrome color to native status bars (Safari status bar, PWA, native WKWebView wrappers). Updated dynamically by boot.js when theme/skin changes. The light/dark default values match style.css :root --sidebar / :root.dark --sidebar. -->
<meta name="theme-color" content="#FAF7F0" media="(prefers-color-scheme: light)">
@@ -1222,7 +1222,7 @@
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
<label for="settingsDashboardMode">Official Hermes Dashboard</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Show a nav-rail link when the official <code>hermes dashboard</code> is reachable. Overrides are restricted to loopback URLs.</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Show a nav-rail link when the official <code>hermes dashboard</code> is reachable. Public reverse-proxy URLs are stored as browser-only links and are never server-probed.</div>
<select id="settingsDashboardMode" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
<option value="auto">Auto-detect</option>
<option value="always">Always show</option>
+167
View File
@@ -297,6 +297,173 @@
:root.dark[data-skin="nous"] .mcp-http{color:#7EB6E0;}
:root.dark[data-skin="nous"] .mcp-status-active{color:#7EB6E0;}
/* ── Skin: Geist Contrast (Geist-inspired neutral precision) ──
Dark mode uses a #FFF175 accent; solid accent controls use black
foreground, while selected/navigation states stay neutral and restrained. */
:root[data-skin="geist-contrast"]{
color-scheme:light;
--bg:#ffffff;--sidebar:#fafafa;--surface:#ffffff;--surface-subtle:#fafafa;--surface-subtle-hover:#f5f5f5;
--main-bg:#ffffff;--topbar-bg:rgba(255,255,255,.92);
--border:#eaeaea;--border2:#d4d4d4;--border-subtle:#ededed;--border-muted:#d4d4d4;
--text:#111111;--strong:#000000;--muted:#666666;--em:#444444;
--accent:#0070f3;--accent-hover:#005bd1;--accent-bg:rgba(0,112,243,.075);--accent-bg-strong:rgba(0,112,243,.16);--accent-text:#005bd1;
--blue:#0070f3;--gold:#111111;--focus-ring:rgba(0,112,243,.28);--focus-glow:rgba(0,112,243,.08);
--input-bg:#ffffff;--hover-bg:#f5f5f5;--code-bg:#fafafa;--code-inline-bg:#f5f5f5;--code-text:#111111;--pre-text:#111111;
--error:#e5484d;--success:#007a45;--warning:#b45309;--info:#0070f3;
--radius-sm:4px;--radius-md:6px;--radius-card:8px;--radius-lg:10px;
--font-ui:"Geist","Geist Sans",-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
}
:root.dark[data-skin="geist-contrast"]{
color-scheme:dark;
--bg:#000000;--sidebar:#050505;--surface:#0a0a0a;--surface-subtle:#111111;--surface-subtle-hover:#171717;
--main-bg:#000000;--topbar-bg:rgba(0,0,0,.88);
--border:#262626;--border2:#3f3f3f;--border-subtle:#171717;--border-muted:#2a2a2a;
--text:#ededed;--strong:#ffffff;--muted:#a1a1a1;--em:#d4d4d4;
--accent:#FFF175;--accent-hover:#fff7a8;--accent-bg:rgba(255,241,117,.075);--accent-bg-strong:rgba(255,241,117,.14);--accent-text:#f5e65f;
--blue:#FFF175;--gold:#FFF175;--focus-ring:rgba(255,241,117,.34);--focus-glow:rgba(255,241,117,.10);
--input-bg:#0a0a0a;--hover-bg:#111111;--code-bg:#0a0a0a;--code-inline-bg:#171717;--code-text:#f5f5f5;--pre-text:#ededed;
--error:#ff6369;--success:#3dd68c;--warning:#f5a524;--info:#FFF175;
}
:root[data-skin="geist-contrast"],
:root[data-skin="geist-contrast"] body{font-family:var(--font-ui)!important;letter-spacing:-0.011em;background:var(--bg)!important;}
:root[data-skin="geist-contrast"] .app-titlebar,
:root[data-skin="geist-contrast"] .rail,
:root[data-skin="geist-contrast"] .sidebar,
:root[data-skin="geist-contrast"] .rightpanel,
:root[data-skin="geist-contrast"] .topbar,
:root[data-skin="geist-contrast"] .composer-wrap{background:var(--sidebar)!important;border-color:var(--border)!important;box-shadow:none!important;backdrop-filter:saturate(180%) blur(12px);}
:root[data-skin="geist-contrast"] .main,
:root[data-skin="geist-contrast"] .chat,
:root[data-skin="geist-contrast"] .empty-state,
:root[data-skin="geist-contrast"] .panel-view,
:root[data-skin="geist-contrast"] .settings-section,
:root[data-skin="geist-contrast"] .settings-card,
:root[data-skin="geist-contrast"] .model-dropdown,
:root[data-skin="geist-contrast"] .profile-menu,
:root[data-skin="geist-contrast"] .session-actions-menu,
:root[data-skin="geist-contrast"] .app-dialog,
:root[data-skin="geist-contrast"] .kanban-modal,
:root[data-skin="geist-contrast"] .cron-item,
:root[data-skin="geist-contrast"] .tool-card,
:root[data-skin="geist-contrast"] .msg-body pre,
:root[data-skin="geist-contrast"] .preview-md pre,
:root[data-skin="geist-contrast"] .composer-box{background:var(--surface)!important;border-color:var(--border)!important;box-shadow:none!important;}
:root[data-skin="geist-contrast"] .session-item,
:root[data-skin="geist-contrast"] .nav-tab,
:root[data-skin="geist-contrast"] .rail-btn,
:root[data-skin="geist-contrast"] .icon-btn,
:root[data-skin="geist-contrast"] .panel-head-btn,
:root[data-skin="geist-contrast"] .sm-btn,
:root[data-skin="geist-contrast"] .btn,
:root[data-skin="geist-contrast"] button,
:root[data-skin="geist-contrast"] select,
:root[data-skin="geist-contrast"] input,
:root[data-skin="geist-contrast"] textarea,
:root[data-skin="geist-contrast"] .chip,
:root[data-skin="geist-contrast"] .project-chip,
:root[data-skin="geist-contrast"] .profile-chip,
:root[data-skin="geist-contrast"] .model-chip,
:root[data-skin="geist-contrast"] .reasoning-chip,
:root[data-skin="geist-contrast"] .app-dialog-btn{border-radius:6px!important;}
:root[data-skin="geist-contrast"] .session-item:hover,
:root[data-skin="geist-contrast"] .nav-tab:hover,
:root[data-skin="geist-contrast"] .rail-btn:hover,
:root[data-skin="geist-contrast"] .icon-btn:hover,
:root[data-skin="geist-contrast"] .panel-head-btn:hover,
:root[data-skin="geist-contrast"] .sm-btn:hover,
:root[data-skin="geist-contrast"] .project-chip:hover,
:root[data-skin="geist-contrast"] .chip:hover,
:root[data-skin="geist-contrast"] .profile-opt:hover,
:root[data-skin="geist-contrast"] .ws-opt:hover,
:root[data-skin="geist-contrast"] .file-item:hover,
:root[data-skin="geist-contrast"] .suggestion:hover{background:var(--hover-bg)!important;}
:root[data-skin="geist-contrast"] .session-item.active,
:root[data-skin="geist-contrast"] .nav-tab.active,
:root[data-skin="geist-contrast"] .rail-btn.active,
:root[data-skin="geist-contrast"] .project-chip.active,
:root[data-skin="geist-contrast"] .profile-opt.active,
:root[data-skin="geist-contrast"] .chip.model,
:root[data-skin="geist-contrast"] .side-menu-item.active,
:root[data-skin="geist-contrast"] .skin-pick-btn.active,
:root[data-skin="geist-contrast"] .theme-pick-btn.active,
:root[data-skin="geist-contrast"] .font-size-pick-btn.active{background:var(--surface-subtle)!important;border-color:var(--border2)!important;color:var(--text)!important;box-shadow:inset 0 0 0 1px var(--border)!important;font-weight:500!important;}
:root[data-skin="geist-contrast"] .session-item.active{position:relative;border:1px solid var(--border2)!important;}
:root[data-skin="geist-contrast"] .session-item.active::before{content:"";position:absolute;left:6px;top:10px;bottom:10px;width:2px;border-radius:999px;background:var(--accent);}
:root[data-skin="geist-contrast"] .session-item.active .session-title,
:root[data-skin="geist-contrast"] .session-item.active .session-meta,
:root[data-skin="geist-contrast"] .session-item.active .session-preview,
:root[data-skin="geist-contrast"] .session-item.active *{color:inherit!important;}
:root.dark[data-skin="geist-contrast"] .session-item.active,
:root.dark[data-skin="geist-contrast"] .session-item.active *{color:var(--text)!important;}
:root[data-skin="geist-contrast"] .nav-tab.active,
:root[data-skin="geist-contrast"] .rail-btn.active{color:var(--accent-text)!important;}
:root.dark[data-skin="geist-contrast"] .nav-tab.active svg,
:root.dark[data-skin="geist-contrast"] .rail-btn.active svg,
:root.dark[data-skin="geist-contrast"] .nav-tab.active [data-lucide],
:root.dark[data-skin="geist-contrast"] .rail-btn.active [data-lucide]{color:var(--accent-text)!important;stroke:var(--accent-text)!important;}
:root[data-skin="geist-contrast"] .profile-chip,
:root[data-skin="geist-contrast"] .model-chip,
:root[data-skin="geist-contrast"] .reasoning-chip,
:root[data-skin="geist-contrast"] .composer-workspace-chip,
:root[data-skin="geist-contrast"] .composer-profile-chip,
:root[data-skin="geist-contrast"] .composer-model-chip{color:var(--muted)!important;border-color:transparent!important;background:transparent!important;}
:root[data-skin="geist-contrast"] .profile-chip:hover,
:root[data-skin="geist-contrast"] .model-chip:hover,
:root[data-skin="geist-contrast"] .reasoning-chip:hover,
:root[data-skin="geist-contrast"] .composer-workspace-chip:hover,
:root[data-skin="geist-contrast"] .composer-profile-chip:hover,
:root[data-skin="geist-contrast"] .composer-model-chip:hover{color:var(--text)!important;background:var(--surface-subtle)!important;border-color:var(--border)!important;}
:root[data-skin="geist-contrast"] .new-chat-btn,
:root[data-skin="geist-contrast"] button.send-btn:not(:disabled),
:root[data-skin="geist-contrast"] .btn.primary,
:root[data-skin="geist-contrast"] .update-primary,
:root[data-skin="geist-contrast"] .app-dialog-btn.confirm,
:root[data-skin="geist-contrast"] .approval-btn.once,
:root[data-skin="geist-contrast"] .approval-btn.session,
:root[data-skin="geist-contrast"] .clarify-submit{background:var(--accent)!important;border-color:var(--accent)!important;color:#050505!important;font-weight:600!important;box-shadow:none!important;}
:root[data-skin="geist-contrast"]:not(.dark) .new-chat-btn,
:root[data-skin="geist-contrast"]:not(.dark) button.send-btn:not(:disabled),
:root[data-skin="geist-contrast"]:not(.dark) .btn.primary,
:root[data-skin="geist-contrast"]:not(.dark) .update-primary,
:root[data-skin="geist-contrast"]:not(.dark) .app-dialog-btn.confirm,
:root[data-skin="geist-contrast"]:not(.dark) .approval-btn.once,
:root[data-skin="geist-contrast"]:not(.dark) .approval-btn.session,
:root[data-skin="geist-contrast"]:not(.dark) .clarify-submit{color:#ffffff!important;}
:root[data-skin="geist-contrast"] .new-chat-btn:hover,
:root[data-skin="geist-contrast"] button.send-btn:not(:disabled):hover,
:root[data-skin="geist-contrast"] .btn.primary:hover,
:root[data-skin="geist-contrast"] .update-primary:hover,
:root[data-skin="geist-contrast"] .app-dialog-btn.confirm:hover,
:root[data-skin="geist-contrast"] .approval-btn.once:hover,
:root[data-skin="geist-contrast"] .approval-btn.session:hover,
:root[data-skin="geist-contrast"] .clarify-submit:hover{background:var(--accent-hover)!important;border-color:var(--accent-hover)!important;transform:none!important;}
:root[data-skin="geist-contrast"] button.send-btn:disabled{background:var(--surface-subtle)!important;border-color:var(--border)!important;color:var(--muted)!important;opacity:1!important;}
:root.dark[data-skin="geist-contrast"] button.send-btn:disabled svg,
:root.dark[data-skin="geist-contrast"] button.send-btn:disabled [data-lucide]{color:var(--muted)!important;stroke:var(--muted)!important;}
:root[data-skin="geist-contrast"] input:focus,
:root[data-skin="geist-contrast"] textarea:focus,
:root[data-skin="geist-contrast"] select:focus,
:root[data-skin="geist-contrast"] .composer-box:focus-within,
:root[data-skin="geist-contrast"] .app-dialog-input:focus,
:root[data-skin="geist-contrast"] .sidebar-search input:focus{border-color:var(--accent)!important;box-shadow:0 0 0 3px var(--focus-ring)!important;outline:none!important;}
:root[data-skin="geist-contrast"] .logo,
:root[data-skin="geist-contrast"] .sidebar-header .logo{background:var(--surface-subtle)!important;color:var(--accent-text)!important;border:1px solid var(--border2)!important;border-radius:8px!important;box-shadow:none!important;font-weight:650!important;}
:root[data-skin="geist-contrast"] .app-titlebar-icon rect,
:root[data-skin="geist-contrast"] .app-titlebar-icon path,
:root[data-skin="geist-contrast"] .app-titlebar-icon circle{color:var(--accent-text)!important;fill:var(--accent-text)!important;}
:root[data-skin="geist-contrast"] .msg-row[data-role="user"] .msg-body{background:var(--surface-subtle)!important;border:1px solid var(--border)!important;border-radius:10px!important;}
:root[data-skin="geist-contrast"] .suggestion{background:var(--surface)!important;border-color:var(--border)!important;color:var(--muted)!important;transition:background .15s ease,border-color .15s ease,color .15s ease!important;}
:root[data-skin="geist-contrast"] .suggestion:hover{border-color:var(--border2)!important;color:var(--text)!important;}
:root[data-skin="geist-contrast"] .msg-body a,
:root[data-skin="geist-contrast"] .preview-md a,
:root[data-skin="geist-contrast"] .empty-state a,
:root[data-skin="geist-contrast"] .tool-arg-key,
:root[data-skin="geist-contrast"] .tool-card-more,
:root[data-skin="geist-contrast"] .session-pin-indicator,
:root[data-skin="geist-contrast"] .sidebar-date-header.pinned{color:var(--accent-text)!important;}
:root[data-skin="geist-contrast"]::-webkit-scrollbar-thumb{background:var(--border2)!important;}
:root[data-skin="geist-contrast"] ::selection{background:var(--accent-bg-strong);color:var(--strong);}
/* #594: app-dialog light mode overrides — base styles use hardcoded dark gradients */
:root:not(.dark) .app-dialog{
background:linear-gradient(180deg,rgba(240,237,232,.99),rgba(228,224,216,.99));
+8 -3
View File
@@ -401,10 +401,15 @@ function _dashboardIsBrowserLoopback(){
return host==='127.0.0.1'||host==='localhost'||host==='::1';
}
function _dashboardBrowserUrl(status){
if(!status||!status.running||!status.port) return '';
if(!status||!status.running) return '';
if(status.browser_url||status.url){
try{return new URL(status.browser_url||status.url).toString().replace(/\/$/,'');}
catch(_){}
}
if(!status.port) return '';
let source;
try{source=new URL(status.url||('http://127.0.0.1:'+status.port));}
catch(_){source=new URL('http://127.0.0.1:'+status.port);}
try{source=new URL('http://127.0.0.1:'+status.port);}
catch(_){return '';}
const browserHost=window.location.hostname||source.hostname;
const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost;
return source.protocol+'//'+displayHost+':'+status.port;
@@ -67,18 +67,26 @@ class TestSettingsPickerActiveState:
assert ".skin-pick-btn.active" in STYLE_CSS, (
"style.css must have a .skin-pick-btn.active rule"
)
# The active rule must use !important to beat the base !important rule
idx = STYLE_CSS.find(".theme-pick-btn.active")
rule = STYLE_CSS[idx:idx + 200]
# The active rule must use !important to beat the base !important rule.
# Search for the GLOBAL #mainSettings-scoped rule (skin-specific overrides
# like `:root[data-skin="geist-contrast"] .theme-pick-btn.active` are
# allowed to use their own values and would otherwise be the first match).
idx = STYLE_CSS.find("#mainSettings .theme-pick-btn.active")
assert idx != -1, "Could not find global #mainSettings .theme-pick-btn.active rule"
rule = STYLE_CSS[idx:idx + 300]
assert "!important" in rule, (
".theme-pick-btn.active must use !important to override "
"#mainSettings .theme-pick-btn.active must use !important to override "
"the base border-color:var(--border)!important rule"
)
def test_active_rule_uses_accent_color(self):
"""The .active rule must apply the accent color to make selection visible."""
idx = STYLE_CSS.find(".theme-pick-btn.active")
rule = STYLE_CSS[idx:idx + 200]
# Use the global #mainSettings-scoped rule (skin-specific overrides like
# `:root[data-skin="geist-contrast"] .theme-pick-btn.active` use their
# own palette values and would otherwise be the first match).
idx = STYLE_CSS.find("#mainSettings .theme-pick-btn.active")
assert idx != -1, "Could not find global #mainSettings .theme-pick-btn.active rule"
rule = STYLE_CSS[idx:idx + 300]
assert "var(--accent)" in rule, (
".theme-pick-btn.active must set border-color to var(--accent)"
"#mainSettings .theme-pick-btn.active must set border-color to var(--accent)"
)
+9
View File
@@ -59,3 +59,12 @@ def test_dashboard_settings_controls_live_in_system_panel():
assert 'id="settingsDashboardUrl"' in INDEX_HTML
assert "function saveDashboardSettings" in UI_JS
assert "api('/api/dashboard/config'" in UI_JS
def test_dashboard_frontend_uses_browser_url_without_requiring_probe_port():
match = re.search(r"function _dashboardBrowserUrl\(status\).*?\n}\nfunction _applyDashboardStatus", UI_JS, re.DOTALL)
assert match is not None
helper = match.group(0)
assert "status.browser_url||status.url" in helper
assert "!status.port" in helper
assert helper.index("status.browser_url||status.url") < helper.index("!status.port")
+20 -11
View File
@@ -132,11 +132,11 @@ def test_status_tries_default_loopback_targets_until_dashboard_found(monkeypatch
assert attempts == [("127.0.0.1", 9119, 0.5, "http"), ("localhost", 9119, 0.5, "http")]
def test_status_honors_never_and_strict_override(monkeypatch):
def test_status_honors_never_and_external_browser_link_without_probe(monkeypatch):
from api import dashboard_probe
def fail_probe(*args, **kwargs):
raise AssertionError("disabled dashboard must not probe")
raise AssertionError("this status path must not probe a dashboard")
monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fail_probe)
assert dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"enabled": "never"}}}) == {
@@ -144,9 +144,13 @@ def test_status_honors_never_and_strict_override(monkeypatch):
"enabled": "never",
}
result = dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"url": "http://example.com:9119"}}})
assert result["running"] is False
assert "invalid" in result["error"]
result = dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"enabled": "always", "url": "https://dashboard.example.test"}}})
assert result == {
"running": True,
"enabled": "always",
"url": "https://dashboard.example.test",
"browser_url": "https://dashboard.example.test",
}
@@ -203,9 +207,14 @@ def test_dashboard_config_roundtrip_writes_profile_config_yaml(tmp_path, monkeyp
assert saved == {"enabled": "auto", "url": "http://127.0.0.1:19119"}
assert "dashboard:" in (tmp_path / "config.yaml").read_text(encoding="utf-8")
try:
save_dashboard_config({"enabled": "auto", "url": "http://example.com:9119"})
except ValueError:
pass
else:
raise AssertionError("external dashboard URL override must be rejected")
saved = save_dashboard_config({"enabled": "always", "url": "https://dashboard.example.test"})
assert saved == {"enabled": "always", "url": "https://dashboard.example.test"}
assert get_dashboard_config() == {"enabled": "always", "url": "https://dashboard.example.test"}
for unsafe_url in ("https://example.com/path", "https://user:pass@example.com", "javascript:alert(1)"):
try:
save_dashboard_config({"enabled": "auto", "url": unsafe_url})
except ValueError:
pass
else:
raise AssertionError(f"unsafe dashboard URL must be rejected: {unsafe_url}")
+54
View File
@@ -0,0 +1,54 @@
"""Geist Contrast skin registration and contrast affordances."""
from pathlib import Path
REPO = Path(__file__).parent.parent
CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8")
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
COMMANDS_JS = (REPO / "static" / "commands.js").read_text(encoding="utf-8")
def test_geist_contrast_skin_is_registered_with_matching_key_and_label():
assert "{name:'Geist Contrast'" in BOOT_JS
assert "value:'geist-contrast'" in BOOT_JS
assert "s.value||s.name" in BOOT_JS
assert "'geist-contrast':1" in INDEX_HTML
assert '"geist-contrast"' in CONFIG_PY
def test_geist_contrast_slash_theme_uses_skin_value_key():
assert "const skins=(_SKINS||[]).map(s=>(s.value||s.name).toLowerCase());" in COMMANDS_JS
assert "skins.includes(val)" in COMMANDS_JS
assert "showToast(t('theme_set')+appearance.skin)" in COMMANDS_JS
def test_geist_contrast_dark_tokens_use_yellow_accent_with_neutral_surfaces():
assert ':root.dark[data-skin="geist-contrast"]' in CSS
assert "--bg:#000000" in CSS
assert "--surface:#0a0a0a" in CSS
assert "--text:#ededed" in CSS
assert "--accent:#FFF175" in CSS
assert "--accent-text:#f5e65f" in CSS
def test_geist_contrast_selection_is_neutral_not_solid_yellow():
active_rule = ':root[data-skin="geist-contrast"] .session-item.active{position:relative;border:1px solid var(--border2)!important;}'
marker_rule = ':root[data-skin="geist-contrast"] .session-item.active::before{content:"";position:absolute;left:6px;top:10px;bottom:10px;width:2px;border-radius:999px;background:var(--accent);}'
dark_text_rule = ':root.dark[data-skin="geist-contrast"] .session-item.active,\n :root.dark[data-skin="geist-contrast"] .session-item.active *{color:var(--text)!important;}'
assert active_rule in CSS
assert marker_rule in CSS
assert dark_text_rule in CSS
def test_geist_contrast_solid_accent_controls_use_black_text_in_dark_mode():
assert ':root[data-skin="geist-contrast"] button.send-btn:not(:disabled)' in CSS
assert "color:#050505!important" in CSS
assert ':root[data-skin="geist-contrast"] button.send-btn:disabled{background:var(--surface-subtle)!important;border-color:var(--border)!important;color:var(--muted)!important;opacity:1!important;}' in CSS
assert ':root.dark[data-skin="geist-contrast"] button.send-btn:disabled svg' in CSS
def test_geist_contrast_composer_chips_are_neutral_until_hovered():
neutral_rule = ':root[data-skin="geist-contrast"] .profile-chip,\n :root[data-skin="geist-contrast"] .model-chip,\n :root[data-skin="geist-contrast"] .reasoning-chip,\n :root[data-skin="geist-contrast"] .composer-workspace-chip,\n :root[data-skin="geist-contrast"] .composer-profile-chip,\n :root[data-skin="geist-contrast"] .composer-model-chip{color:var(--muted)!important;border-color:transparent!important;background:transparent!important;}'
assert neutral_rule in CSS
+1 -1
View File
@@ -29,7 +29,7 @@ def test_theme_command_help_mentions_current_theme_and_skin_values():
"""Every /theme help string should describe the current Theme × Skin contract."""
required_fragments = (
"system/dark/light",
"default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous",
"default/ares/mono/slate/poseidon/sisyphus/charizard/sienna/catppuccin/nous/geist-contrast",
)
for locale in ("en", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "fr"):
value = _literal_value(_locale_block(locale), "cmd_theme")
@@ -49,6 +49,23 @@ def test_messaging_merge_helper_matches_session_get_coordinate_space():
]
def test_messaging_merge_helper_dedupes_equivalent_timestamp_formats():
session = SimpleNamespace(
messages=[
{"role": "user", "content": "hi", "timestamp": "10.0"},
{"role": "assistant", "content": "same answer", "timestamp": "11.000000"},
]
)
cli_messages = [
{"role": "user", "content": "hi", "timestamp": 10},
{"role": "assistant", "content": "same answer", "timestamp": 11},
]
merged = routes._merged_session_messages_for_display(session, cli_messages)
assert [m["content"] for m in merged] == ["hi", "same answer"]
def test_branch_handler_uses_merged_messaging_messages_for_keep_count():
branch_idx = ROUTES_PY.index('parsed.path == "/api/session/branch":')
block = ROUTES_PY[branch_idx : branch_idx + 2600]
+73
View File
@@ -0,0 +1,73 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _read(relpath: str) -> str:
return (ROOT / relpath).read_text(encoding="utf-8")
def _function_block(src: str, name: str) -> str:
start = src.find(f"def {name}")
assert start != -1, f"{name} not found"
next_def = src.find("\n def ", start + 1)
assert next_def != -1, f"end of {name} not found"
return src[start:next_def]
def test_tool_start_callback_emits_existing_tool_sse_event_with_tool_id():
src = _read("api/streaming.py")
block = _function_block(src, "on_tool_start")
assert "put('tool'" in block, (
"The dedicated Hermes Agent tool_start_callback must emit the existing "
"tool SSE event; otherwise WebUI stays visually silent while tools run."
)
assert "'event_type': 'tool.started'" in block
assert "'tid': tool_call_id" in block, (
"Live frontend cards need the tool_call_id so tool_complete can update "
"the running card in place."
)
assert "_live_tool_event_start_ids" in block, (
"Tool start SSE emission should be idempotent per callback id."
)
assert "STREAM_LIVE_TOOL_CALLS" in block and "'done': False" in block
def test_tool_complete_callback_emits_existing_tool_complete_sse_event_with_tool_id():
src = _read("api/streaming.py")
block = _function_block(src, "on_tool_complete")
assert "put('tool_complete'" in block, (
"The dedicated Hermes Agent tool_complete_callback must emit the existing "
"tool_complete SSE event so the frontend can settle the running tool card."
)
assert "'event_type': 'tool.completed'" in block
assert "'tid': tool_call_id" in block
assert "_live_tool_event_complete_ids" in block, (
"Tool completion SSE emission should be idempotent per callback id."
)
assert "result_snippet = _tool_result_snippet(function_result)" in block
assert "_checkpoint_activity[0] += 1" in block
def test_legacy_progress_events_are_suppressed_when_structured_callbacks_are_wired():
src = _read("api/streaming.py")
block = _function_block(src, "on_tool")
assert "event_type in (None, 'tool.started') and 'tool_start_callback' in _agent_params" in block
assert "event_type == 'tool.completed' and 'tool_complete_callback' in _agent_params" in block
assert block.index("'tool_start_callback' in _agent_params") < block.index("put('tool'")
assert block.index("'tool_complete_callback' in _agent_params") < block.index("put('tool_complete'")
def test_tool_callback_events_keep_existing_frontend_event_contract():
messages = _read("static/messages.js")
ui = _read("static/ui.js")
assert "source.addEventListener('tool',e=>{" in messages
assert "source.addEventListener('tool_complete',e=>{" in messages
assert "tid:d.tid" in messages
assert "data-live-tid" in ui
assert "existing.replaceWith(replacement)" in ui
+8 -2
View File
@@ -41,14 +41,20 @@ class TestActiveSessionTitleThemeColor(unittest.TestCase):
"""
.session-item.active .session-title must use var(--gold) not a hardcoded hex.
The light-mode override line (:not(.dark)) is allowed to keep its own
hardcoded color; we only check the base/dark rule.
hardcoded color; we only check the base/dark rule. Skin-specific
overrides (e.g. `:root[data-skin="..."]`) are also allowed to use
their own palette values — they bind to a different selector scope.
"""
# Find all lines that match the active session title selector
# Find all lines that match the active session title selector. Exclude
# the :not(.dark) light-mode override and skin-specific overrides like
# `:root[data-skin="geist-contrast"] .session-item.active .session-title`
# which legitimately use their own palette values.
lines = STYLE_CSS.splitlines()
base_rule_lines = [
line for line in lines
if ".session-item.active .session-title" in line
and ':not(.dark)' not in line
and ':root[data-skin=' not in line
]
self.assertTrue(
+51
View File
@@ -0,0 +1,51 @@
from api.config import STREAMS, STREAMS_LOCK, create_stream_channel
from api.routes import _stream_runtime_diagnostics
def test_stream_channel_exposes_buffer_and_subscriber_counts():
channel = create_stream_channel()
channel.put_nowait(("token", {"text": "offline"}))
assert channel.diagnostic_snapshot() == {
"subscriber_count": 0,
"offline_buffered_events": 1,
}
subscriber = channel.subscribe()
try:
snapshot = channel.diagnostic_snapshot()
assert snapshot["subscriber_count"] == 1
assert snapshot["offline_buffered_events"] == 1
assert subscriber.get_nowait()[0] == "token"
finally:
channel.unsubscribe(subscriber)
def test_stream_runtime_diagnostics_summarizes_active_stream_channels():
channel = create_stream_channel()
channel.put_nowait(("token", {"text": "offline"}))
subscriber = channel.subscribe()
try:
with STREAMS_LOCK:
previous = dict(STREAMS)
STREAMS.clear()
STREAMS["stream-one"] = channel
try:
payload = _stream_runtime_diagnostics()
finally:
with STREAMS_LOCK:
STREAMS.clear()
STREAMS.update(previous)
assert payload["active_streams"] == 1
assert payload["total_subscribers"] == 1
assert payload["total_offline_buffered_events"] == 1
assert payload["streams"] == [
{
"stream_id": "stream-one",
"subscriber_count": 1,
"offline_buffered_events": 1,
}
]
finally:
channel.unsubscribe(subscriber)