Merge pull request #7741 from nesquena/stage/rm-343-batch

Release: #7631 sidebar window setting (@carlotestor), #7716 hidden-tab 404 poll (@happy5318), #7734 OpenRouter GLM default (@MuhammadUsamaMX)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-09-22 18:15:56 -07:00
commit 18db77a132
14 files changed
+411 -13

No files matched your search

+7
View File
@@ -132,6 +132,13 @@
# Extra sources for the notes drawer (see docs for the expected format).
# HERMES_WEBUI_EXTERNAL_NOTES_SOURCES=
# Sidebar recency window: how many recent sessions are listed (default: 20).
# Also caps how many delegated subagent children can nest at once; raise it
# for wide fan-outs. Invalid or non-positive values fall back to 20.
# Values above 200 are clamped. Resolved before profile init, so a profile
# .env cannot override this server-wide bound.
# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20
# Directory scanned for WebUI plugins (default: ~/.hermes/plugins).
# HERMES_WEBUI_PLUGINS_DIR=~/.hermes/plugins
+17
View File
@@ -3,6 +3,14 @@
## [Unreleased]
### Added
- **The sidebar's recent-session window is configurable.** `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` sets
how many recent sessions the sidebar lists (default 20). It also bounds how many delegated subagent
children can nest at once, so raise it for wide fan-outs. Invalid or non-positive values fall back to
20, values above 200 are clamped, and it is resolved before profile init so a profile `.env` cannot
override it. (#7631 by @carlotestor)
### Performance
- **Opening a session while its task is still running is much faster.** Rebuilding the live
@@ -16,6 +24,15 @@
### Fixed
- **A hidden browser tab stops polling a session that was deleted.** When a tab is in the
background, its stream poll kept asking for a session every few seconds after it was deleted or
archived away, forever. A `404`/`410` for the polled session now stops that poll; `503` and network
errors keep retrying, a late `404` for one session cannot stop another session's poll, and making
the tab visible again reopens the live stream. (#7299, #7716 by @happy5318)
- **The OpenRouter setup no longer offers a model OpenRouter doesn't serve.** Onboarding offered
`z-ai/glm-4.5-flash`, which is not in OpenRouter's catalog, so picking it failed on the first
message. That slot now offers `z-ai/glm-4.5-air`, Z.AI's nearest light model. The direct Z.AI
setup still offers GLM-4.5 Flash. (#7520, #7734 by @MuhammadUsamaMX)
- **Opening the sidebar can no longer stall the agent's writes to `state.db`.** Several WebUI
paths that only read the agent's `state.db` could quietly become writers. The read-only opener
fell back to a writable connection when `mode=ro` failed. The session listing self-healed a
+1
View File
@@ -373,6 +373,7 @@ Full list of environment variables:
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable |
| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` | Size of the sidebar's interactive recency window (how many recent non-cron/webhook sessions are listed). Also bounds how many delegated subagent children can nest at once, since a child only renders when its row wins a slot in the window — raise it for wide fan-outs. Non-integer or non-positive values fall back to the default. Values above 200 are clamped. Resolved before profile init, so a profile `.env` cannot override it |
| `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) |
| `HERMES_WEBUI_SESSIONS_MAX` | `100` | Legacy operator override for the max compact `Session` objects held in the in-memory LRU. Prefer the `webui.sessions_cache_max` key in `config.yaml` (which takes precedence); this env var remains a fallback. Bounds resident memory so long-running installs cannot accumulate every session ever touched and eventually crash (#4765/#2233/#4633). Eviction only ever drops clean, persisted, non-active sessions; an evicted session lazily reloads from its JSON sidecar on next access |
+15
View File
@@ -68,6 +68,21 @@ def _env_int(name: str, default: int, *, minimum: int = 1) -> int:
return default
return value if value >= minimum else default
def _env_int_clamped(name: str, default: int, *, minimum: int = 1, maximum: int) -> int:
"""Like ``_env_int``, then clamp a valid override to ``maximum``."""
value = _env_int(name, default, minimum=minimum)
if not str(os.getenv(name) or "").strip():
return value
return min(value, maximum)
# Sidebar recency window. Resolved here, before profile init, so a profile
# .env cannot override a server-wide resource bound. Clamped at 200.
CLI_VISIBLE_SESSION_LIMIT = _env_int_clamped(
"HERMES_WEBUI_VISIBLE_SESSION_LIMIT", 20, maximum=200,
)
# ── TLS/HTTPS config (optional, env-overridable) ────────────────────────────
TLS_CERT = os.getenv("HERMES_WEBUI_TLS_CERT", "").strip() or None
TLS_KEY = os.getenv("HERMES_WEBUI_TLS_KEY", "").strip() or None
+5 -1
View File
@@ -48,7 +48,11 @@ from api.agent_sessions import (
from api.process_event_utils import stamp_message_source
logger = logging.getLogger(__name__)
CLI_VISIBLE_SESSION_LIMIT = 20
# Size of the interactive sidebar recency window. Also bounds how many
# delegated subagent children can be rendered at once, since a child only
# nests when it wins a slot in this window. Resolved in api.config before
# profile init; override with HERMES_WEBUI_VISIBLE_SESSION_LIMIT (clamped 1–200).
CLI_VISIBLE_SESSION_LIMIT = _cfg.CLI_VISIBLE_SESSION_LIMIT
# How many messageful cron sessions to surface in the project-chip layer.
# Needs to exceed CLI_VISIBLE_SESSION_LIMIT so older cron runs stay
# addressable even when many newer non-cron sessions dominate the default
+3 -1
View File
@@ -71,7 +71,9 @@ _SUPPORTED_PROVIDER_SETUPS = {
"default_model": "anthropic/claude-sonnet-4.6",
"requires_base_url": False,
"models": [
{"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
{"id": "z-ai/glm-4.5-air", "label": "GLM-4.5 Air"}
if model["id"] == "zai/glm-4.5-flash"
else {"id": _to_openrouter_namespace(model["id"]), "label": model["label"]}
for model in _FALLBACK_MODELS
],
"category": "easy_start",
+6 -1
View File
@@ -198,7 +198,11 @@ def _unwrap_profile_home_to_base(home: Path) -> Path:
# are operator/deployment-level postures, not per-profile toggles. Letting a
# profile .env set HERMES_WEBUI_ISOLATED_PROFILE=0 would let a contained user
# escape isolation (#4589).
_PROTECTED_ENV_KEYS = frozenset({'HERMES_WEBUI_ISOLATED_PROFILE'})
_PROTECTED_ENV_KEYS = frozenset({
'HERMES_WEBUI_ISOLATED_PROFILE',
# Server-wide sidebar window; a profile .env must not widen the per-request query.
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
})
def _isolated_profile_opt_in() -> bool:
@@ -934,6 +938,7 @@ _BLOCKED_RUNTIME_ENV_KEYS = {
# #4589: operator/deployment isolation posture — never overridable by a
# profile's own env on any runtime/gateway-parity path.
'HERMES_WEBUI_ISOLATED_PROFILE',
'HERMES_WEBUI_VISIBLE_SESSION_LIMIT',
}
+9 -4
View File
@@ -2544,8 +2544,8 @@ def _build_session_list_cache_payload(
)
if show_cli_sessions:
diag_stage("cli_cap")
archived_scoped = _cap_recent_cli_sessions(archived_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
visible_scoped = _cap_recent_cli_sessions(visible_scoped, cli_cap=CLI_VISIBLE_SESSION_CAP)
archived_scoped = _cap_recent_cli_sessions(archived_scoped)
visible_scoped = _cap_recent_cli_sessions(visible_scoped)
if visible_only:
archived_scoped = [
s for s in archived_scoped if _session_has_server_visible_messages(s)
@@ -10405,11 +10405,16 @@ def _dedupe_cli_sidebar_sessions_for_api(
return _include_project_hidden_background_sidebar_sessions(candidates, visible)
CLI_VISIBLE_SESSION_CAP = 20
def _cli_visible_session_cap() -> int:
"""Shared sidebar window, not a second hard-coded 20."""
from api.config import CLI_VISIBLE_SESSION_LIMIT
return CLI_VISIBLE_SESSION_LIMIT
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int = CLI_VISIBLE_SESSION_CAP) -> list[dict]:
def _cap_recent_cli_sessions(sessions: list[dict], cli_cap: int | None = None) -> list[dict]:
"""Keep only the most recent CLI-visible sessions after filtering."""
if cli_cap is None:
cli_cap = _cli_visible_session_cap()
if cli_cap <= 0:
return sessions
kept = []
+16 -1
View File
@@ -8128,7 +8128,22 @@ function _startHiddenActiveStreamPoll(sid) {
if (S.activeStreamId) return; // already rendering; wait it out
try {
fetch(_apiUrl('api/session/status?session_id=' + encodeURIComponent(sid)), {credentials: 'same-origin'})
.then(r => r.ok ? r.json() : null)
.then(r => {
// #7299: 404 Not Found / 410 Gone are TERMINAL for this
// session-owned poll. The session has been deleted or no
// longer exists in the active state directory, so further
// polls are guaranteed to fail. Stop the poll immediately
// to avoid the infinite 404 loop on stale background tabs
// (one tab could fire ~10 such requests per minute; multiple
// tabs multiply the noise). Transient failures (5xx, rate
// limit, network error) keep polling — only the missing
// session itself is terminal.
if ((r.status === 404 || r.status === 410) && _sessionStreamHiddenPollSid === sid) {
_stopHiddenActiveStreamPoll();
return null;
}
return r.ok ? r.json() : null;
})
.then(d => {
if (!d || _sessionStreamHiddenPollSid !== sid) return;
const streamId = d.active_stream_id;
@@ -89,7 +89,9 @@ def test_hidden_poll_hits_session_status_and_attaches_as_replay():
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
assert start != -1
body = MESSAGES_JS[start:start + 2400]
# #7299 added a 404/410 terminal-stop branch to the response handler.
# The window must cover the original fetch chain + the new branch.
body = MESSAGES_JS[start:start + 8000]
assert "api/session/status?session_id=" in body
assert "d.active_stream_id" in body
# attaches as replay (recovered=true) — turn is already mid-flight
@@ -174,7 +176,7 @@ def test_poll_stops_only_when_attach_succeeds():
assert start != -1, "_startHiddenActiveStreamPoll signature not found"
# Window 2400: the multi-pane follow-up adds an explanatory comment block
# before the attach call, pushing it past a narrower slice.
body = MESSAGES_JS[start:start + 3200]
body = MESSAGES_JS[start:start + 8000]
assert "const attached = _attachServerInitiatedStream(sid, streamId, true)" in body
# Stop the poll only on a true attach (the false branch keeps polling within
# the bounded-retry budget rather than stopping).
@@ -0,0 +1,152 @@
"""Regression tests for issue #7299 — hidden-tab active-stream poll
keeps retrying deleted sessions forever, generating an invisible stream
of permanent 404 requests from stale background tabs.
When a tab is hidden, Hermes WebUI replaces the persistent per-session
SSE connection with a lightweight ``GET /api/session/status`` poll
every six seconds. If the tracked session has been deleted or no
longer exists in the active state directory, the endpoint returns
``404``, but the client previously converted every non-2xx response to
``null`` and kept the interval alive forever — one tab generates up to
ten requests per minute whenever browser timer throttling allows it.
The fix: in ``_startHiddenActiveStreamPoll``'s fetch chain, treat
``404 Not Found`` and ``410 Gone`` as **terminal** for the
session-owned poll — call ``_stopHiddenActiveStreamPoll()`` so the
interval and the bound ``_sessionStreamHiddenPollSid`` are cleared
together. Transient failures (5xx, rate-limit, network error) keep
polling because they do not necessarily mean the session is gone; a
later tick may catch a server-initiated turn starting while the tab
remains hidden.
"""
from __future__ import annotations
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
MESSAGES_JS = (REPO_ROOT / "static" / "messages.js").read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# Source-shape: the fetch chain in ``_startHiddenActiveStreamPoll`` must
# gate the terminal 404/410 path through ``_stopHiddenActiveStreamPoll``
# and the bound sid check. A silent reversion to the pre-fix
# "convert all non-2xx to null and keep polling" form fails the suite.
# ---------------------------------------------------------------------------
def test_hidden_poll_stops_on_404_response() -> None:
"""The fetch chain must call ``_stopHiddenActiveStreamPoll`` on a 404.
A 404 from ``/api/session/status`` means the session has been
deleted or no longer exists in the active state directory. The
pre-fix code converted every non-2xx to ``null`` and never
stopped the interval — so the same 404 fired every six seconds
for the lifetime of the hidden tab. Pin the contract that a 404
now tears down the poll.
"""
# The check is anchored on the hidden-poll function so a future
# refactor cannot satisfy it with an unrelated status-404
# handler in a different code path.
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
assert start != -1
# 3.6 KB is enough to cover the fetch → first .then → second .then
# block, even after the #7299 fix added the 404/410 terminal
# branch. The hidden poll is one of the larger functions in the
# file, so we take a wide window to be resilient to a small
# reformat.
body = MESSAGES_JS[start:start + 8000]
assert "r.status === 404" in body, (
"the hidden poll must check r.status === 404 in the fetch "
"response so a deleted session no longer triggers an "
"infinite 404 loop on a stale background tab"
)
assert "_stopHiddenActiveStreamPoll" in body, (
"the hidden poll must call _stopHiddenActiveStreamPoll when "
"the response is terminal (404/410) so the interval is "
"cleared instead of left running"
)
def test_hidden_poll_stops_on_410_response() -> None:
"""The fetch chain must also call ``_stopHiddenActiveStreamPoll`` on 410.
Some session-state endpoints use ``410 Gone`` (rather than
``404 Not Found``) for "this session id is permanently gone".
Both are terminal for the hidden poll — the pre-fix code treated
them identically, so the fix must handle both.
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
body = MESSAGES_JS[start:start + 8000]
assert "r.status === 410" in body, (
"the hidden poll must check r.status === 410 in the fetch "
"response so a 'gone' session also tears down the poll, "
"matching the 404 contract"
)
def test_hidden_poll_404_stop_guards_on_bound_sid() -> None:
"""The 404/410 stop path must check the bound ``_sessionStreamHiddenPollSid``.
The same fetch chain is invoked on every tick of a 6-second
interval. A race can let a stale fetch's response land after
the user switched sessions — in that case ``_sessionStreamHiddenPollSid``
no longer matches the fetched ``sid``, and the stop must NOT
tear down the *new* session's poll. Pin the contract.
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
body = MESSAGES_JS[start:start + 8000]
# The 404/410 branch must check the bound sid before stopping
# — pinned as a string match because the JS module is large
# and we want a regression to show up at a glance in CI.
assert "_sessionStreamHiddenPollSid === sid" in body, (
"the 404/410 stop path must guard on "
"_sessionStreamHiddenPollSid === sid so a stale fetch's "
"response cannot tear down a different session's poll"
)
def test_hidden_poll_preserves_transient_failure_retry() -> None:
"""5xx / rate-limit / network errors MUST keep polling (not stop).
The fix only narrows the stop path to terminal 404/410. Transient
failures (5xx, 429, network error) still produce ``null`` and
the next tick re-fires. A regression that broadens the stop
path to all non-2xx would re-introduce the original bug in
the opposite direction — the poll would die on a single
503 and never recover.
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
body = MESSAGES_JS[start:start + 8000]
# The pre-fix ``r.ok ? r.json() : null`` fallback must still be
# present for any non-ok / non-terminal response.
assert "r.ok ? r.json() : null" in body, (
"the 5xx / rate-limit / network-error path must still "
"produce null and let the next tick re-fire; only 404/410 "
"should tear down the poll"
)
def test_hidden_poll_starts_interval_unchanged() -> None:
"""The interval is unchanged (still 6000 ms).
The fix is purely about the response handling, not the
cadence. A regression that doubles the interval (or removes
the immediate-fire) breaks the original contract — a turn
already running when the tab goes hidden must be caught
without waiting a full interval.
"""
start = MESSAGES_JS.find("function _startHiddenActiveStreamPoll(sid)")
body = MESSAGES_JS[start:start + 8000]
assert "setInterval(tick, 6000)" in body, (
"the hidden poll must keep the 6000ms interval; the fix is "
"in the response handling, not the cadence"
)
# The immediate-fire call (no full interval wait) must also
# still be present so a turn already running when the tab goes
# hidden is caught without waiting a full interval.
assert "tick();" in body, (
"the immediate-fire tick() call must be preserved so a "
"turn already running when the tab goes hidden is caught "
"without waiting a full interval"
)
@@ -34,12 +34,24 @@ def _ids(provider_id: str) -> list[str]:
def _expected_openrouter_id(model_id: str) -> str:
"""Independent reference implementation of the namespace translation."""
"""Independent reference implementation of the namespace translation.
One Z.AI model has no OpenRouter counterpart: ``glm-4.5-flash`` is not in
OpenRouter's catalog, so #7520 serves Z.AI's nearest light model
``z-ai/glm-4.5-air`` in its place (same slot, so order is unchanged).
"""
if model_id in _OPENROUTER_REMAPS:
return _OPENROUTER_REMAPS[model_id]
if model_id.startswith("zai/"):
return "z-ai/" + model_id[len("zai/") :]
return model_id
# #7520: fallback ids that OpenRouter does not serve, mapped to the id the
# wizard offers instead. Keep this list tiny and explicit.
_OPENROUTER_REMAPS = {"zai/glm-4.5-flash": "z-ai/glm-4.5-air"}
def test_openrouter_setup_never_serves_direct_zai_namespace():
served = [model_id for model_id in _ids("openrouter") if model_id.startswith("zai/")]
assert served == [], (
@@ -66,14 +78,19 @@ def test_openrouter_setup_exposes_z_ai_models():
def test_openrouter_projection_is_namespace_only():
"""The OpenRouter list stays a 1:1, order-preserving projection."""
"""The OpenRouter list stays a 1:1, order-preserving projection.
Labels are preserved for every entry except the #7520 remap slots, whose
label names the model actually served.
"""
fallback = config._FALLBACK_MODELS
openrouter = _models("openrouter")
assert len(openrouter) == len(fallback)
for source, projected in zip(fallback, openrouter, strict=True):
assert projected["label"] == source["label"]
assert projected["id"] == _expected_openrouter_id(source["id"])
if source["id"] not in _OPENROUTER_REMAPS:
assert projected["label"] == source["label"]
def test_direct_zai_setup_keeps_direct_provider_ids():
+39
View File
@@ -249,3 +249,42 @@ def test_onboarding_setup_rejects_api_key_with_newline():
)
assert status == 400
assert "newline" in data["error"].lower()
# ── Regression: #7520 ────────────────────────────────────────────────────────
def test_openrouter_wizard_no_dead_zai_ids():
"""OpenRouter wizard must not offer zai/glm-4.5-flash — OpenRouter does not
serve it. The free-tier equivalent is z-ai/glm-4.5-air. (#7520)"""
from api.onboarding import _SUPPORTED_PROVIDER_SETUPS
or_setup = _SUPPORTED_PROVIDER_SETUPS["openrouter"]
or_ids = {m["id"] for m in or_setup["models"]}
# Dead id must not appear in the OpenRouter projection. Since #7514 the
# projection maps zai/ -> z-ai/, so the id actually offered (and dead on
# OpenRouter) is z-ai/glm-4.5-flash; check both spellings so this test
# fails on the pre-fix code rather than passing vacuously.
assert "z-ai/glm-4.5-flash" not in or_ids, (
"OpenRouter wizard still offers dead id z-ai/glm-4.5-flash"
)
assert "zai/glm-4.5-flash" not in or_ids
# The remapped replacement must be present, and must not be labelled
# "free": OpenRouter bills z-ai/glm-4.5-air as a paid model.
air = [m for m in or_setup["models"] if m["id"] == "z-ai/glm-4.5-air"]
assert air, "OpenRouter wizard missing remapped z-ai/glm-4.5-air"
assert "free" not in air[0]["label"].lower()
def test_zai_native_still_has_glm_4_5_flash():
"""The native Z.AI onboarding setup must still offer glm-4.5-flash —
the remapping is OpenRouter-only."""
from api.onboarding import _SUPPORTED_PROVIDER_SETUPS
zai_setup = _SUPPORTED_PROVIDER_SETUPS["zai"]
zai_ids = {m["id"] for m in zai_setup["models"]}
assert "glm-4.5-flash" in zai_ids, (
"Native Z.AI setup lost glm-4.5-flash — the remapping must be "
"OpenRouter-only, not touching _FALLBACK_MODELS"
)
+117
View File
@@ -0,0 +1,117 @@
"""CLI_VISIBLE_SESSION_LIMIT is overridable via HERMES_WEBUI_VISIBLE_SESSION_LIMIT.
The sidebar recency window also bounds how many delegated subagent children can
render at once, since a child only nests when its row wins a slot in the same
payload. Operators running wide fan-outs need to raise it without editing code.
The constant is bound once at import time, so each case imports ``api.models``
in a fresh subprocess (same pattern as test_issue3283_profiles_config_import_order)
rather than ``importlib.reload``-ing it in the shared test process — reloading
would recreate the module's locks, caches, and classes while other modules keep
references to the old objects.
The route cap must come from that same value. A profile ``.env`` must not
override it: the constant is resolved in ``api.config`` before profile init,
and the key is in ``api.profiles._PROTECTED_ENV_KEYS``.
"""
import os
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
_PROBE = """
import api.models
import api.routes
print(api.models.CLI_VISIBLE_SESSION_LIMIT)
print(api.routes._cli_visible_session_cap())
rows = [{"source": "cli", "title": "cli", "updated_at": i} for i in range(30)]
print(len(api.routes._cap_recent_cli_sessions(rows)))
"""
_PROFILE_PROBE = """
import os
from pathlib import Path
import api.config
import api.profiles
home = Path(os.environ["HERMES_HOME"])
(home / ".env").write_text("HERMES_WEBUI_VISIBLE_SESSION_LIMIT=7\\n", encoding="utf-8")
api.profiles._reload_dotenv(home)
print("PROTECTED", "HERMES_WEBUI_VISIBLE_SESSION_LIMIT" in api.profiles._PROTECTED_ENV_KEYS)
print("ENV", os.environ.get("HERMES_WEBUI_VISIBLE_SESSION_LIMIT"))
print("LIMIT", api.config.CLI_VISIBLE_SESSION_LIMIT)
"""
def _run_probe(tmp_path, probe, value):
home = tmp_path / "home"
home.mkdir()
env = os.environ.copy()
for key in list(env):
if key.startswith("HERMES_WEBUI_") or key in ("HERMES_HOME", "HERMES_BASE_HOME"):
env.pop(key)
env["HOME"] = str(home)
env["HERMES_HOME"] = str(home)
env["PYTHONPATH"] = str(REPO_ROOT)
if value is not None:
env["HERMES_WEBUI_VISIBLE_SESSION_LIMIT"] = value
proc = subprocess.run(
[sys.executable, "-c", probe],
cwd=REPO_ROOT,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=90,
check=False,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip().splitlines()
def _limit_in_fresh_interpreter(tmp_path, value):
return int(_run_probe(tmp_path, _PROBE, value)[0])
def test_defaults_to_20_when_unset(tmp_path):
assert _limit_in_fresh_interpreter(tmp_path, None) == 20
def test_env_override_raises_the_window(tmp_path):
assert _limit_in_fresh_interpreter(tmp_path, "64") == 64
@pytest.mark.parametrize("value", ["bogus", "0", "-5"])
def test_invalid_or_nonpositive_falls_back_to_default(tmp_path, value):
assert _limit_in_fresh_interpreter(tmp_path, value) == 20
def test_values_above_200_are_clamped(tmp_path):
assert _limit_in_fresh_interpreter(tmp_path, "500") == 200
def test_route_cap_follows_the_shared_value(tmp_path):
lines = _run_probe(tmp_path, _PROBE, "8")
assert lines[0] == "8"
assert lines[1] == "8"
assert lines[2] == "8"
def test_profile_env_cannot_override_visible_session_limit(tmp_path):
lines = _run_probe(tmp_path, _PROFILE_PROBE, "40")
assert lines[0] == "PROTECTED True"
assert lines[1] == "ENV 40"
assert lines[2] == "LIMIT 40"
def test_setting_is_documented():
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
env_example = (REPO_ROOT / ".env.example").read_text(encoding="utf-8")
assert "| `HERMES_WEBUI_VISIBLE_SESSION_LIMIT` | `20` |" in readme
assert "# HERMES_WEBUI_VISIBLE_SESSION_LIMIT=20" in env_example
assert "200" in readme
assert "clamped" in env_example