Merge pull request #2091 from nesquena/stage-338
Some checks failed
Release & Docker / release (push) Has been cancelled

Release U — v0.51.45 (9-PR contributor batch — themes docs + skill cache + lineage forks + spinner + slug + recovery polish + compression anchor)
This commit is contained in:
nesquena-hermes
2026-05-11 10:33:46 -07:00
committed by GitHub
28 changed files with 928 additions and 295 deletions

13
.gitignore vendored
View File

@@ -36,15 +36,20 @@ api/_version.py
.DS_Store
Thumbs.db
# Local reference clones — never committed (except tracked design/UI-UX reference pages)
# Local reference clones/artifacts — never committed by default.
# Markdown docs at docs/*.md are intentionally trackable for contributor docs.
docs/*
!docs/*.md
!docs/ui-ux/
!docs/ui-ux/**
!docs/rfcs/
!docs/rfcs/**
!docs/docker.md
!docs/supervisor.md
!docs/troubleshooting.md
# Local-only AI assistant context — never committed even under docs/.
docs/AGENTS.md
docs/CLAUDE.md
docs/.cursorrules
docs/.windsurfrules
# Local-only PR review harness: rendering drivers, sample bank, fixtures.
# Used by Claude during deep reviews; never shared in the repo.

View File

@@ -2,6 +2,43 @@
## [Unreleased]
## [v0.51.45] — 2026-05-11 — Release U (9-PR contributor batch — themes docs + gitignore policy + kanban parity + skill cache patching + fork lineage + sidebar spinner + custom provider slug + session recovery polish + compression anchor refactor)
### Added
- **PR #2074** by @franksong2702`_patch_skill_home_modules(home)` centralizes patching of both `tools.skills_tool` and `tools.skill_manager_tool` module-level skill paths so process-wide HERMES_HOME switches and per-request streaming switches stay aligned. Closes #2023. Closes the cleanup gap from the original #2023 fix where the streaming per-request path patched both modules but the process-wide switch path only patched `tools.skills_tool`. Preserves the no-import-under-`_ENV_LOCK` invariant from #2024.
- **PR #2077** by @franksong2702 — Compression anchor visibility helpers collapsed into a single shared module `api/compression_anchor.py` (new file, 77 lines) so the manual `/api/session/compress` path in `api/routes.py` and the streaming auto-compression path in `api/streaming.py` share one canonical implementation. Net effect: 48 lines removed from `routes.py`, 41 from `streaming.py`, plus 59-line regression suite. Closes #2028.
### Fixed
- **PR #2068** by @franksong2702 — Stuck sidebar spinners on completed sessions (closes #2066). `_isSessionLocallyStreaming()` no longer consults `INFLIGHT` for non-active sessions — INFLIGHT entries for non-active sessions are always artifacts and never affect spinner state. Added `_purgeStaleInflightEntries()` cleanup pass and 71-line regression file `tests/test_issue2066_stale_sidebar_spinner.py` covering the abnormal-termination cases (page refresh / network drop / gateway restart) that the symptomatic 5-minute-staleness alternative would have left broken.
- **PR #2056** by @franksong2702 — Custom provider name slugs no longer preserve slug-hostile punctuation (closes #2047). Friendly setup names like `Local (127.0.0.1:15721)` now become `custom:local-127.0.0.1-15721` instead of `custom:local-(127.0.0.1:15721)`. The latter shape collided with the `@provider:model` grammar and could corrupt the model into `15721):deepseek-v4-flash`. Endpoint-derived `custom:<host>:<port>` slugs continue to flow through the host-port parser unchanged. `_custom_provider_slug_from_name()` is now reused by both model resolution and available-model lookup instead of duplicating `lower().replace(" ", "-")`.
- **PR #2065** by @franksong2702 — Four low-severity polish items from the v0.51.42 Opus pre-release review (closes #2050). (1) `state.db` rows with `source='webui'` but zero readable messages now emit `state_db_orphan_webui_row` / `unsafe_to_repair` / `manual_review` instead of being silently dropped. (2) `repair_safe_session_recovery()` returns an explicit `clean` flag preserving `ok` for compatibility; `/api/session/recovery/repair-safe` 200/409 dispatch keys off `clean`, so a 409 now means "audit still has findings" rather than "repair code failed." (3) `MEDIA_ALLOWED_ROOTS` splits on `os.pathsep` (POSIX `:` / Windows `;`) instead of a hard-coded colon. (4) Replaced the confusing `details[-1:]` one-element slice with an explicit local detail-recorded flag.
- **PR #2063** by @dso2ng — Explicit `session_source="fork"` sessions are kept out of `read_session_lineage_report()` continuation chains. The query now fetches optional `session_source` so the existing continuation helper can see fork metadata; pre-fix the backend read-only lineage report bridge added in #2012 contradicted the sidebar collapse logic taught in #2014 (where forks are explicit branches, not compression continuations). Regression covers a fork child whose parent ended via compression.
### Refactored
- **PR #2077** (cross-listed) — see Added.
### Documentation
- **PR #2088** by @michael-dg — `THEMES.md` re-aligned with the post-#627 `Theme × Skin` architecture. The old monolithic palette names (`Dark`, `Light`, `Slate`, `Solarized Dark`, `Monokai`, `Nord`, `OLED`) no longer match the actual two-picker model (Theme `System` / `Dark` / `Light` applied as `.dark` class on `<html>`, plus Skin — 8 named accent palettes — applied as `data-skin="<name>"`). The Settings → Appearance panel exposes both pickers plus Font Size, and `/theme <name>` accepts theme + skin tokens.
- **PR #2073** by @ai-ag2026 — Top-level Markdown docs (`docs/*.md`) are now tracked instead of silently ignored by the broad `docs/*` rule. Arbitrary scratch/reference files under `docs/` (non-`.md`) remain ignored by default. Regression tests cover the intended `git check-ignore` behavior on both paths.
### Tests
- **PR #2076** by @franksong2702`test_kanban_locale_parity` (added to `tests/test_kanban_ui_static.py`) catches missing-key regressions across ~86 `kanban_*` i18n keys × 9 locales (en, ja, ru, es, de, zh, zh-Hant, pt, ko). Follows the existing `test_lineage_segment_locale_keys_are_defined_for_sidebar_locales` pattern. Issue #1973 flagged that this regression class was previously caught only by manual review during the Opus pre-ship audit.
### Stage-338 maintainer review (Opus advisor)
- **`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

251
THEMES.md
View File

@@ -1,19 +1,31 @@
# Hermes Web UI — Themes
Hermes Web UI supports pluggable color themes. Seven themes ship built-in, and
you can create your own with pure CSS — no Python changes needed.
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: eight named skins ship built-in. 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
without losing your favorite accent — pure CSS, no Python changes needed.
---
## Switching Themes
## Switching Appearance
**Settings panel:** Click the gear icon, select a theme from the dropdown. The
preview is instant — the UI updates as you click through options.
**Settings panel:** Click the gear icon**Appearance**. The **Theme** card
toggles Light/Dark/System; the **Skin** grid offers eight accent palettes.
Preview is instant — the UI updates as you click.
**Slash command:** Type `/theme dark` or `/theme light` in the composer.
**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`). It updates the
matching axis and leaves the other one alone.
**Themes persist** across page reloads and server restarts (stored in
`settings.json` server-side, with `localStorage` for flicker-free loading).
**Persistence:** Both choices are stored in `localStorage` for flicker-free
loading, and saved server-side via `POST /api/settings` (under `theme` and
`skin` keys in `settings.json`).
---
@@ -21,125 +33,134 @@ preview is instant — the UI updates as you click through options.
| Theme | Description |
|-------|-------------|
| **Dark** (default) | Deep navy/indigo with muted blue accents. Easy on the eyes for long sessions. |
| **Light** | Warm off-white with dark text. High contrast for bright environments. |
| **Slate** | Warm charcoal, lighter than Dark. Easier on the eyes for extended use. |
| **Solarized Dark** | Ethan Schoonover's classic dark palette. Teal background, warm accents. |
| **Monokai** | Warm dark theme inspired by the Monokai editor scheme. Green/pink accents. |
| **Nord** | Arctic blue-gray palette from the Nord color system. Calm and minimal. |
| **OLED** | True black (#000) backgrounds for OLED displays. Minimizes glow and burn-in risk. |
| **Custom themes** | Any string accepted by `settings.json`, `POST /api/settings`, and `/theme` if added to the picker/command list. Pure CSS variables only. |
| **System** (default) | Follows the OS `prefers-color-scheme` preference and updates live. |
| **Dark** | Deep dark surfaces, low-glare for long sessions. |
| **Light** | Bright surfaces with dark text, high contrast for daylight environments. |
The theme is applied as a class on `<html>`: `.dark` is present for dark mode,
absent for light. System mode tracks the OS preference at runtime.
---
## Built-in Skins
| Skin | Description |
|------|-------------|
| **Default** | The original Hermes gold accent. Warm and understated. |
| **Ares** | Fiery red. High-energy and assertive. |
| **Mono** | Neutral gray. Distraction-free, for deep focus. |
| **Slate** | Slate blue-gray. Subtle and grown-up. |
| **Poseidon** | Ocean blue. Calm and focused for long sessions. |
| **Sisyphus** | Vivid purple. Distinctive without being loud. |
| **Charizard** | Warm orange. Energetic and easy on the eyes. |
| **Sienna** | Warm clay and sand earth palette. Soft and natural. |
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
skin clears the attribute).
---
## Creating a Custom Skin
A skin is a small CSS block that overrides the accent variables for both the
light and dark variants:
```css
/* Light variant */
:root[data-skin="my-skin"] {
--accent: #2E7D32; /* Active states, links, primary buttons */
--accent-hover: #1B5E20; /* Hover */
--accent-bg: rgba(46,125,50,0.08); /* Soft tinted backgrounds */
--accent-bg-strong: rgba(46,125,50,0.15); /* Highlighted backgrounds */
--accent-text: #1B5E20; /* Text on accent bg */
}
/* Dark variant — usually lighter or more saturated for contrast */
:root.dark[data-skin="my-skin"] {
--accent: #66BB6A;
--accent-hover: #43A047;
--accent-bg: rgba(102,187,106,0.08);
--accent-bg-strong: rgba(102,187,106,0.15);
--accent-text: #66BB6A;
}
```
Two ways to ship it:
1. **In the repo (built-in):** add the block to `static/style.css`, register it
in the Settings skin picker (`static/index.html`) and in the `/theme` command
list (`static/commands.js`), then open a PR.
2. **Self-hosted (no fork):** use the WebUI extensions surface — see
`docs/EXTENSIONS.md`. Drop your CSS in `HERMES_WEBUI_EXTENSION_DIR` and
declare it in `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS`. No code changes
needed; the skin attribute can be set from your own JS.
### Tips
- **Test both themes.** A skin that pops on Dark can be illegible on Light.
Always check `:root[data-skin]` (light) *and* `:root.dark[data-skin]` (dark).
- **Pick contrasting `--accent-text` on `--accent-bg`.** The strong variant
appears behind small labels and chips; weak contrast there reads as blur.
- **The logo gradient uses `--accent` automatically**, so it adapts to your
skin without any extra work.
- **No server changes needed.** The `skin` setting in `settings.json` accepts
any string, so your custom skin name persists without code changes once you
load the CSS.
---
## Creating a Custom Theme
A theme is a CSS block that overrides the color variables. Add it to
`static/style.css` (or a separate file that you link after the main stylesheet).
A full custom *theme* (a different overall mood, not just an accent change) is
a larger task than a skin: it has to redefine the core palette variables
(`--bg`, `--surface`, `--text`, `--border`, `--code-bg`, and friends) for one
or both modes. The contract is defined in the top `:root` and `:root.dark`
blocks of `static/style.css` — start there.
### Step 1: Define your theme block
Every color in the UI comes from these CSS variables:
```css
:root[data-theme="your-theme-name"] {
/* Core palette */
--bg: #1a1a2e; /* Main background */
--sidebar: #16213e; /* Sidebar background */
--border: rgba(255,255,255,0.08); /* Subtle borders */
--border2: rgba(255,255,255,0.14); /* Stronger borders */
--text: #e8e8f0; /* Primary text color */
--muted: #8888aa; /* Secondary/muted text */
--accent: #e94560; /* Accent color (errors, warnings, delete) */
--blue: #7cb9ff; /* Primary action color (links, active states) */
--gold: #c9a84c; /* Secondary accent (pinned items, gold highlights) */
--code-bg: #0d1117; /* Code block background */
/* Surface and chrome (required for full theme polish) */
--surface: #1a2535; /* Dropdowns, popups, toast, approval card */
--topbar-bg: rgba(22,33,62,.98); /* Topbar background */
--main-bg: rgba(26,26,46,0.5); /* Main chat area background */
--input-bg: rgba(255,255,255,.04); /* Input/button subtle backgrounds */
--hover-bg: rgba(255,255,255,.06); /* Hover state backgrounds */
--focus-ring: rgba(124,185,255,.35); /* Focus border color */
--focus-glow: rgba(124,185,255,.08); /* Focus box-shadow glow */
/* Typography (required for readable text across themes) */
--strong: #fff; /* Bold text in messages */
--em: #c9c9e8; /* Italic text in messages */
--code-text: #f0c27f; /* Inline code text color */
--code-inline-bg: rgba(0,0,0,.35); /* Inline code background */
--pre-text: #e2e8f0; /* Code block text color */
}
```
The **core palette** controls the overall mood. The **surface/chrome** and
**typography** variables are part of the standard theme contract — define all
of them for a complete theme.
For **light themes**, you also need `:root[data-theme="name"]` overrides
for elements that use `rgba(255,255,255,.XX)` hover/border effects (these
are invisible on light backgrounds). See the built-in light theme for the
full pattern — it overrides ~45 selectors for proper dark-on-light contrast
on hover states, borders, chips, role labels, session items, and
interactive elements.
### Step 2: Add it to the theme picker (optional)
To make your theme appear in the Settings dropdown, add an `<option>` to the
theme `<select>` in `static/index.html`:
```html
<option value="your-theme-name">Your Theme Name</option>
```
And update the `/theme` command's valid theme list in `static/commands.js`.
### Step 3: Test it
Switch to your theme via `/theme your-theme-name` or the Settings panel.
Check these areas:
- Sidebar session list (hover states, active state, project borders)
- Message bubbles (user vs assistant styling)
- Code blocks (background contrast, copy button visibility)
- Tool cards (running indicator, expand/collapse)
- Settings panel and login page
- Mobile layout (hamburger sidebar, bottom nav)
### Tips
- **Light themes** need scrollbar and selection overrides, plus the full
text/code set (`--strong`, `--em`, `--code-text`, `--code-inline-bg`,
`--pre-text`) or they will look broken.
- The **logo gradient** uses `--accent` automatically, so it adapts to your
theme without extra work.
- **Prism.js syntax highlighting** uses its own CDN stylesheet (Tomorrow theme).
It works well on dark themes; on light themes the contrast is acceptable but
not perfect. Custom Prism theme support is planned for a future update.
- **No server changes needed.** The `theme` setting in `settings.json` accepts
any string — your custom theme name will persist without code changes.
Most of the time, a custom **skin** is what you actually want. Reach for a
custom theme only when the existing Light/Dark modes don't fit (for example,
a high-contrast accessibility theme or an OLED black variant).
---
## How Themes Work Internally
## Font Size
1. Each theme is a `:root[data-theme="name"]` CSS block that overrides variables.
2. Switching themes sets `document.documentElement.dataset.theme = name` in JS.
3. A tiny inline `<script>` in `<head>` reads `localStorage` before the
stylesheet loads — this prevents a flash of the wrong theme on page load.
4. The theme preference is saved server-side via `POST /api/settings` and
loaded on boot via `GET /api/settings`.
5. The `/theme` command and Settings dropdown both update the DOM, localStorage,
and server settings simultaneously.
Right under Theme/Skin in **Settings → Appearance**: `Small`, `Default`,
`Large`. Applied as `data-font-size` on `<html>` and scales the WebUI's root
font size. Persists alongside theme and skin.
---
## Contributing a Theme
## How It Works Internally
To contribute a new built-in theme:
1. **Theme:** `document.documentElement.classList.toggle('dark', isDark)`
light mode removes the class. System mode tracks
`matchMedia('(prefers-color-scheme: dark)')`.
2. **Skin:** `document.documentElement.dataset.skin = name` (or remove the
attribute for `default`).
3. **Font size:** `document.documentElement.dataset.fontSize = size` (or
remove for `default`).
4. **No flash on load:** a tiny inline `<script>` in `<head>` reads
`localStorage` before the stylesheet does, so the right look is applied
before paint.
5. **Server sync:** preferences are saved via `POST /api/settings` and
rehydrated on boot via `GET /api/settings`.
1. Add your `:root[data-theme="name"]` block to `static/style.css`
2. Add the `<option>` to the Settings panel in `static/index.html`
3. Add the theme name to the valid list in `cmdTheme()` in `static/commands.js`
4. Test on desktop and mobile
5. Open a PR — themes are pure CSS additions with no backend changes needed
---
## Contributing a Skin
Skins are the easiest extension point — pure CSS, no Python, no JS logic. To
contribute one upstream:
1. Add your `:root[data-skin="name"]` and `:root.dark[data-skin="name"]`
blocks to `static/style.css`.
2. Register it in the Settings skin picker in `static/index.html` and in the
skin list used by `cmdTheme()` in `static/commands.js`.
3. Test on desktop and mobile across both Light and Dark themes.
4. Open a PR — skins are pure CSS additions with no backend changes needed.
For a custom *theme* (overriding the base palette), prefer opening an issue
first to discuss scope, since it touches many selectors.

View File

@@ -199,6 +199,8 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
"""
if not parent or not child:
return False
if str(child.get('session_source') or '').strip().lower() == 'fork':
return False
parent_source = str(parent.get('source') or '').strip().lower()
child_source = str(child.get('source') or '').strip().lower()
if parent_source and child_source and parent_source != child_source:
@@ -379,6 +381,7 @@ def read_importable_agent_session_rows(
return []
parent_expr = _optional_col('parent_session_id', session_cols)
session_source_expr = _optional_col('session_source', session_cols)
ended_expr = _optional_col('ended_at', session_cols)
end_reason_expr = _optional_col('end_reason', session_cols)
user_id_expr = _optional_col('user_id', session_cols)
@@ -408,6 +411,7 @@ def read_importable_agent_session_rows(
f"""
SELECT s.id, s.title, s.model, s.message_count,
s.started_at, s.source,
{session_source_expr},
{user_id_expr},
{chat_id_expr},
{chat_type_expr},
@@ -496,6 +500,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
return _empty_lineage_report(sid)
source_expr = _optional_col('source', session_cols)
session_source_expr = _optional_col('session_source', session_cols)
title_expr = _optional_col('title', session_cols)
started_expr = _optional_col('started_at', session_cols, '0')
ended_expr = _optional_col('ended_at', session_cols)
@@ -509,6 +514,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
f"""
SELECT s.id,
{source_expr},
{session_source_expr},
{title_expr},
{started_expr},
{parent_expr},
@@ -551,6 +557,7 @@ def read_session_lineage_report(db_path: Path, session_id: str | None, max_hops:
f"""
SELECT s.id,
{source_expr},
{session_source_expr},
{title_expr},
{started_expr},
{parent_expr},
@@ -620,6 +627,7 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
session_cols = {row[1] for row in cur.fetchall()}
if 'parent_session_id' not in session_cols or 'end_reason' not in session_cols:
return {}
session_source_expr = _optional_col('session_source', session_cols)
# Scoped fetch via PRIMARY KEY + idx_sessions_parent rather than a
# full table scan. The sessions table grows unbounded over time
# (1000+ rows is normal, 10000+ for power users), and this function
@@ -653,9 +661,9 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st
placeholders = ','.join('?' * len(chunk))
cur.execute(
f"""
SELECT id, source, title, started_at, parent_session_id, ended_at, end_reason
FROM sessions
WHERE id IN ({placeholders})
SELECT s.id, s.source, {session_source_expr}, s.title, s.started_at, s.parent_session_id, s.ended_at, s.end_reason
FROM sessions s
WHERE s.id IN ({placeholders})
""",
chunk,
)

77
api/compression_anchor.py Normal file
View File

@@ -0,0 +1,77 @@
"""
Shared helpers for session compression anchor metadata.
"""
def _content_text(content, *, part_types):
if isinstance(content, list):
return "\n".join(
str(part.get("text") or part.get("content") or "")
for part in content
if isinstance(part, dict) and part.get("type") in part_types
).strip()
return str(content or "").strip()
def _content_has_part_type(content, part_types):
if not isinstance(content, list):
return False
return any(
isinstance(part, dict) and part.get("type") in part_types
for part in content
)
def visible_messages_for_anchor(messages, *, auto_compression: bool = False):
"""Return transcript messages that can anchor compression UI metadata.
Manual compression historically only counted plain ``text`` content parts
for non-assistant messages, while the streaming auto-compression path also
accepted provider-style ``input_text`` / ``output_text`` parts and metadata
markers on any non-tool role. Keep that difference explicit at the call site
instead of carrying two near-identical helper implementations.
"""
out = []
text_part_types = {"text", "input_text", "output_text"} if auto_compression else {"text"}
for message in messages or []:
if not isinstance(message, dict):
continue
role = message.get("role")
if not role or role == "tool":
continue
content = message.get("content", "")
has_attachments = bool(message.get("attachments"))
text = _content_text(content, part_types=text_part_types)
if auto_compression:
has_tool_calls = bool(
isinstance(message.get("tool_calls"), list) and message.get("tool_calls")
)
has_tool_use = _content_has_part_type(content, {"tool_use"})
has_reasoning = bool(message.get("reasoning"))
if not text:
has_reasoning = has_reasoning or _content_has_part_type(
content,
{"thinking", "reasoning"},
)
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(message)
continue
if role == "assistant":
has_tool_calls = bool(
isinstance(message.get("tool_calls"), list) and message.get("tool_calls")
)
has_tool_use = _content_has_part_type(content, {"tool_use"})
has_reasoning = bool(message.get("reasoning")) or _content_has_part_type(
content,
{"thinking", "reasoning"},
)
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(message)
continue
if text or has_attachments:
out.append(message)
return out

View File

@@ -15,6 +15,7 @@ import json
import logging
import os
import queue
import re
import sys
import threading
import time
@@ -747,7 +748,14 @@ def _custom_provider_slug_from_name(name: object) -> str:
return ""
if raw.startswith("custom:"):
return raw
return "custom:" + raw.replace(" ", "-")
# Keep name-derived custom provider slugs out of the @provider:model colon
# grammar. Endpoint-derived slugs may still be custom:<host>:<port>, but a
# friendly name like "Local (127.0.0.1:15721)" should not preserve ':'.
slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
if not slug:
return ""
return "custom:" + slug
def _custom_provider_entries(config_obj: dict | None = None) -> list[dict]:
@@ -1592,7 +1600,7 @@ def resolve_model_provider(model_id: str) -> tuple:
if isinstance(key, str) and key.strip()
)
if entry_name and model_id in entry_model_ids:
provider_hint = 'custom:' + entry_name.lower().replace(' ', '-')
provider_hint = _custom_provider_slug_from_name(entry_name)
return model_id, provider_hint, entry_base_url or None
# @provider:model format — explicit provider hint from the dropdown.
@@ -2895,7 +2903,7 @@ def get_available_models() -> dict:
continue
entry_name = str(entry.get("name") or "").strip()
if entry_name:
return "custom:" + entry_name.lower().replace(" ", "-")
return _custom_provider_slug_from_name(entry_name)
return "custom"
return ""

View File

@@ -5,14 +5,15 @@ Wraps hermes_cli.profiles to provide profile switching for the web UI.
The web UI maintains a process-level "active profile" that determines which
HERMES_HOME directory is used for config, skills, memory, cron, and API keys.
Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level
cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot
HERMES_HOME at import time.
cached paths in hermes-agent modules (skills_tool, skill_manager_tool,
cron/jobs) that snapshot HERMES_HOME at import time.
"""
import json
import logging
import os
import re
import shutil
import sys
import threading
from pathlib import Path
@@ -37,6 +38,22 @@ _loaded_profile_env_keys: set[str] = set()
# process-global _active_profile.
_tls = threading.local()
_SKILL_HOME_MODULES = ("tools.skills_tool", "tools.skill_manager_tool")
def _patch_skill_home_modules(home: Path) -> None:
"""Patch imported skill modules that cache HERMES_HOME at import time."""
for module_name in _SKILL_HOME_MODULES:
module = sys.modules.get(module_name)
if module is None:
continue
try:
module.HERMES_HOME = home
module.SKILLS_DIR = home / "skills"
except AttributeError:
logger.debug("Failed to patch %s module", module_name)
def _unwrap_profile_home_to_base(home: Path) -> Path:
"""Return the base Hermes home when *home* is already a named profile dir."""
if home.parent.name == 'profiles':
@@ -611,13 +628,7 @@ def _set_hermes_home(home: Path):
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
os.environ['HERMES_HOME'] = str(home)
# Patch skills_tool module-level cache (snapshots HERMES_HOME at import)
try:
import tools.skills_tool as _sk
_sk.HERMES_HOME = home
_sk.SKILLS_DIR = home / 'skills'
except (ImportError, AttributeError):
logger.debug("Failed to patch skills_tool module")
_patch_skill_home_modules(home)
# Patch cron/jobs module-level cache
try:

View File

@@ -24,6 +24,7 @@ from typing import Any
from api.config import (
_PROVIDER_DISPLAY,
_PROVIDER_MODELS,
_custom_provider_slug_from_name,
_get_label_for_model,
_models_from_live_provider_ids,
_read_live_provider_model_ids,
@@ -36,6 +37,19 @@ from api.config import (
logger = logging.getLogger(__name__)
def _custom_provider_name_matches(provider_id: str, name: object) -> bool:
"""Return True when *provider_id* refers to a named custom provider."""
pid = str(provider_id or "").strip().lower()
raw_name = str(name or "").strip().lower()
if not pid or not raw_name:
return False
slug = _custom_provider_slug_from_name(raw_name)
candidates = {raw_name, f"custom:{raw_name}"}
if slug:
candidates.add(slug)
return pid in candidates
_OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key"
_PROVIDER_QUOTA_TIMEOUT_SECONDS = 3.0
_ACCOUNT_USAGE_SUBPROCESS_TIMEOUT_SECONDS = 35.0
@@ -395,8 +409,7 @@ def _provider_has_key(provider_id: str) -> bool:
if isinstance(custom_providers, list):
for cp in custom_providers:
if isinstance(cp, dict):
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
if _custom_provider_name_matches(provider_id, cp.get("name")):
if str(cp.get("api_key") or "").strip():
return True
return False
@@ -440,8 +453,7 @@ def _get_provider_api_key(provider_id: str) -> str | None:
for cp in custom_providers:
if not isinstance(cp, dict):
continue
cp_name = str(cp.get("name") or "").strip().lower().replace(" ", "-")
if f"custom:{cp_name}" == provider_id or str(cp.get("name", "")).strip().lower() == provider_id:
if _custom_provider_name_matches(provider_id, cp.get("name")):
cp_key = str(cp.get("api_key") or "").strip()
if cp_key.startswith("${") and cp_key.endswith("}"):
return os.getenv(cp_key[2:-1], "").strip() or None
@@ -1033,7 +1045,13 @@ def get_providers() -> dict[str, Any]:
if not isinstance(cp, dict) or not cp.get("name"):
continue
cp_name = str(cp["name"]).strip()
cp_id = f"custom:{cp_name}"
cp_id = _custom_provider_slug_from_name(cp_name)
if not cp_id:
logger.warning(
"Custom provider entry %r produced empty slug; skipping",
cp_name,
)
continue
# Collect models from `models` list or `model` single
cp_models = []
if isinstance(cp.get("models"), list):
@@ -1206,8 +1224,7 @@ def _clean_provider_key_from_config(provider_id: str) -> None:
if isinstance(custom_providers, list):
for cp in custom_providers:
if isinstance(cp, dict):
cp_name = (cp.get("name") or "").strip().lower().replace(" ", "-")
if f"custom:{cp_name}" == provider_id or cp.get("name", "").strip().lower() == provider_id:
if _custom_provider_name_matches(provider_id, cp.get("name")):
if cp.get("api_key"):
del cp["api_key"]
changed = True

View File

@@ -28,6 +28,7 @@ from api.agent_sessions import (
is_cli_session_row_visible,
read_session_lineage_report,
)
from api.compression_anchor import visible_messages_for_anchor
logger = logging.getLogger(__name__)
@@ -3823,7 +3824,7 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == "/api/session/recovery/repair-safe":
from api.session_recovery import repair_safe_session_recovery
result = repair_safe_session_recovery(SESSION_DIR, state_db_path=_active_state_db_path())
return j(handler, result, status=200 if result.get("ok") else 409)
return j(handler, result, status=200 if result.get("clean") else 409)
if parsed.path.startswith("/api/kanban/"):
from api.kanban_bridge import handle_kanban_post
@@ -5607,7 +5608,7 @@ def _handle_media(handler, parsed):
- SVG always served as attachment (XSS risk)
- No path traversal: resolved path must stay within an allowed root
- Additional roots can be added via MEDIA_ALLOWED_ROOTS env var
(colon-separated list of absolute paths)
(os.pathsep-separated list of absolute paths; ":" on POSIX, ";" on Windows)
"""
import os as _os
from api.auth import is_auth_enabled, parse_cookie, verify_session
@@ -5653,10 +5654,10 @@ def _handle_media(handler, parsed):
pass
# Also allow additional roots from MEDIA_ALLOWED_ROOTS env var
# (colon-separated list of absolute paths, e.g. /home/user/models:/home/user/Pictures)
# (os.pathsep-separated list; ":" on POSIX, ";" on Windows).
extra_roots = _os.environ.get("MEDIA_ALLOWED_ROOTS", "").strip()
if extra_roots:
for root in extra_roots.split(":"):
for root in extra_roots.split(_os.pathsep):
root = root.strip()
if root:
try:
@@ -7563,51 +7564,6 @@ def _handle_clarify_respond(handler, body):
def _handle_session_compress(handler, body):
def _visible_messages_for_anchor(messages):
out = []
for m in messages or []:
if not isinstance(m, dict):
continue
role = m.get("role")
if not role or role == "tool":
continue
content = m.get("content", "")
has_attachments = bool(m.get("attachments"))
if role == "assistant":
tool_calls = m.get("tool_calls")
has_tool_calls = isinstance(tool_calls, list) and len(tool_calls) > 0
has_tool_use = False
has_reasoning = bool(m.get("reasoning"))
if isinstance(content, list):
for p in content:
if not isinstance(p, dict):
continue
if p.get("type") == "tool_use":
has_tool_use = True
if p.get("type") in {"thinking", "reasoning"}:
has_reasoning = True
text = "\n".join(
str(p.get("text") or p.get("content") or "")
for p in content
if isinstance(p, dict) and p.get("type") == "text"
).strip()
else:
text = str(content or "").strip()
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(m)
continue
if isinstance(content, list):
text = "\n".join(
str(p.get("text") or p.get("content") or "")
for p in content
if isinstance(p, dict) and p.get("type") == "text"
).strip()
else:
text = str(content or "").strip()
if text or has_attachments:
out.append(m)
return out
def _anchor_message_key(m):
if not isinstance(m, dict):
return None
@@ -7846,7 +7802,7 @@ def _handle_session_compress(handler, body):
s.pending_user_message = None
s.pending_attachments = []
s.pending_started_at = None
visible_after = _visible_messages_for_anchor(compressed)
visible_after = visible_messages_for_anchor(compressed, auto_compression=False)
s.compression_anchor_visible_idx = max(0, len(visible_after) - 1) if visible_after else None
s.compression_anchor_message_key = _anchor_message_key(visible_after[-1]) if visible_after else None
summary_text = None

View File

@@ -177,7 +177,12 @@ def _orphaned_backup_live_paths(
return paths
def _read_state_db_missing_sidecar_rows(session_dir: Path, state_db_path: Path | None) -> list[dict]:
def _read_state_db_missing_sidecar_rows(
session_dir: Path,
state_db_path: Path | None,
*,
include_empty: bool = False,
) -> list[dict]:
"""Return WebUI-origin state.db rows whose JSON sidecar is missing."""
if state_db_path is None or not state_db_path.exists():
return []
@@ -229,9 +234,10 @@ def _read_state_db_missing_sidecar_rows(session_dir: Path, state_db_path: Path |
if msg['timestamp'] is not None:
message['timestamp'] = msg['timestamp']
message_rows.append(message)
if not message_rows:
if not message_rows and not include_empty:
continue
data['messages'] = message_rows
data['_state_db_empty_messages'] = not message_rows
rows.append(data)
return rows
except Exception as exc:
@@ -323,6 +329,7 @@ def recover_missing_sidecars_from_state_db(session_dir: Path, state_db_path: Pat
# Session.save() convention).
tmp_suffix = f".json.reconcile.tmp.{os.getpid()}.{threading.current_thread().ident}"
tmp = target.with_suffix(tmp_suffix)
detail_recorded = False
try:
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8')
except OSError as exc:
@@ -345,6 +352,7 @@ def recover_missing_sidecars_from_state_db(session_dir: Path, state_db_path: Pat
pass
except OSError as exc:
details.append({'session_id': sid, 'materialized': False, 'error': str(exc)})
detail_recorded = True
finally:
try:
tmp.unlink(missing_ok=True)
@@ -353,7 +361,7 @@ def recover_missing_sidecars_from_state_db(session_dir: Path, state_db_path: Pat
if materialized_now:
materialized += 1
details.append({'session_id': sid, 'materialized': True, 'messages': len(payload.get('messages') or [])})
elif not any(d.get('session_id') == sid for d in details[-1:]):
elif not detail_recorded:
details.append({'session_id': sid, 'materialized': False, 'skipped': 'sidecar_appeared_during_reconcile'})
return {'scanned': len(rows), 'materialized': materialized, 'details': details}
@@ -458,8 +466,18 @@ def audit_session_recovery(session_dir: Path, state_db_path: Path | None = None)
_msg_count(session_dir / f"{session_id}.json"), -1,
))
for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path):
for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path, include_empty=True):
sid = str(row.get('id') or '')
if row.get('_state_db_empty_messages'):
items.append(_new_audit_item(
sid,
"state_db_orphan_webui_row",
"unsafe_to_repair",
"manual_review",
-1,
-1,
))
continue
items.append(_new_audit_item(
sid,
"state_db_missing_sidecar",
@@ -506,8 +524,10 @@ def repair_safe_session_recovery(session_dir: Path, state_db_path: Path | None =
after = audit_session_recovery(session_dir, state_db_path=state_db_path)
unsafe_remaining = int((after.get("summary") or {}).get("unsafe_to_repair") or 0)
repairable_remaining = int((after.get("summary") or {}).get("repairable") or 0)
clean = unsafe_remaining == 0 and repairable_remaining == 0
return {
"ok": unsafe_remaining == 0 and repairable_remaining == 0,
"clean": clean,
"ok": clean,
"repaired": int(backup_repair.get("restored") or 0) + int(sidecar_repair.get("materialized") or 0),
"before": before,
"backup_repair": backup_repair,

View File

@@ -33,6 +33,7 @@ from api.config import (
model_with_provider_context,
)
from api.helpers import redact_session_data, _redact_text
from api.compression_anchor import visible_messages_for_anchor
from api.metering import meter
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
@@ -1606,44 +1607,6 @@ def _compression_anchor_message_key(message):
return {'role': role, 'ts': ts, 'text': text, 'attachments': attach_count}
def _visible_messages_for_compression_anchor(messages):
out = []
for m in messages or []:
if not isinstance(m, dict):
continue
role = m.get('role')
if not role or role == 'tool':
continue
content = m.get('content', '')
has_attachments = bool(m.get('attachments'))
has_tool_calls = bool(isinstance(m.get('tool_calls'), list) and m.get('tool_calls'))
has_tool_use = False
has_reasoning = bool(m.get('reasoning'))
if isinstance(content, list):
text = '\n'.join(
str(p.get('text') or p.get('content') or '')
for p in content
if isinstance(p, dict)
and p.get('type') in {'text', 'input_text', 'output_text'}
).strip()
for part in content:
if not isinstance(part, dict):
continue
if part.get('type') == 'tool_use':
has_tool_use = True
if not text:
has_reasoning = has_reasoning or any(
isinstance(part, dict)
and part.get('type') in {'thinking', 'reasoning'}
for part in content
)
else:
text = str(content or '').strip()
if text or has_attachments or has_tool_calls or has_tool_use or has_reasoning:
out.append(m)
return out
def _compression_summary_from_messages(messages):
for m in reversed(messages or []):
if not isinstance(m, dict):
@@ -2256,13 +2219,18 @@ def _run_agent_streaming(
# two concurrent tabs on different profiles don't clobber each other via the
# process-level active-profile global. Falls back gracefully.
try:
from api.profiles import get_hermes_home_for_profile, get_profile_runtime_env
from api.profiles import (
_patch_skill_home_modules,
get_hermes_home_for_profile,
get_profile_runtime_env,
)
_profile_home_path = get_hermes_home_for_profile(getattr(s, 'profile', None))
_profile_home = str(_profile_home_path)
_profile_runtime_env = get_profile_runtime_env(_profile_home_path)
except ImportError:
_profile_home = os.environ.get('HERMES_HOME', '')
_profile_runtime_env = {}
_patch_skill_home_modules = None
# Capture the resolved profile name now, while profile context is
# reliable. Used in the compression migration block to stamp s.profile
@@ -2315,23 +2283,8 @@ def _run_agent_streaming(
# above, so we only do lightweight sys.modules lookups and
# attribute assignments here — no first-time import under
# the lock (#2024).
from pathlib import Path as _P
import sys as _sys
_ph = _P(_profile_home)
_sk = _sys.modules.get('tools.skills_tool')
if _sk is not None:
try:
_sk.HERMES_HOME = _ph
_sk.SKILLS_DIR = _ph / 'skills'
except AttributeError:
pass
_sm = _sys.modules.get('tools.skill_manager_tool')
if _sm is not None:
try:
_sm.HERMES_HOME = _ph
_sm.SKILLS_DIR = _ph / 'skills'
except AttributeError:
pass
if _patch_skill_home_modules is not None:
_patch_skill_home_modules(Path(_profile_home))
# Lock released — agent runs without holding it
# ── MCP Server Discovery (lazy import, idempotent) ──
# MUST run AFTER the HERMES_HOME mutation above — `discover_mcp_tools()`
@@ -3370,7 +3323,7 @@ def _run_agent_streaming(
_compressed = True
# Notify the frontend that compression happened
if _compressed:
visible_after = _visible_messages_for_compression_anchor(s.messages)
visible_after = visible_messages_for_anchor(s.messages, auto_compression=True)
s.compression_anchor_visible_idx = (
max(0, len(visible_after) - 1) if visible_after else None
)

View File

@@ -231,16 +231,38 @@ function _isSessionActivelyViewedForList(sid) {
function _isSessionLocallyStreaming(s) {
if (!s || !s.session_id) return false;
const isActive = S.session && s.session_id === S.session.session_id;
return Boolean(
(isActive && S.busy)
|| (typeof INFLIGHT === 'object' && INFLIGHT && INFLIGHT[s.session_id])
);
// For the active session, rely on S.busy to indicate an ongoing stream.
// INFLIGHT entries for non-active sessions are artifacts of interrupted
// streams (page refresh, network disconnect, gateway restart) where
// `delete INFLIGHT[sid]` was never reached — they should NOT cause the
// sidebar spinner to appear on completed sessions. (#2066)
return isActive && Boolean(S.busy);
}
function _isSessionEffectivelyStreaming(s) {
return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s)));
}
function _purgeStaleInflightEntries() {
// Clean up INFLIGHT entries for sessions the server confirms are NOT
// streaming. This prevents the in-memory cache from growing unbounded
// when streams end abnormally. (#2066)
if (typeof INFLIGHT !== 'object' || !INFLIGHT) return;
const sessionsById = new Map();
if (Array.isArray(_allSessions)) {
for (const s of _allSessions) {
if (s && s.session_id) sessionsById.set(s.session_id, s);
}
}
for (const sid of Object.keys(INFLIGHT)) {
const s = sessionsById.get(sid);
if (s && !s.is_streaming) {
delete INFLIGHT[sid];
if (typeof clearInflightState === 'function') clearInflightState(sid);
}
}
}
function _rememberRenderedStreamingState(s, isStreaming) {
if (!s || !s.session_id || !isStreaming) return;
_sessionStreamingById.set(s.session_id, true);
@@ -2257,6 +2279,10 @@ function renderSessionListFromCache(){
// Don't re-render while user is actively renaming a session (would destroy the input)
if(_renamingSid) return;
closeSessionActionMenu();
// Purge stale INFLIGHT entries for sessions the server confirms are NOT
// streaming. This runs on every list refresh to prevent memory leaks from
// interrupted streams. (#2066)
_purgeStaleInflightEntries();
const q=($('sessionSearch').value||'').toLowerCase();
const activeSidForSidebar=_activeSessionIdForSidebar();
const titleMatches=q?_allSessions.filter(s=>(s.title||'Untitled').toLowerCase().includes(q)):_allSessions;

View File

@@ -202,6 +202,38 @@ class TestCustomProvidersInGetProviders:
finally:
self._restore_cfg(old_cfg, old_mtime)
def test_custom_provider_parenthesized_port_uses_safe_provider_id(self, monkeypatch, tmp_path):
"""Local setup names with ports must expose the same safe id used by routing."""
_install_fake_hermes_cli(monkeypatch)
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
monkeypatch.setenv("LOCAL_PORT_API_KEY", "sk-local-port-test-12345678")
old_cfg, old_mtime = self._setup_cfg([
{
"name": "Local (127.0.0.1:15721)",
"base_url": "http://127.0.0.1:15721/v1",
"api_key": "${LOCAL_PORT_API_KEY}",
"model": "deepseek-v4-flash",
},
])
from api.providers import _get_provider_api_key, _provider_has_key, get_providers
try:
provider_id = "custom:local-127.0.0.1-15721"
result = get_providers()
provider_ids = {p["id"] for p in result["providers"]}
assert provider_id in provider_ids
assert "custom:Local (127.0.0.1:15721)" not in provider_ids
assert "custom:local-(127.0.0.1:15721)" not in provider_ids
local = [p for p in result["providers"] if p["id"] == provider_id][0]
assert local["display_name"] == "Local (127.0.0.1:15721)"
assert local["has_key"] is True
assert _provider_has_key(provider_id) is True
assert _get_provider_api_key(provider_id) == "sk-local-port-test-12345678"
finally:
self._restore_cfg(old_cfg, old_mtime)
def test_custom_provider_no_name_skipped(self, monkeypatch, tmp_path):
"""Malformed custom provider without name should be silently skipped."""
_install_fake_hermes_cli(monkeypatch)

View File

@@ -0,0 +1,33 @@
"""Regression tests for docs/ ignore policy."""
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _git_check_ignore(path: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", "check-ignore", "-q", path],
cwd=ROOT,
capture_output=True,
text=True,
)
def test_new_top_level_markdown_docs_are_trackable():
"""New docs/*.md files should be visible to Git, not silently ignored."""
assert _git_check_ignore("docs/example-new-guide.md").returncode == 1
def test_docs_scratch_files_remain_ignored():
"""The broad docs/* ignore rule should still keep arbitrary scratch files out."""
assert _git_check_ignore("docs/local-scratch.tmp").returncode == 0
def test_local_only_ai_context_files_remain_ignored_under_docs():
"""Local AI assistant context files must stay out of commits under docs/."""
assert _git_check_ignore("docs/AGENTS.md").returncode == 0
assert _git_check_ignore("docs/CLAUDE.md").returncode == 0
assert _git_check_ignore("docs/.cursorrules").returncode == 0
assert _git_check_ignore("docs/.windsurfrules").returncode == 0

View File

@@ -0,0 +1,35 @@
"""Regression coverage for issue #2023.
Process-wide profile switches must keep both skill tool modules pointed at the
active profile home. The modules live in hermes-agent and may not be importable
in this test environment, so the test injects lightweight stand-ins into
``sys.modules``.
"""
import sys
import types
def _skill_module(name, home):
module = types.ModuleType(name)
module.HERMES_HOME = home
module.SKILLS_DIR = home / "skills"
return module
def test_set_hermes_home_patches_both_skill_tool_module_caches(monkeypatch, tmp_path):
from api.profiles import _set_hermes_home
old_home = tmp_path / "old-home"
new_home = tmp_path / "new-home"
skills_tool = _skill_module("tools.skills_tool", old_home)
skill_manager_tool = _skill_module("tools.skill_manager_tool", old_home)
monkeypatch.setitem(sys.modules, "tools.skills_tool", skills_tool)
monkeypatch.setitem(sys.modules, "tools.skill_manager_tool", skill_manager_tool)
_set_hermes_home(new_home)
assert skills_tool.HERMES_HOME == new_home
assert skills_tool.SKILLS_DIR == new_home / "skills"
assert skill_manager_tool.HERMES_HOME == new_home
assert skill_manager_tool.SKILLS_DIR == new_home / "skills"

View File

@@ -7,8 +7,9 @@ holding the lock during them serialises every concurrent session behind
the slowest import.
The fix introduces ``_prewarm_skill_tool_modules()`` which does the
imports *before* the lock is acquired, and the lock body uses only
``sys.modules.get()`` lookups (O(1) dict lookup, no import machinery).
imports *before* the lock is acquired, and the lock body uses a shared
helper that only performs ``sys.modules.get()`` lookups (O(1) dict lookup,
no import machinery).
These tests are AST/source-level because the actual import targets
(``tools.skills_tool``, ``tools.skill_manager_tool``) live in the
@@ -20,6 +21,7 @@ import textwrap
REPO = pathlib.Path(__file__).resolve().parent.parent
STREAMING_PY = REPO / "api" / "streaming.py"
PROFILES_PY = REPO / "api" / "profiles.py"
def _read_streaming() -> str:
@@ -133,25 +135,13 @@ class TestPrewarmHelperExists:
class TestSysModulesLookupInEnvLock:
"""Inside the lock, the code must use ``sys.modules.get()`` instead of
``import`` for the skill-tool modules."""
"""Inside the lock, streaming must use the shared cache patch helper."""
def test_sys_modules_get_used_in_env_lock(self):
def test_shared_skill_home_patch_helper_used_in_env_lock(self):
source = _read_streaming()
bodies = _find_env_lock_with_bodies(source)
assert bodies, "Expected at least one `with _ENV_LOCK:` block"
# Collect all string content within the lock bodies by extracting
# Constant/Str nodes — simpler than full AST string reconstruction.
lock_source_segments: list[str] = []
for body in bodies:
for node in ast.walk(ast.Module(body=body, type_ignores=[])):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
lock_source_segments.append(node.value)
# The lock body should reference sys.modules.get for both modules
lock_text = "\n".join(lock_source_segments)
# More reliable: check the raw source lines inside the lock
lines = source.splitlines()
in_lock = False
lock_lines: list[str] = []
@@ -175,17 +165,38 @@ class TestSysModulesLookupInEnvLock:
lock_lines.append(line)
lock_source = "\n".join(lock_lines)
assert "sys.modules.get" in lock_source, (
"Inside `_ENV_LOCK`, skill-tool modules must be accessed via "
"`sys.modules.get()` instead of `import` (#2024)"
assert "_patch_skill_home_modules" in lock_source, (
"Inside `_ENV_LOCK`, streaming must use the shared skill module "
"cache patch helper instead of duplicating module-specific logic "
"(#2023/#2024)"
)
assert "tools.skills_tool" in lock_source, (
"tools.skills_tool must still be referenced inside `_ENV_LOCK` "
"for attribute patching (HERMES_HOME / SKILLS_DIR)"
def test_shared_helper_uses_sys_modules_get_for_both_skill_modules(self):
source = PROFILES_PY.read_text(encoding="utf-8")
tree = ast.parse(source)
helper = next(
(
node
for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef)
and node.name == "_patch_skill_home_modules"
),
None,
)
assert "tools.skill_manager_tool" in lock_source, (
"tools.skill_manager_tool must still be referenced inside `_ENV_LOCK` "
"for attribute patching (HERMES_HOME / SKILLS_DIR)"
assert helper is not None, "_patch_skill_home_modules() must be defined"
helper_source = ast.get_source_segment(source, helper) or ""
assert "sys.modules.get" in helper_source, (
"_patch_skill_home_modules() must use sys.modules.get(), not import, "
"so env-lock callers do not trigger first-time imports (#2024)"
)
assert "HERMES_HOME" in helper_source
assert "SKILLS_DIR" in helper_source
assert "tools.skills_tool" in source, (
"profiles.py must patch tools.skills_tool module-level caches"
)
assert "tools.skill_manager_tool" in source, (
"profiles.py must patch tools.skill_manager_tool module-level caches"
)
def test_no_import_statement_for_skill_tools_in_lock(self):
@@ -216,4 +227,4 @@ class TestSysModulesLookupInEnvLock:
raise AssertionError(
f"Found `import {mod}` inside `_ENV_LOCK` body — "
f"use sys.modules.get() instead (#2024). Line: {stripped}"
)
)

View File

@@ -0,0 +1,59 @@
"""
Regression coverage for shared compression-anchor visibility helpers (#2028).
"""
from pathlib import Path
from api.compression_anchor import visible_messages_for_anchor
def test_legacy_duplicate_anchor_helpers_are_removed():
routes_src = Path("api/routes.py").read_text(encoding="utf-8")
streaming_src = Path("api/streaming.py").read_text(encoding="utf-8")
assert "def _visible_messages_for_anchor" not in routes_src
assert "def _visible_messages_for_compression_anchor" not in streaming_src
assert "visible_messages_for_anchor(compressed, auto_compression=False)" in routes_src
assert "visible_messages_for_anchor(s.messages, auto_compression=True)" in streaming_src
def test_visible_messages_for_anchor_preserves_manual_text_part_filter():
text_only = {"role": "assistant", "content": [{"type": "text", "text": "Visible"}]}
input_only = {"role": "assistant", "content": [{"type": "input_text", "text": "Model input"}]}
reasoning_only = {"role": "assistant", "content": [{"type": "thinking", "text": "hidden"}]}
tool_use_only = {"role": "assistant", "content": [{"type": "tool_use", "id": "call_1"}]}
tool_message = {"role": "tool", "content": "tool output"}
assert visible_messages_for_anchor(
[text_only, input_only, reasoning_only, tool_use_only, tool_message],
auto_compression=False,
) == [text_only, reasoning_only, tool_use_only]
def test_visible_messages_for_anchor_preserves_auto_compression_text_part_filter():
text_only = {"role": "assistant", "content": [{"type": "text", "text": "Visible"}]}
input_only = {"role": "assistant", "content": [{"type": "input_text", "text": "Model input"}]}
output_only = {"role": "assistant", "content": [{"type": "output_text", "text": "Model output"}]}
reasoning_only = {"role": "assistant", "content": [{"type": "reasoning", "text": "hidden"}]}
tool_message = {"role": "tool", "content": "tool output"}
assert visible_messages_for_anchor(
[text_only, input_only, output_only, reasoning_only, tool_message],
auto_compression=True,
) == [text_only, input_only, output_only, reasoning_only]
def test_visible_messages_for_anchor_keeps_manual_user_messages_simple():
user_tool_metadata = {"role": "user", "content": [], "tool_calls": [{"id": "call_1"}]}
user_attachment = {"role": "user", "content": [], "attachments": [{"name": "screenshot.png"}]}
assistant_tool_metadata = {"role": "assistant", "content": [], "tool_calls": [{"id": "call_2"}]}
assert visible_messages_for_anchor(
[user_tool_metadata, user_attachment, assistant_tool_metadata],
auto_compression=False,
) == [user_attachment, assistant_tool_metadata]
assert visible_messages_for_anchor(
[user_tool_metadata, user_attachment, assistant_tool_metadata],
auto_compression=True,
) == [user_tool_metadata, user_attachment, assistant_tool_metadata]

View File

@@ -0,0 +1,71 @@
"""Regression checks for #2066 stale sidebar spinner state."""
import json
import subprocess
from pathlib import Path
SESSIONS_JS = (Path(__file__).resolve().parent.parent / "static" / "sessions.js").read_text()
def _function_block(name: str, next_name: str) -> str:
start = SESSIONS_JS.find(f"function {name}")
assert start != -1, f"{name} not found in sessions.js"
end = SESSIONS_JS.find(f"function {next_name}", start)
assert end != -1, f"{next_name} not found after {name}"
return SESSIONS_JS[start:end]
def test_local_streaming_only_uses_active_session_busy_state():
block = _function_block("_isSessionLocallyStreaming", "_isSessionEffectivelyStreaming")
assert "const isActive = S.session && s.session_id === S.session.session_id;" in block
assert "return isActive && Boolean(S.busy);" in block
assert "INFLIGHT[s.session_id]" not in block
assert "INFLIGHT && INFLIGHT[s.session_id]" not in block
def test_cache_render_purges_stale_non_streaming_inflight_entries():
purge_block = _function_block("_purgeStaleInflightEntries", "_rememberRenderedStreamingState")
render_block = _function_block("renderSessionListFromCache", "_showProjectPicker")
assert "const sessionsById = new Map();" in purge_block
assert "if (s && s.session_id) sessionsById.set(s.session_id, s);" in purge_block
assert "const s = sessionsById.get(sid);" in purge_block
assert "_allSessionsById" not in purge_block
assert "if (s && !s.is_streaming)" in purge_block
assert "delete INFLIGHT[sid];" in purge_block
assert "clearInflightState(sid);" in purge_block
assert "_purgeStaleInflightEntries();" in render_block
def test_stale_inflight_purge_executes_without_undeclared_session_map():
purge_block = _function_block("_purgeStaleInflightEntries", "_rememberRenderedStreamingState")
script = f"""
let _allSessions = [
{{session_id: 'done-session', is_streaming: false}},
{{session_id: 'running-session', is_streaming: true}}
];
let INFLIGHT = {{
'done-session': true,
'running-session': true,
'unknown-session': true
}};
let cleared = [];
function clearInflightState(sid) {{
cleared.push(sid);
}}
{purge_block}
_purgeStaleInflightEntries();
console.log(JSON.stringify({{inflight: INFLIGHT, cleared}}));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
payload = json.loads(result.stdout)
assert payload == {
"inflight": {
"running-session": True,
"unknown-session": True,
},
"cleared": ["done-session"],
}

View File

@@ -186,8 +186,8 @@ def test_polling_transition_tracks_the_same_effective_streaming_state_as_sidebar
assert render_idx != -1, "_renderOneSession not found"
render_block = SESSIONS_JS[render_idx:SESSIONS_JS.find("const hasUnread=", render_idx)]
assert "(isActive && S.busy)" in local_block
assert "INFLIGHT && INFLIGHT[s.session_id]" in local_block
assert "isActive && Boolean(S.busy)" in local_block
assert "INFLIGHT && INFLIGHT[s.session_id]" not in local_block
assert "s.is_streaming || _isSessionLocallyStreaming(s)" in effective_block
assert "const isStreaming=_isSessionEffectivelyStreaming(s);" in render_block, (
"the row spinner and polling completion transition must use the same "

View File

@@ -129,8 +129,9 @@ def test_sidebar_uses_local_inflight_state_for_immediate_spinner():
messages_js = (Path(__file__).resolve().parent.parent / "static" / "messages.js").read_text()
assert "function _isSessionLocallyStreaming(s)" in SESSIONS_JS
assert "(isActive && S.busy)" in SESSIONS_JS
assert "INFLIGHT[s.session_id]" in SESSIONS_JS
assert "isActive && Boolean(S.busy)" in SESSIONS_JS
assert "function _purgeStaleInflightEntries()" in SESSIONS_JS
assert "delete INFLIGHT[sid];" in SESSIONS_JS
assert "function _isSessionEffectivelyStreaming(s)" in SESSIONS_JS
assert "const isStreaming=_isSessionEffectivelyStreaming(s);" in SESSIONS_JS
assert "if(typeof renderSessionListFromCache==='function') renderSessionListFromCache();" in messages_js

View File

@@ -1062,3 +1062,51 @@ console.log(JSON.stringify(results));
# directly into the style attribute.
assert "_kanbanSafeColor(b.color)" in PANELS
assert "color:${esc(b.color)}" not in PANELS
def test_kanban_locale_parity():
"""Every kanban_* i18n key in the English locale must exist in all
non-English locale blocks. The kanban panel has its own set of ~86
keys (kanban_board, kanban_task, …) that are rendered via t() — a
missing key silently falls back to English, which is acceptable for
content keys but confusing for UI labels the user expects to see
translated.
This test catches regressions where a new kanban key is added to the
English block but not to one or more locale blocks. Pattern borrowed
from test_lineage_segment_locale_keys_are_defined_for_sidebar_locales
in test_session_lineage_collapse.py.
Refs: #1973
"""
locale_blocks = _locale_blocks_with_body(I18N)
assert locale_blocks, "No locale blocks found in i18n.js"
# Collect the kanban_* keys from the English block.
en_name = "en"
en_body = None
for name, body in locale_blocks:
if name == en_name:
en_body = body
break
assert en_body is not None, "English locale block not found"
en_keys = set(re.findall(r"(kanban_\w+)\s*:", en_body))
assert en_keys, "No kanban_* keys found in English locale"
# Verify each non-English locale has the same set.
failures = []
for name, body in locale_blocks:
if name == en_name:
continue
loc_keys = set(re.findall(r"(kanban_\w+)\s*:", body))
missing = en_keys - loc_keys
extra = loc_keys - en_keys
if missing:
failures.append(f"{name}: missing {sorted(missing)}")
if extra:
failures.append(f"{name}: extra {sorted(extra)}")
assert not failures, (
"Kanban i18n key parity violations:\n" + "\n".join(failures)
)

View File

@@ -241,6 +241,14 @@ class TestMediaEndpointUnit(unittest.TestCase):
self.assertIn("MEDIA_ALLOWED_ROOTS", routes_src,
"MEDIA_ALLOWED_ROOTS env var must be parsed in _handle_media")
def test_media_allowed_roots_uses_os_pathsep(self):
"""MEDIA_ALLOWED_ROOTS must use the platform path separator."""
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
start = routes_src.index("extra_roots =")
block = routes_src[start:start + 900]
self.assertIn(".split(_os.pathsep)", block)
self.assertNotIn('.split(":")', block)
def test_media_endpoints_advertise_byte_range_support(self):
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
self.assertIn("Accept-Ranges", routes_src)

View File

@@ -179,6 +179,35 @@ def test_custom_provider_models_dict_routes_to_named_custom_provider():
assert base_url == 'http://127.0.0.1:8080/v1'
# ── Issue #2047: parenthesized local provider names with ports ────────────
def test_custom_provider_name_with_parenthesized_port_uses_safe_slug():
"""Setup-generated names like 'Local (host:port)' must not leak ':' into slugs."""
model, provider, base_url = _resolve_with_config(
'deepseek-v4-flash',
provider='custom',
custom_providers=[{
'name': 'Local (127.0.0.1:15721)',
'base_url': 'http://127.0.0.1:15721/v1',
'model': 'deepseek-v4-flash',
}],
)
assert model == 'deepseek-v4-flash'
assert provider == 'custom:local-127.0.0.1-15721'
assert base_url == 'http://127.0.0.1:15721/v1'
def test_safe_custom_provider_hint_keeps_model_after_port_slug():
"""The safe slug emitted by the picker must parse back without corrupting the model."""
model, provider, base_url = _resolve_with_config(
'@custom:local-127.0.0.1-15721:deepseek-v4-flash',
provider='custom',
)
assert model == 'deepseek-v4-flash'
assert provider == 'custom:local-127.0.0.1-15721'
assert base_url is None
# ── Issue #1922: default model shadowed by overlapping custom_providers[] ──
def test_default_model_not_shadowed_by_overlapping_custom_provider():

View File

@@ -383,6 +383,36 @@ class TestRemoveProviderKey:
assert "api_key" not in active["providers"]["openai"]
assert active["model"] == {"provider": "openai"}
def test_clean_custom_provider_key_matches_safe_name_slug(self, monkeypatch, tmp_path):
"""Custom-provider key removal must match the canonical safe name slug."""
import yaml
import api.config as cfg_mod
import api.providers as providers
config_path = tmp_path / "config.yaml"
config_path.write_text(
yaml.safe_dump({
"custom_providers": [{
"name": "Local (127.0.0.1:15721)",
"base_url": "http://127.0.0.1:15721/v1",
"api_key": "${LOCAL_PORT_API_KEY}",
"model": "deepseek-v4-flash",
}],
}),
encoding="utf-8",
)
monkeypatch.setattr(cfg_mod, "_get_config_path", lambda: config_path)
monkeypatch.setattr(providers, "reload_config", lambda: None)
providers._clean_provider_key_from_config("custom:local-127.0.0.1-15721")
reloaded = yaml.safe_load(config_path.read_text(encoding="utf-8"))
custom_provider = reloaded["custom_providers"][0]
assert custom_provider["name"] == "Local (127.0.0.1:15721)"
assert "api_key" not in custom_provider
def test_remove_provider_key_calls_set_with_none(self, monkeypatch, tmp_path):
"""remove_provider_key should delegate to set_provider_key(id, None)."""
_install_fake_hermes_cli(monkeypatch)

View File

@@ -69,6 +69,29 @@ def test_audit_reports_state_db_row_missing_sidecar(tmp_path):
)
def test_empty_state_db_webui_row_is_unsafe_not_materialized(tmp_path):
sid = _make_state_db(tmp_path / "state.db", sid="empty_state_row", messages=0)
audit = audit_session_recovery(tmp_path, state_db_path=tmp_path / "state.db")
assert any(
item["session_id"] == sid
and item["kind"] == "state_db_orphan_webui_row"
and item["category"] == "unsafe_to_repair"
and item["recommendation"] == "manual_review"
for item in audit["items"]
)
assert not any(
item["session_id"] == sid and item["kind"] == "state_db_missing_sidecar"
for item in audit["items"]
)
result = recover_missing_sidecars_from_state_db(tmp_path, tmp_path / "state.db")
assert result["materialized"] == 0
assert not (tmp_path / f"{sid}.json").exists()
def test_materialized_sidecar_round_trips_through_session_load(tmp_path, monkeypatch):
"""Schema parity guard: a materialized sidecar must be readable by Session.load
and the resulting Session must have the same messages we put in state.db.

View File

@@ -32,6 +32,7 @@ def _ensure_state_db(path):
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
@@ -45,14 +46,14 @@ def _ensure_state_db(path):
return conn
def _insert_state_row(conn, sid, *, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui'):
def _insert_state_row(conn, sid, *, parent=None, ended_at=None, end_reason=None, started_at=None, source='webui', session_source=None):
conn.execute(
"""
INSERT INTO sessions
(id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)
(id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)
""",
(sid, source, sid, started_at or time.time(), parent, ended_at, end_reason),
(sid, source, session_source, sid, started_at or time.time(), parent, ended_at, end_reason),
)
conn.commit()
@@ -99,6 +100,40 @@ def test_all_sessions_exposes_state_db_lineage_metadata_for_webui_json_sessions(
conn.close()
def test_all_sessions_keeps_explicit_forks_out_of_state_db_lineage_metadata(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100
try:
_save_webui_session("lineage_api_root", title="Visible root", updated_at=t0)
_save_webui_session("lineage_api_fork", title="Explicit fork", updated_at=t0 + 10)
_insert_state_row(
conn,
"lineage_api_root",
started_at=t0,
ended_at=t0 + 5,
end_reason="compression",
)
_insert_state_row(
conn,
"lineage_api_fork",
parent="lineage_api_root",
started_at=t0 + 6,
session_source="fork",
)
rows = {row["session_id"]: row for row in all_sessions()}
fork = rows["lineage_api_fork"]
assert fork.get("parent_session_id") == "lineage_api_root"
assert fork.get("relationship_type") == "child_session"
assert fork.get("parent_title") == "lineage_api_root"
assert fork.get("_parent_lineage_root_id") == "lineage_api_root"
assert "_lineage_root_id" not in fork
assert "_compression_segment_count" not in fork
finally:
conn.close()
def test_non_compression_state_db_parent_does_not_create_sidebar_lineage(_isolate):
conn = _ensure_state_db(_isolate)
t0 = time.time() - 100

View File

@@ -18,6 +18,7 @@ def _ensure_state_db(path):
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
session_source TEXT,
title TEXT,
model TEXT,
started_at REAL NOT NULL,
@@ -26,19 +27,34 @@ def _ensure_state_db(path):
ended_at REAL,
end_reason TEXT
);
CREATE TABLE messages (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
timestamp REAL
);
"""
)
return conn
def _insert_state_row(conn, sid, *, parent=None, ended_at=None, end_reason=None, started_at=None, source="webui"):
def _insert_state_row(conn, sid, *, parent=None, ended_at=None, end_reason=None, started_at=None, source="webui", session_source=None):
conn.execute(
"""
INSERT INTO sessions
(id, source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)
(id, source, session_source, title, model, started_at, message_count, parent_session_id, ended_at, end_reason)
VALUES (?, ?, ?, ?, 'openai/gpt-5', ?, 2, ?, ?, ?)
""",
(sid, source, sid.replace("_", " "), started_at or time.time(), parent, ended_at, end_reason),
(sid, source, session_source, sid.replace("_", " "), started_at or time.time(), parent, ended_at, end_reason),
)
conn.commit()
def _insert_message(conn, sid, *, timestamp=None, role="user"):
conn.execute(
"INSERT INTO messages (id, session_id, role, content, timestamp) VALUES (?, ?, ?, 'hello', ?)",
(f"msg_{sid}_{role}", sid, role, timestamp or time.time()),
)
conn.commit()
@@ -104,6 +120,59 @@ def test_lineage_report_keeps_cross_surface_parent_out_of_hidden_segments(tmp_pa
conn.close()
def test_lineage_report_keeps_explicit_forks_out_of_hidden_segments(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
t0 = time.time() - 100
try:
_insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression")
_insert_state_row(
conn,
"lineage_report_fork",
parent="lineage_report_root",
started_at=t0 + 6,
session_source="fork",
)
report = agent_sessions.read_session_lineage_report(tmp_path / "state.db", "lineage_report_fork")
assert report["lineage_key"] == "lineage_report_fork"
assert report["tip_session_id"] == "lineage_report_fork"
assert report["total_segments"] == 1
assert [s["session_id"] for s in report["segments"]] == ["lineage_report_fork"]
assert report["segments"][0]["role"] == "tip"
assert report["children"] == []
assert report["manual_review"] is False
finally:
conn.close()
def test_importable_agent_projection_keeps_explicit_forks_out_of_compression_lineage(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
t0 = time.time() - 100
try:
_insert_state_row(conn, "lineage_report_root", started_at=t0, ended_at=t0 + 5, end_reason="compression")
_insert_state_row(
conn,
"lineage_report_fork",
parent="lineage_report_root",
started_at=t0 + 6,
session_source="fork",
)
_insert_message(conn, "lineage_report_fork", timestamp=t0 + 7)
rows = agent_sessions.read_importable_agent_session_rows(tmp_path / "state.db", exclude_sources=())
assert [row["id"] for row in rows] == ["lineage_report_fork"]
fork = rows[0]
assert fork.get("relationship_type") == "child_session"
assert fork.get("parent_session_id") == "lineage_report_root"
assert fork.get("_parent_lineage_root_id") == "lineage_report_root"
assert "_lineage_root_id" not in fork
assert "_compression_segment_count" not in fork
finally:
conn.close()
def test_lineage_report_surfaces_non_continuation_children_without_mutation(tmp_path):
conn = _ensure_state_db(tmp_path / "state.db")
t0 = time.time() - 100

View File

@@ -27,6 +27,7 @@ def test_repair_safe_session_recovery_restores_backup_and_rebuilds_index(tmp_pat
result = repair_safe_session_recovery(tmp_path)
assert result["clean"] is True
assert result["ok"] is True
assert result["repaired"] == 1
assert live.exists()
@@ -50,12 +51,21 @@ def test_repair_safe_session_recovery_leaves_unsafe_orphan_for_manual_review(tmp
result = repair_safe_session_recovery(tmp_path, state_db_path=db)
assert result["clean"] is False
assert result["ok"] is False
assert result["repaired"] == 0
assert not live.exists()
assert result["after"]["status"] == "needs_manual_review"
def test_repair_safe_route_uses_clean_flag_for_status_code():
from pathlib import Path
src = Path("api/routes.py").read_text(encoding="utf-8")
assert 'status=200 if result.get("clean") else 409' in src
def test_recovery_audit_routes_are_registered():
from pathlib import Path