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:
14 files changed
+411
-13
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user