Merge pull request #2091 from nesquena/stage-338
Some checks failed
Release & Docker / release (push) Has been cancelled
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:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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.
|
||||
|
||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -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
251
THEMES.md
@@ -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.
|
||||
|
||||
@@ -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
77
api/compression_anchor.py
Normal 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
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
tests/test_docs_gitignore_policy.py
Normal file
33
tests/test_docs_gitignore_policy.py
Normal 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
|
||||
35
tests/test_issue2023_hermes_home_skill_modules.py
Normal file
35
tests/test_issue2023_hermes_home_skill_modules.py
Normal 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"
|
||||
@@ -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}"
|
||||
)
|
||||
)
|
||||
|
||||
59
tests/test_issue2028_compression_anchor_helpers.py
Normal file
59
tests/test_issue2028_compression_anchor_helpers.py
Normal 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]
|
||||
71
tests/test_issue2066_stale_sidebar_spinner.py
Normal file
71
tests/test_issue2066_stale_sidebar_spinner.py
Normal 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"],
|
||||
}
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user