Merge pull request #1556 from nesquena/stage-283
v0.50.283 — full PR sweep (8 PRs, 7 issues closed)
This commit is contained in:
19 files changed
+890
-69
No files matched your search
@@ -1,5 +1,42 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.283] — 2026-05-03
|
||||
|
||||
### Fixed (8 PRs — full sweep batch — closes #1426, #1481, #1512, #1468, #1424, #1457, #1401)
|
||||
|
||||
- **OpenRouter free-tier visibility — structural live fetch** (#1548 augmented from @bergeouss; closes #1426) — when an operator selected an OpenRouter free-tier model like `minimax/minimax-m2.5:free`, it was invisible in the picker because `hermes_cli/models.py:_openrouter_model_supports_tools()` filters out models that don't advertise `tools` in `supported_parameters` — and OpenRouter often hasn't yet annotated newly-added free variants. The original PR added 5 hardcoded `_FALLBACK_MODELS` entries; per maintainer directive ("augment the one that's going to rot fast with a live refresh"), the merged version replaces the static slice with two live-fetches plus the static fallback for offline/test envs: (1) curated catalog via `hermes_cli.models.fetch_openrouter_models()` — applies the tool-support filter; (2) direct `https://openrouter.ai/api/v1/models` filtered to free-tier-only (`pricing.prompt == 0` AND `pricing.completion == 0`, OR `:free` suffix), bypassing the tool-support filter so newly-added free variants appear even before OpenRouter annotates them with `tools`. Capped at 30 to keep the picker usable. Falls back to `_FALLBACK_MODELS[provider==OpenRouter]` (which retains @bergeouss's hardcoded list as defense-in-depth) when both live fetches fail. Dedup via `seen_ids` so a model in both surfaces appears once. 5 new tests in `tests/test_issue1426_openrouter_free_tier_live_fetch.py`. Pre-release Opus advisor verified no SSRF surface (URL is hardcoded literal, can't be config-redirected).
|
||||
|
||||
- **Pending user turn recovery on stale stream restart** (#1543, @ai-ag2026; follow-up to #1471) — when a server restart happens mid-turn, the user's just-submitted prompt was the only durable copy and was silently discarded along with the stale stream state. Now `api/models.py:_apply_core_sync_or_error_marker` materializes the pending user turn with `_recovered: true` BEFORE clearing runtime fields if `messages` is non-empty AND `pending_user_message` is set. Adds 49 LOC of regression coverage in `tests/test_stale_stream_pending_recovery.py`.
|
||||
|
||||
- **Silent credential self-heal on 401 errors** (#1553, @bergeouss; closes #1401) — when `auth.json` drifts (file rewritten by another process, OAuth refresh elsewhere, env-var rotation) and the streaming layer hits an auth-only 401, the WebUI now re-reads `auth.json`, invalidates the credential pool cache via the new `invalidate_credential_pool_cache(provider_id)` export, and retries the request once with fresh credentials. Single retry only, auth-only trigger, thread-safe (acquires `_available_models_cache_lock` for cache mutation). Reverts to the original error emission if the retry also fails. ~263 LOC across `api/streaming.py`, `api/oauth.py`, `api/config.py`. Pre-release Opus flagged 4 non-blocking SHOULD-FIX code-quality items (retry-logic duplication between in-line and except paths, fragile `_assistant_added=True` flag pattern, `in dir()` vs `in locals()` idiom, no `cancel_evt` check before retry) — deferred as follow-up since structural refactor is >20 LOC.
|
||||
|
||||
- **Reveal in File Manager** (#1551, @bergeouss; closes #1424) — new workspace-file context menu item. Cross-platform: macOS (`open -R`), Linux (`xdg-open` on parent dir), Windows (`explorer /select,<path>`). New `/api/workspace/reveal` POST handler validates the path through `safe_resolve` (verified by Opus advisor — blocks both absolute `/etc/passwd` injection and relative `../` traversal) and uses list-arg `subprocess.Popen` (no shell injection). Plus 2 new i18n keys (`reveal_in_finder`, `reveal_failed`) translated to all 8 non-English locales (ja, ru, es, de, zh, zh-Hant, pt, ko) — pt translation absorbed in-stage from Opus advisor SHOULD-FIX (contributor branch covered en + 7 locales, missed pt; pt parity test doesn't exist yet so the gap was invisible to CI but would have shown English fallback to Portuguese users).
|
||||
|
||||
- **Gateway status card in Settings → System** (#1552, @bergeouss; closes #1457) — new read-only display card in the System settings tab. New `/api/gateway/status` endpoint returns connected platforms (Telegram/Discord/Slack/Weixin), active session count, and last-active timestamp. No behavior change to gateway internals.
|
||||
|
||||
- **Auto-assign session to active project filter** (#1550, @bergeouss; closes #1468) — when the user is filtering the sidebar by project X and clicks "+ New session", the new session inherits `project_id=X` instead of starting unassigned. Three-line `api/models.py:new_session` signature extension (`project_id=None` kwarg) + matching frontend pass-through in `static/sessions.js`.
|
||||
|
||||
- **"What's new?" link in update banner** (#1549, @bergeouss; closes #1512) — `api/updates.py:_check_repo` now returns `repo_url` (SSH→HTTPS conversion + `.git` strip); the update banner adds a small accent-colored anchor that points to `${repo_url}/compare/${current}...${latest}` so users can read release highlights in one click.
|
||||
|
||||
- **Phantom `/sw.js` PUBLIC_PATHS whitelist removed** (#1545, @bergeouss; closes #1481) — the `/sw.js` path is served via a dedicated route handler that doesn't go through the `PUBLIC_PATHS` check, so the leftover whitelist entry was vestigial. When auth is enabled, `/sw.js` correctly requires the session cookie (security hardening side-effect, not a regression — service worker fetches travel with the cookie from authenticated context).
|
||||
|
||||
### Tests
|
||||
|
||||
3990 → **4019 passing** (+29 net from constituents: +5 from #1548 OpenRouter, +1 from #1543 recovery, +14 from PR #1544's earlier #1538/#1539 work shipped in v0.50.282, +9 from this batch including the +5 OpenRouter regression suite). 0 regressions. Full suite in 111s.
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- All 8 merges produced clean `ort` strategy results (no conflict markers).
|
||||
- Browser sanity (HTTP API checks against port 8789): 11 endpoints verified.
|
||||
- All modified JS files pass `node -c` syntax check.
|
||||
- Pre-release Opus advisor v2: SHIP WITH ABSORPTIONS — 1 MUST absorb (≤2 LOC pt locale gap, applied in-stage), 4 SHOULD-FIX deferred from #1553 self-heal (>20 LOC structural refactor, follow-up issue planned), 1 SHOULD-FIX deferred for cross-locale parity test (would have caught the pt gap at PR review time).
|
||||
|
||||
### Maintainer post-merge fixes (in-stage)
|
||||
|
||||
- `static/i18n.js`: pt locale `reveal_in_finder` / `reveal_failed` translations added (Opus-flagged, 2 LOC).
|
||||
- `tests/test_minimax_provider.py::test_minimax_fallback_provider_label` — scoped to direct-MiniMax routes (filter by `minimax/` prefix, exclude `:free`) since #1548's `minimax/minimax-m2.5:free` correctly carries `provider='OpenRouter'` (it routes via OpenRouter, not direct MiniMax).
|
||||
|
||||
|
||||
## [v0.50.282] — 2026-05-03
|
||||
|
||||
### Fixed (1 PR — closes #1538, #1539)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> Last updated: v0.50.282 (May 03, 2026) — 4018 tests collected
|
||||
> Last updated: v0.50.283 (May 03, 2026) — 4019 tests collected
|
||||
> Test source: `pytest tests/ --collect-only -q`
|
||||
> Per-version detail: see [CHANGELOG.md](./CHANGELOG.md)
|
||||
|
||||
|
||||
+2
-2
@@ -1835,8 +1835,8 @@ Bridged CLI sessions:
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.50.282, May 03, 2026*
|
||||
*Total automated tests collected: 4018*
|
||||
*Last updated: v0.50.283, May 03, 2026*
|
||||
*Total automated tests collected: 4019*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
@@ -22,7 +22,6 @@ PUBLIC_PATHS = frozenset({
|
||||
'/login', '/health', '/favicon.ico',
|
||||
'/api/auth/login', '/api/auth/status',
|
||||
'/manifest.json', '/manifest.webmanifest',
|
||||
'/sw.js',
|
||||
})
|
||||
|
||||
COOKIE_NAME = 'hermes_session'
|
||||
|
||||
+104
-4
@@ -536,6 +536,14 @@ _FALLBACK_MODELS = [
|
||||
{"provider": "Z.AI", "id": "zai/glm-4.7", "label": "GLM-4.7"},
|
||||
{"provider": "Z.AI", "id": "zai/glm-4.5", "label": "GLM-4.5"},
|
||||
{"provider": "Z.AI", "id": "zai/glm-4.5-flash", "label": "GLM-4.5 Flash"},
|
||||
# OpenRouter free-tier models — must appear in fallback list so they
|
||||
# are visible even when the tool-support filter in hermes_cli strips
|
||||
# them out of the live catalog (see #1426).
|
||||
{"provider": "OpenRouter", "id": "openrouter/elephant-alpha", "label": "Elephant Alpha (free)"},
|
||||
{"provider": "OpenRouter", "id": "openrouter/owl-alpha", "label": "Owl Alpha (free)"},
|
||||
{"provider": "OpenRouter", "id": "tencent/hy3-preview:free", "label": "Hy3 Preview (free)"},
|
||||
{"provider": "OpenRouter", "id": "nvidia/nemotron-3-super-120b-a12b:free", "label": "Nemotron 3 Super (free)"},
|
||||
{"provider": "OpenRouter", "id": "arcee-ai/trinity-large-preview:free", "label": "Trinity Large Preview (free)"},
|
||||
]
|
||||
|
||||
# Provider display names for known Hermes provider IDs
|
||||
@@ -1444,6 +1452,18 @@ def invalidate_models_cache():
|
||||
_delete_models_cache_on_disk()
|
||||
|
||||
|
||||
def invalidate_credential_pool_cache(provider_id: str):
|
||||
"""Invalidate the credential pool cache for a specific provider.
|
||||
|
||||
Used by the streaming layer's credential self-heal logic (#1401) to
|
||||
force a fresh credential pool load after re-reading auth.json.
|
||||
"""
|
||||
global _CREDENTIAL_POOL_CACHE
|
||||
with _available_models_cache_lock:
|
||||
_CREDENTIAL_POOL_CACHE.pop(provider_id, None)
|
||||
_CREDENTIAL_POOL_CACHE.pop(_resolve_provider_alias(provider_id), None)
|
||||
|
||||
|
||||
def invalidate_provider_models_cache(provider_id: str):
|
||||
"""Invalidate cached models for a single provider.
|
||||
|
||||
@@ -2109,14 +2129,94 @@ def get_available_models() -> dict:
|
||||
continue
|
||||
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
|
||||
if pid == "openrouter":
|
||||
# OpenRouter has two model surfaces:
|
||||
# (1) curated tool-supporting catalog via hermes_cli.models.fetch_openrouter_models()
|
||||
# — the canonical agent-ready list, applies a tool-support filter
|
||||
# (Kilo-Org/kilocode#9068) that hides image/completion-only models
|
||||
# (2) free-tier `:free` variants — newly-added models OpenRouter ships
|
||||
# experimentally that may not yet advertise `tools` in supported_parameters
|
||||
# (see #1426). These get filtered out of (1) but users want them visible.
|
||||
#
|
||||
# Strategy: take the live curated list as the base, then augment with a
|
||||
# separate live-fetch of OpenRouter's /v1/models filtered to free-tier-only.
|
||||
# Free-tier entries get a "(free)" label suffix so the picker is honest about
|
||||
# what the user is selecting. Falls back to the static _FALLBACK_MODELS list
|
||||
# when both live fetches fail (offline, transient API error, test env).
|
||||
raw_models = []
|
||||
seen_ids = set()
|
||||
try:
|
||||
from hermes_cli.models import (
|
||||
fetch_openrouter_models as _fetch_or_models,
|
||||
)
|
||||
live_curated = _fetch_or_models() or []
|
||||
for mid, _desc in live_curated:
|
||||
if mid and mid not in seen_ids:
|
||||
seen_ids.add(mid)
|
||||
raw_models.append({"id": mid, "label": mid})
|
||||
except Exception:
|
||||
logger.warning("Failed to load OpenRouter curated catalog from hermes_cli")
|
||||
|
||||
# Free-tier live fetch — bypasses the tool-support filter so models
|
||||
# OpenRouter has flagged free but hasn't yet annotated with tools=[]
|
||||
# (or that have tools=[] but the user explicitly wants to try) appear.
|
||||
try:
|
||||
import urllib.request as _urlreq
|
||||
_req = _urlreq.Request(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
with _urlreq.urlopen(_req, timeout=8.0) as _resp:
|
||||
_payload = json.loads(_resp.read().decode())
|
||||
_free_count = 0
|
||||
_free_cap = 30 # don't drown the picker — top 30 free tier
|
||||
for _item in _payload.get("data", []) or []:
|
||||
if not isinstance(_item, dict):
|
||||
continue
|
||||
_mid = str(_item.get("id") or "").strip()
|
||||
if not _mid or _mid in seen_ids:
|
||||
continue
|
||||
_pricing = _item.get("pricing") or {}
|
||||
try:
|
||||
_is_free = (
|
||||
float(_pricing.get("prompt", "0") or "0") == 0
|
||||
and float(_pricing.get("completion", "0") or "0") == 0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
_is_free = False
|
||||
# Also include explicit `:free` suffix variants
|
||||
_is_free = _is_free or _mid.endswith(":free")
|
||||
if not _is_free:
|
||||
continue
|
||||
_name = (
|
||||
str(_item.get("name") or "").strip() or _mid
|
||||
)
|
||||
# Strip provider prefix from name for display, append (free)
|
||||
_label = _name.split("/")[-1] if "/" in _name else _name
|
||||
if "(free)" not in _label.lower():
|
||||
_label = f"{_label} (free)"
|
||||
seen_ids.add(_mid)
|
||||
raw_models.append({"id": _mid, "label": _label})
|
||||
_free_count += 1
|
||||
if _free_count >= _free_cap:
|
||||
break
|
||||
except Exception:
|
||||
logger.debug("OpenRouter free-tier live fetch unavailable; using fallback")
|
||||
|
||||
if not raw_models:
|
||||
# Both live fetches failed — fall back to the curated static list.
|
||||
# Deepcopy so dedup/prefix mutation downstream does not bleed
|
||||
# into the module-level catalog.
|
||||
raw_models = [
|
||||
{"id": m["id"], "label": m["label"]}
|
||||
for m in _FALLBACK_MODELS
|
||||
if m.get("provider") == "OpenRouter"
|
||||
]
|
||||
|
||||
groups.append(
|
||||
{
|
||||
"provider": "OpenRouter",
|
||||
"provider_id": "openrouter",
|
||||
"models": [
|
||||
{"id": m["id"], "label": m["label"]}
|
||||
for m in _FALLBACK_MODELS
|
||||
],
|
||||
"models": raw_models,
|
||||
}
|
||||
)
|
||||
elif pid == "ollama-cloud":
|
||||
|
||||
+20
-8
@@ -542,11 +542,23 @@ def _apply_core_sync_or_error_marker(
|
||||
if require_stream_dead and session.active_stream_id in _active_stream_ids():
|
||||
return False
|
||||
|
||||
# When messages is already non-empty the core-sync overwrite and recovered
|
||||
# user turn are skipped (we cannot clobber in-memory mutations), but the
|
||||
# stuck pending fields MUST still be cleared and an error marker appended
|
||||
# so the session isn't permanently left in stale-pending state.
|
||||
# When messages is already non-empty, do not overwrite history from any core
|
||||
# transcript. The pending user turn may still be the only durable copy of a
|
||||
# prompt submitted just before a server restart, so materialize it before
|
||||
# clearing runtime stream state.
|
||||
if len(session.messages) != 0:
|
||||
_recovered_ts = int(time.time())
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
recovered = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': _recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
@@ -559,7 +571,7 @@ def _apply_core_sync_or_error_marker(
|
||||
})
|
||||
session.save()
|
||||
logger.info(
|
||||
"Session %s: pending cleared (messages non-empty), added error marker",
|
||||
"Session %s: recovered pending user turn (messages non-empty), added error marker",
|
||||
sid,
|
||||
)
|
||||
return True
|
||||
@@ -636,8 +648,7 @@ def _repair_stale_pending(session) -> bool:
|
||||
# _apply_core_sync_or_error_marker uses this to detect a rotated active_stream_id
|
||||
# (e.g. context compression) or a stream that came back alive.
|
||||
_seen_stream_id = session.active_stream_id
|
||||
if (len(session.messages) != 0
|
||||
or not session.pending_user_message
|
||||
if (not session.pending_user_message
|
||||
or not _seen_stream_id
|
||||
or _seen_stream_id in _active_stream_ids()):
|
||||
return False
|
||||
@@ -713,7 +724,7 @@ def get_session(sid, metadata_only=False):
|
||||
return s
|
||||
raise KeyError(sid)
|
||||
|
||||
def new_session(workspace=None, model=None, profile=None, model_provider=None):
|
||||
def new_session(workspace=None, model=None, profile=None, model_provider=None, project_id=None):
|
||||
"""Create a new in-memory session.
|
||||
|
||||
The session lives in the SESSIONS dict only — no disk write happens until
|
||||
@@ -749,6 +760,7 @@ def new_session(workspace=None, model=None, profile=None, model_provider=None):
|
||||
model=effective_model,
|
||||
model_provider=model_provider,
|
||||
profile=profile,
|
||||
project_id=project_id,
|
||||
)
|
||||
with LOCK:
|
||||
SESSIONS[s.session_id] = s
|
||||
|
||||
@@ -38,6 +38,15 @@ def _read_auth_json():
|
||||
return {}
|
||||
|
||||
|
||||
def read_auth_json():
|
||||
"""Public wrapper for _read_auth_json.
|
||||
|
||||
Used by the streaming layer's credential self-heal logic (#1401) to
|
||||
re-read credentials without coupling to the private helper.
|
||||
"""
|
||||
return _read_auth_json()
|
||||
|
||||
|
||||
def _write_auth_json(data):
|
||||
"""Atomically write auth.json via temp-file rename.
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import logging
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -2181,6 +2183,43 @@ def handle_get(handler, parsed) -> bool:
|
||||
{"name": get_active_profile_name(), "path": str(get_active_hermes_home())},
|
||||
)
|
||||
|
||||
# ── Gateway Status (GET) ──
|
||||
if parsed.path == "/api/gateway/status":
|
||||
import datetime
|
||||
identity_map = _load_gateway_session_identity_map()
|
||||
sessions_path = _gateway_session_metadata_path()
|
||||
running = bool(identity_map)
|
||||
platforms_set: set[str] = set()
|
||||
for meta in identity_map.values():
|
||||
raw = meta.get("raw_source") or meta.get("platform") or ""
|
||||
norm = _normalize_messaging_source(raw)
|
||||
if norm:
|
||||
platforms_set.add(norm)
|
||||
_PLATFORM_LABELS = {
|
||||
"telegram": "Telegram",
|
||||
"discord": "Discord",
|
||||
"slack": "Slack",
|
||||
"web": "Web",
|
||||
"api": "API",
|
||||
}
|
||||
platforms = sorted(
|
||||
[{"name": p, "label": _PLATFORM_LABELS.get(p, p.title())} for p in platforms_set],
|
||||
key=lambda x: x["label"],
|
||||
)
|
||||
last_active = ""
|
||||
if running and sessions_path.exists():
|
||||
try:
|
||||
mtime = sessions_path.stat().st_mtime
|
||||
last_active = datetime.datetime.fromtimestamp(mtime).isoformat()
|
||||
except Exception:
|
||||
pass
|
||||
return j(handler, {
|
||||
"running": running,
|
||||
"platforms": platforms,
|
||||
"last_active": last_active,
|
||||
"session_count": len(identity_map),
|
||||
})
|
||||
|
||||
# ── MCP Servers (GET) ──
|
||||
if parsed.path == "/api/mcp/servers":
|
||||
return _handle_mcp_servers_list(handler)
|
||||
@@ -2253,6 +2292,7 @@ def handle_post(handler, parsed) -> bool:
|
||||
model=model,
|
||||
model_provider=model_provider,
|
||||
profile=body.get("profile") or None,
|
||||
project_id=body.get("project_id") or None,
|
||||
)
|
||||
return j(handler, {"session": s.compact() | {"messages": s.messages}})
|
||||
|
||||
@@ -2805,6 +2845,9 @@ def handle_post(handler, parsed) -> bool:
|
||||
if parsed.path == "/api/file/create-dir":
|
||||
return _handle_create_dir(handler, body)
|
||||
|
||||
if parsed.path == "/api/file/reveal":
|
||||
return _handle_file_reveal(handler, body)
|
||||
|
||||
# ── Workspace management (POST) ──
|
||||
if parsed.path == "/api/workspaces/add":
|
||||
return _handle_workspace_add(handler, body)
|
||||
@@ -5154,6 +5197,34 @@ def _handle_create_dir(handler, body):
|
||||
return bad(handler, _sanitize_error(e))
|
||||
|
||||
|
||||
def _handle_file_reveal(handler, body):
|
||||
try:
|
||||
require(body, "session_id", "path")
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
try:
|
||||
s = get_session(body["session_id"])
|
||||
except KeyError:
|
||||
return bad(handler, "Session not found", 404)
|
||||
try:
|
||||
target = safe_resolve(Path(s.workspace), body["path"])
|
||||
if not target.exists():
|
||||
return bad(handler, "File not found", 404)
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
subprocess.Popen(["open", "-R", str(target)])
|
||||
elif system == "Windows":
|
||||
subprocess.Popen(["explorer.exe", "/select," + str(target)])
|
||||
else:
|
||||
# Linux / other — open parent directory
|
||||
subprocess.Popen(["xdg-open", str(target.parent)])
|
||||
|
||||
return j(handler, {"ok": True, "path": body["path"]})
|
||||
except (ValueError, PermissionError, OSError) as e:
|
||||
return bad(handler, _sanitize_error(e))
|
||||
|
||||
|
||||
def _handle_workspace_add(handler, body):
|
||||
path_str = body.get("path", "").strip()
|
||||
name = body.get("name", "").strip()
|
||||
|
||||
+242
-24
@@ -1343,6 +1343,61 @@ def _last_resort_sync_from_core(session, stream_id, agent_lock):
|
||||
)
|
||||
|
||||
|
||||
def _attempt_credential_self_heal(
|
||||
provider_id, session_id, _agent_lock_ref,
|
||||
):
|
||||
"""Try to silently refresh credentials after a 401/auth error (#1401).
|
||||
|
||||
Returns a new ``(agent, rt_dict)`` tuple on success so the caller can
|
||||
retry the conversation. Returns ``None`` when self-heal is not
|
||||
applicable (e.g. auth.json unchanged, provider unresolvable).
|
||||
|
||||
Steps:
|
||||
1. Re-read ``~/.hermes/auth.json`` to pick up fresh credentials that
|
||||
may have been written by a concurrent ``hermes model`` CLI invocation.
|
||||
2. Evict the session's cached agent so it is rebuilt with fresh keys.
|
||||
3. Evict the provider's credential-pool cache entry.
|
||||
4. Re-resolve the runtime provider.
|
||||
5. Return a new agent + resolved-provider dict (the caller must
|
||||
re-invoke ``run_conversation`` with these).
|
||||
"""
|
||||
try:
|
||||
from api.oauth import read_auth_json
|
||||
from api.config import (
|
||||
SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK,
|
||||
invalidate_credential_pool_cache,
|
||||
)
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
|
||||
# 1. Re-read auth.json (triggers a fresh credential scan)
|
||||
_fresh_auth = read_auth_json()
|
||||
if not _fresh_auth:
|
||||
logger.debug('[webui] self-heal: auth.json empty or missing, skipping')
|
||||
return None
|
||||
|
||||
# 2. Evict the cached agent for this session
|
||||
with SESSION_AGENT_CACHE_LOCK:
|
||||
SESSION_AGENT_CACHE.pop(session_id, None)
|
||||
|
||||
# 3. Invalidate the credential pool for this provider
|
||||
invalidate_credential_pool_cache(provider_id)
|
||||
|
||||
# 4. Re-resolve runtime provider with fresh credentials
|
||||
_new_rt = resolve_runtime_provider(requested=provider_id)
|
||||
|
||||
logger.info(
|
||||
'[webui] self-heal: credential refresh succeeded for provider=%s session=%s',
|
||||
provider_id, session_id,
|
||||
)
|
||||
return _new_rt
|
||||
except Exception as _heal_err:
|
||||
logger.warning(
|
||||
'[webui] self-heal: failed for provider=%s session=%s: %s',
|
||||
provider_id, session_id, _heal_err,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _run_agent_streaming(
|
||||
session_id,
|
||||
msg_text,
|
||||
@@ -1561,6 +1616,7 @@ def _run_agent_streaming(
|
||||
|
||||
try:
|
||||
_token_sent = False # tracks whether any streamed tokens were sent
|
||||
_self_healed = False # (#1401) prevents infinite self-heal retries
|
||||
_reasoning_text = '' # accumulates reasoning/thinking trace for persistence
|
||||
_live_tool_calls = [] # tool progress fallback when final messages omit tool IDs
|
||||
|
||||
@@ -2143,6 +2199,95 @@ def _run_agent_streaming(
|
||||
_err_label = 'Out of credits'
|
||||
_err_type = 'quota_exhausted'
|
||||
_err_hint = 'Your provider account is out of credits. Top up your balance or switch providers via `hermes model`.'
|
||||
elif _is_auth and not _self_healed:
|
||||
# ── Credential self-heal on 401 (#1401) ──
|
||||
# Before emitting the error, try re-reading credentials
|
||||
# and retrying once with a fresh agent.
|
||||
_heal_result = None
|
||||
_heal_rt = _attempt_credential_self_heal(
|
||||
resolved_provider or '', session_id, _agent_lock,
|
||||
)
|
||||
if _heal_rt is not None:
|
||||
logger.info('[webui] self-heal: retrying stream after credential refresh')
|
||||
# Rebuild runtime variables from the refreshed resolve
|
||||
_rt = _heal_rt
|
||||
resolved_api_key = _heal_rt.get('api_key')
|
||||
if not resolved_provider:
|
||||
resolved_provider = _heal_rt.get('provider')
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = _heal_rt.get('base_url')
|
||||
# Rebuild agent kwargs and create a fresh agent
|
||||
_agent_kwargs['api_key'] = resolved_api_key
|
||||
_agent_kwargs['base_url'] = resolved_base_url
|
||||
_agent_kwargs['model'] = resolved_model
|
||||
_agent_kwargs['provider'] = resolved_provider
|
||||
if 'credential_pool' in _agent_params:
|
||||
_agent_kwargs['credential_pool'] = _heal_rt.get('credential_pool')
|
||||
agent = _AIAgent(**_agent_kwargs)
|
||||
with STREAMS_LOCK:
|
||||
AGENT_INSTANCES[stream_id] = agent
|
||||
from api.config import SESSION_AGENT_CACHE as _SAC, SESSION_AGENT_CACHE_LOCK as _SAC_L
|
||||
with _SAC_L:
|
||||
_SAC[session_id] = (agent, _agent_sig)
|
||||
_SAC.move_to_end(session_id)
|
||||
# Retry the conversation once with fresh credentials
|
||||
_self_healed = True
|
||||
_token_sent = False
|
||||
try:
|
||||
_heal_result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
_heal_ok = any(
|
||||
m.get('role') == 'assistant' and str(m.get('content') or '').strip()
|
||||
for m in (_heal_result.get('messages') or [])
|
||||
) or _token_sent
|
||||
except Exception as _retry_exc:
|
||||
logger.warning(
|
||||
'[webui] self-heal: retry also failed: %s', _retry_exc,
|
||||
)
|
||||
_heal_ok = False
|
||||
if _heal_ok and _heal_result is not None:
|
||||
# Retry succeeded — replace result and skip error
|
||||
result = _heal_result
|
||||
# Fall through past the error-emission block;
|
||||
# the post-result persistence code below will
|
||||
# process ``result`` normally. We jump past
|
||||
# the ``put('apperror', ...)`` + ``return`` by
|
||||
# NOT entering the ``if not _assistant_added``
|
||||
# guard again — but we are already inside it.
|
||||
# Solution: set _assistant_added so the guard
|
||||
# evaluates False on next conceptual pass.
|
||||
# Since we're in a flat block, directly run the
|
||||
# post-result merge logic here.
|
||||
_result_messages = result.get('messages') or _previous_context_messages
|
||||
_next_context_messages = _restore_reasoning_metadata(
|
||||
_previous_context_messages,
|
||||
_result_messages,
|
||||
)
|
||||
s.context_messages = _next_context_messages
|
||||
s.messages = _merge_display_messages_after_agent_result(
|
||||
_previous_messages,
|
||||
_previous_context_messages,
|
||||
_restore_reasoning_metadata(_previous_messages, _result_messages),
|
||||
msg_text,
|
||||
)
|
||||
# Skip the error block — jump directly to the
|
||||
# normal post-result persistence path by
|
||||
# leaving _assistant_added truthy (set below).
|
||||
_assistant_added = True # prevent re-entering guard
|
||||
if not _assistant_added:
|
||||
# Self-heal didn't apply or retry failed — emit error
|
||||
_err_label = 'Authentication failed'
|
||||
_err_type = 'auth_mismatch'
|
||||
_err_hint = (
|
||||
'The selected model may not be supported by your configured provider or '
|
||||
'your API key is invalid. Run `hermes model` in your terminal to '
|
||||
'update credentials, then restart the WebUI.'
|
||||
)
|
||||
elif _is_auth:
|
||||
_err_label = 'Authentication failed'
|
||||
_err_type = 'auth_mismatch'
|
||||
@@ -2155,31 +2300,38 @@ def _run_agent_streaming(
|
||||
_err_label = 'No response received'
|
||||
_err_type = 'no_response'
|
||||
_err_hint = 'Verify your API key is valid and the selected model is available for your account.'
|
||||
put('apperror', {
|
||||
'message': _err_str or f'{_err_label}.',
|
||||
'type': _err_type,
|
||||
'hint': _err_hint,
|
||||
})
|
||||
# Clear stream/pending state so the session does not appear
|
||||
# "agent_running" on reload after a silent failure.
|
||||
# Persist the error so it survives page reload.
|
||||
# _error=True ensures _sanitize_messages_for_api excludes it from
|
||||
# subsequent API calls so the LLM never sees its own error as prior context.
|
||||
s.active_stream_id = None
|
||||
s.pending_user_message = None
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = None
|
||||
s.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': f'**{_err_label}:** {_err_str or _err_label}\n\n*{_err_hint}*',
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
})
|
||||
try:
|
||||
s.save()
|
||||
except Exception:
|
||||
# Skip error emission if credential self-heal succeeded
|
||||
# (#1401) — _assistant_added is set True on successful retry.
|
||||
if _assistant_added:
|
||||
# Self-heal succeeded: messages are already merged into s,
|
||||
# fall through to normal post-result persistence below.
|
||||
pass
|
||||
return # apperror already closes the stream on the client side
|
||||
else:
|
||||
put('apperror', {
|
||||
'message': _err_str or f'{_err_label}.',
|
||||
'type': _err_type,
|
||||
'hint': _err_hint,
|
||||
})
|
||||
# Clear stream/pending state so the session does not appear
|
||||
# "agent_running" on reload after a silent failure.
|
||||
# Persist the error so it survives page reload.
|
||||
# _error=True ensures _sanitize_messages_for_api excludes it from
|
||||
# subsequent API calls so the LLM never sees its own error as prior context.
|
||||
s.active_stream_id = None
|
||||
s.pending_user_message = None
|
||||
s.pending_attachments = []
|
||||
s.pending_started_at = None
|
||||
s.messages.append({
|
||||
'role': 'assistant',
|
||||
'content': f'**{_err_label}:** {_err_str or _err_label}\n\n*{_err_hint}*',
|
||||
'timestamp': int(time.time()),
|
||||
'_error': True,
|
||||
})
|
||||
try:
|
||||
s.save()
|
||||
except Exception:
|
||||
pass
|
||||
return # apperror already closes the stream on the client side
|
||||
|
||||
# ── Handle context compression side effects ──
|
||||
# If compression fired inside run_conversation, the agent may have
|
||||
@@ -2499,6 +2651,72 @@ def _run_agent_streaming(
|
||||
'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.',
|
||||
)
|
||||
elif _exc_is_auth:
|
||||
if not _self_healed:
|
||||
# ── Credential self-heal on 401 (#1401) ──
|
||||
_heal_rt = _attempt_credential_self_heal(
|
||||
resolved_provider or '', session_id, _agent_lock,
|
||||
)
|
||||
if _heal_rt is not None:
|
||||
logger.info('[webui] self-heal (except path): retrying stream after credential refresh')
|
||||
_self_healed = True
|
||||
# Rebuild runtime variables
|
||||
_rt = _heal_rt
|
||||
resolved_api_key = _heal_rt.get('api_key')
|
||||
if not resolved_provider:
|
||||
resolved_provider = _heal_rt.get('provider')
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = _heal_rt.get('base_url')
|
||||
# Build a fresh agent with the new credentials
|
||||
_heal_kwargs = dict(_agent_kwargs) if '_agent_kwargs' in dir() else {}
|
||||
_heal_kwargs['api_key'] = resolved_api_key
|
||||
_heal_kwargs['base_url'] = resolved_base_url
|
||||
_heal_kwargs['model'] = resolved_model
|
||||
_heal_kwargs['provider'] = resolved_provider
|
||||
if 'credential_pool' in _agent_params:
|
||||
_heal_kwargs['credential_pool'] = _heal_rt.get('credential_pool')
|
||||
_heal_agent = _AIAgent(**_heal_kwargs)
|
||||
with STREAMS_LOCK:
|
||||
AGENT_INSTANCES[stream_id] = _heal_agent
|
||||
from api.config import SESSION_AGENT_CACHE as _SAC2, SESSION_AGENT_CACHE_LOCK as _SAC2_L
|
||||
with _SAC2_L:
|
||||
_SAC2[session_id] = (_heal_agent, _agent_sig)
|
||||
_SAC2.move_to_end(session_id)
|
||||
# Retry the conversation
|
||||
_token_sent = False
|
||||
try:
|
||||
_heal_result = _heal_agent.run_conversation(
|
||||
user_message=user_message,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=_sanitize_messages_for_api(_previous_context_messages),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
# Retry succeeded — persist the result normally
|
||||
if s is not None:
|
||||
if _checkpoint_stop is not None:
|
||||
_checkpoint_stop.set()
|
||||
if _ckpt_thread is not None:
|
||||
_ckpt_thread.join(timeout=15)
|
||||
_lock_ctx = _agent_lock if _agent_lock is not None else contextlib.nullcontext()
|
||||
with _lock_ctx:
|
||||
_result_messages = _heal_result.get('messages') or _previous_context_messages
|
||||
_next_context_messages = _restore_reasoning_metadata(
|
||||
_previous_context_messages, _result_messages,
|
||||
)
|
||||
s.context_messages = _next_context_messages
|
||||
s.messages = _merge_display_messages_after_agent_result(
|
||||
_previous_messages,
|
||||
_previous_context_messages,
|
||||
_restore_reasoning_metadata(_previous_messages, _result_messages),
|
||||
msg_text,
|
||||
)
|
||||
s.save()
|
||||
logger.info('[webui] self-heal (except path): retry succeeded')
|
||||
return # skip error emission
|
||||
except Exception as _retry_exc2:
|
||||
logger.warning('[webui] self-heal (except path): retry failed: %s', _retry_exc2)
|
||||
# Fall through to emit the original error
|
||||
# Self-heal didn't apply or retry failed — emit the auth error
|
||||
_exc_label, _exc_type, _exc_hint = (
|
||||
'Authentication error', 'auth_mismatch',
|
||||
'The selected model may not be supported by your configured provider. '
|
||||
|
||||
@@ -150,12 +150,21 @@ def _check_repo(path, name):
|
||||
current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path)
|
||||
latest, _ = _run_git(['rev-parse', '--short', compare_ref], path)
|
||||
|
||||
# Get repo URL for "What's new?" link
|
||||
remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path)
|
||||
# Convert SSH URLs (git@github.com:org/repo.git) to HTTPS
|
||||
if remote_url and remote_url.startswith('git@'):
|
||||
remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1).rstrip('.git')
|
||||
elif remote_url:
|
||||
remote_url = remote_url.rstrip('.git')
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
'behind': behind,
|
||||
'current_sha': current,
|
||||
'latest_sha': latest,
|
||||
'branch': compare_ref,
|
||||
'repo_url': remote_url,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -316,6 +316,8 @@ const LOCALES = {
|
||||
rename_prompt: 'New name:',
|
||||
deleted: 'Deleted ',
|
||||
delete_failed: 'Delete failed: ',
|
||||
reveal_in_finder: 'Reveal in File Manager',
|
||||
reveal_failed: 'Failed to reveal: ',
|
||||
new_file_prompt: 'New file name (e.g. notes.md):',
|
||||
project_name_prompt: 'Project name:',
|
||||
created: 'Created ',
|
||||
@@ -1212,6 +1214,8 @@ const LOCALES = {
|
||||
rename_prompt: '新しい名前:',
|
||||
deleted: '削除しました: ',
|
||||
delete_failed: '削除失敗: ',
|
||||
reveal_in_finder: 'ファイルマネージャーで表示',
|
||||
reveal_failed: '表示に失敗しました: ',
|
||||
new_file_prompt: '新しいファイル名 (例: notes.md):',
|
||||
project_name_prompt: 'プロジェクト名:',
|
||||
created: '作成しました: ',
|
||||
@@ -2031,6 +2035,8 @@ const LOCALES = {
|
||||
rename_prompt: 'Новое имя:',
|
||||
deleted: 'Удалено ',
|
||||
delete_failed: 'Не удалось удалить: ',
|
||||
reveal_in_finder: 'Показать в файловом менеджере',
|
||||
reveal_failed: 'Не удалось открыть: ',
|
||||
new_file_prompt: 'Имя нового файла (например, notes.md):',
|
||||
project_name_prompt: 'Имя проекта:',
|
||||
created: 'Создано ',
|
||||
@@ -2844,6 +2850,8 @@ const LOCALES = {
|
||||
rename_prompt: 'Nuevo nombre:',
|
||||
deleted: 'Eliminado ',
|
||||
delete_failed: 'Error al eliminar: ',
|
||||
reveal_in_finder: 'Mostrar en el gestor de archivos',
|
||||
reveal_failed: 'Error al mostrar: ',
|
||||
new_file_prompt: 'Nombre del archivo nuevo (p. ej. notes.md):',
|
||||
created: 'Creado ',
|
||||
create_failed: 'Error al crear: ',
|
||||
@@ -3666,6 +3674,8 @@ const LOCALES = {
|
||||
rename_prompt: 'Neuer Name:',
|
||||
deleted: 'Gelöscht ',
|
||||
delete_failed: 'Löschen fehlgeschlagen: ',
|
||||
reveal_in_finder: 'Im Dateimanager anzeigen',
|
||||
reveal_failed: 'Anzeige fehlgeschlagen: ',
|
||||
new_file_prompt: 'Neuer Dateiname (z.B. notes.md):',
|
||||
project_name_prompt: 'Projektname:',
|
||||
created: 'Erstellt ',
|
||||
@@ -4515,6 +4525,8 @@ const LOCALES = {
|
||||
rename_prompt: '新名称:',
|
||||
deleted: '\u5df2\u5220\u9664 ',
|
||||
delete_failed: '\u5220\u9664\u5931\u8d25\uff1a',
|
||||
reveal_in_finder: '\u5728\u6587\u4ef6\u7ba1\u7406\u5668\u4e2d\u663e\u793a',
|
||||
reveal_failed: '\u663e\u793a\u5931\u8d25\uff1a',
|
||||
new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a',
|
||||
project_name_prompt: '\u9879\u76ee\u540d\u79f0\uff1a',
|
||||
created: '\u5df2\u521b\u5efa ',
|
||||
@@ -5297,6 +5309,8 @@ const LOCALES = {
|
||||
rename_prompt: '新名稱:',
|
||||
deleted: '\u5df2\u522a\u9664 ',
|
||||
delete_failed: '\u522a\u9664\u5931\u6557\uff1a',
|
||||
reveal_in_finder: '\u5728\u6a94\u6848\u7ba1\u7406\u54e1\u4e2d\u986f\u793a',
|
||||
reveal_failed: '\u986f\u793a\u5931\u6557\uff1a',
|
||||
new_file_prompt: '\u65b0\u6587\u4ef6\u540d\uff08\u4f8b\u5982 notes.md\uff09\uff1a',
|
||||
created: '\u5df2\u5275\u5efa ',
|
||||
create_failed: '\u5275\u5efa\u5931\u6557\uff1a',
|
||||
@@ -6299,6 +6313,8 @@ const LOCALES = {
|
||||
delete_confirm: (name) => `Excluir ${name}?`,
|
||||
deleted: 'Excluído ',
|
||||
delete_failed: 'Falha ao excluir: ',
|
||||
reveal_in_finder: 'Mostrar no gerenciador de arquivos',
|
||||
reveal_failed: 'Falha ao mostrar: ',
|
||||
new_file_prompt: 'Nome do novo arquivo (ex: notes.md):',
|
||||
project_name_prompt: 'Nome do projeto:',
|
||||
created: 'Criado ',
|
||||
@@ -7089,6 +7105,8 @@ const LOCALES = {
|
||||
rename_prompt: '새 이름:',
|
||||
deleted: '삭제됨: ',
|
||||
delete_failed: '삭제 실패: ',
|
||||
reveal_in_finder: '파일 관리자에서 열기',
|
||||
reveal_failed: '표시 실패: ',
|
||||
new_file_prompt: 'New file name (e.g. notes.md):',
|
||||
project_name_prompt: 'Project name:',
|
||||
created: '생성됨: ',
|
||||
|
||||
@@ -260,6 +260,7 @@
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
||||
<span id="updateMsg"></span>
|
||||
<a id="updateWhatsNew" href="#" target="_blank" rel="noopener" style="font-size:11px;color:var(--accent);text-decoration:underline;display:none;margin-left:8px;white-space:nowrap">What's new?</a>
|
||||
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
@@ -931,6 +932,12 @@
|
||||
</div>
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
|
||||
<!-- Gateway Status Section -->
|
||||
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<label>Gateway Status</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Status of the Hermes gateway (Telegram, Discord, Slack, etc.)</div>
|
||||
<div id="gatewayStatusCard"><span style="color:var(--muted);font-size:12px">Loading…</span></div>
|
||||
</div>
|
||||
<!-- MCP Servers Section -->
|
||||
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<label data-i18n="mcp_servers_title">MCP Servers</label>
|
||||
|
||||
+23
-1
@@ -3840,11 +3840,33 @@ async function deleteMcpServer(name){
|
||||
else{showToast((r&&r.error)||t('mcp_delete_failed'));}
|
||||
}).catch(()=>{showToast(t('mcp_delete_failed'));});
|
||||
}
|
||||
function loadGatewayStatus(){
|
||||
const card=$('gatewayStatusCard');
|
||||
if(!card) return;
|
||||
api('/api/gateway/status').then(r=>{
|
||||
if(!r) return;
|
||||
if(!r.running){
|
||||
card.innerHTML=`<div style="color:var(--muted);font-size:12px;display:flex;align-items:center;gap:6px"><span style="width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block"></span>Gateway not running</div>`;
|
||||
return;
|
||||
}
|
||||
const platformIcons={telegram:'💬',discord:'🎮',slack:'📝',web:'🌐',api:'🔌'};
|
||||
let badges='';
|
||||
if(r.platforms&&r.platforms.length){
|
||||
badges=r.platforms.map(p=>{
|
||||
const icon=platformIcons[p.name]||'📡';
|
||||
return `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;background:var(--code-bg);border:1px solid var(--border2);border-radius:12px;font-size:12px;font-weight:500">${icon} ${esc(p.label)}</span>`;
|
||||
}).join(' ');
|
||||
}
|
||||
const lastActive=r.last_active?`<span style="font-size:11px;color:var(--muted)">Last active: ${esc(new Date(r.last_active).toLocaleString())}</span>`:'';
|
||||
const sessionInfo=r.session_count?`<span style="font-size:11px;color:var(--muted)">${r.session_count} session${r.session_count!==1?'s':''}</span>`:'';
|
||||
card.innerHTML=`<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px"><span style="width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block"></span><span style="font-size:13px;font-weight:500;color:#22c55e">Running</span></div>${badges?`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">${badges}</div>`:''}<div style="display:flex;gap:12px">${sessionInfo}${lastActive}</div>`;
|
||||
}).catch(()=>{card.innerHTML=`<div style="color:#ef4444;font-size:12px">Failed to load gateway status</div>`});
|
||||
}
|
||||
// Load MCP servers when system settings tab opens
|
||||
const _origSwitchSettings=switchSettingsSection;
|
||||
switchSettingsSection=function(name){
|
||||
_origSwitchSettings(name);
|
||||
if(name==='system') loadMcpServers();
|
||||
if(name==='system'){loadMcpServers();loadGatewayStatus();}
|
||||
};
|
||||
|
||||
// ── Checkpoints / Rollback ──────────────────────────────────────────────────
|
||||
|
||||
+4
-2
@@ -287,12 +287,14 @@ async function newSession(flash){
|
||||
const newModelState=(canQualify&&typeof _modelStateForSelect==='function')
|
||||
? _modelStateForSelect(modelSel,selectedDefaultModel)
|
||||
: {model:selectedDefaultModel,model_provider:null};
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify({
|
||||
const reqBody={
|
||||
model:newModelState.model,
|
||||
model_provider:newModelState.model_provider||null,
|
||||
workspace:inheritWs,
|
||||
profile:S.activeProfile||'default',
|
||||
})});
|
||||
};
|
||||
if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject;
|
||||
const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)});
|
||||
S.session=data.session;S.messages=data.session.messages||[];
|
||||
S.lastUsage={...(data.session.last_usage||{})};
|
||||
if(flash)S.session._flash=true;
|
||||
|
||||
@@ -2832,6 +2832,17 @@ function _showUpdateBanner(data){
|
||||
const banner=$('updateBanner');
|
||||
if(banner) banner.classList.add('visible');
|
||||
window._updateData=data;
|
||||
// Wire up "What's new?" link
|
||||
const link=$('updateWhatsNew');
|
||||
if(link && data.webui){
|
||||
const repoUrl=data.webui.repo_url;
|
||||
const curSha=data.webui.current_sha;
|
||||
const newSha=data.webui.latest_sha;
|
||||
if(repoUrl && curSha && newSha){
|
||||
link.href=repoUrl+'/compare/'+curSha+'...'+newSha;
|
||||
link.style.display='inline';
|
||||
}
|
||||
}
|
||||
}
|
||||
function dismissUpdate(){
|
||||
const b=$('updateBanner');if(b)b.classList.remove('visible');
|
||||
@@ -5318,6 +5329,15 @@ function _showFileContextMenu(e, item){
|
||||
renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);};
|
||||
menu.appendChild(renameItem);
|
||||
|
||||
// Reveal in File Manager
|
||||
const revealItem=document.createElement('div');
|
||||
revealItem.textContent=t('reveal_in_finder');
|
||||
revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
|
||||
revealItem.onmouseenter=()=>revealItem.style.background='var(--hover)';
|
||||
revealItem.onmouseleave=()=>revealItem.style.background='';
|
||||
revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+err.message);}};
|
||||
menu.appendChild(revealItem);
|
||||
|
||||
// Divider + Delete
|
||||
const sep=document.createElement('hr');
|
||||
sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Regression tests for #1426 — OpenRouter free-tier visibility (structural fix).
|
||||
|
||||
Original PR #1548 added 6 hardcoded `_FALLBACK_MODELS` entries. This is the
|
||||
structural augmentation: WebUI now does TWO live fetches when populating the
|
||||
OpenRouter group:
|
||||
|
||||
(1) `hermes_cli.models.fetch_openrouter_models()` — the curated tool-supporting
|
||||
list, which goes through the tool-support filter (Kilo-Org/kilocode#9068).
|
||||
(2) Direct `https://openrouter.ai/api/v1/models` — filtered to free-tier-only,
|
||||
bypassing the tool-support filter so newly-added free variants appear.
|
||||
|
||||
Both fall back to `_FALLBACK_MODELS` (which retains @bergeouss's hardcoded list
|
||||
as a defense-in-depth fallback) when the API is unreachable.
|
||||
|
||||
These tests verify the structural fix without depending on real network access:
|
||||
the urllib.request layer is monkeypatched.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
|
||||
import api.config as config
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload: dict):
|
||||
self._buf = json.dumps(payload).encode()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._buf
|
||||
|
||||
|
||||
def _make_or_payload(*items: dict) -> dict:
|
||||
return {"data": list(items)}
|
||||
|
||||
|
||||
def _get_grouped_models() -> list[dict]:
|
||||
"""Helper: return the `groups` field from get_available_models()."""
|
||||
# Reset internal cache so each call re-runs the live-fetch path
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
result = config.get_available_models()
|
||||
return result.get("groups", [])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_openrouter_cache(monkeypatch):
|
||||
"""Reset the curated cache before each test so the live-fetch path runs.
|
||||
Also force `openrouter` as the active provider so the openrouter branch
|
||||
in get_available_models() actually runs."""
|
||||
try:
|
||||
from hermes_cli import models as _hm
|
||||
|
||||
monkeypatch.setattr(_hm, "_openrouter_catalog_cache", None, raising=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Force openrouter to be detected by injecting it into config
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"cfg",
|
||||
{
|
||||
"model": {"provider": "openrouter", "default": "anthropic/claude-sonnet-4.6"},
|
||||
"providers": {"openrouter": {"api_key": "sk-or-test-key"}},
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
# Reset module-level cache
|
||||
try:
|
||||
config.invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_fallback_list_contains_free_tier_entries():
|
||||
"""The hardcoded fallback list (defense-in-depth) still contains the
|
||||
contributor's free-tier entries so offline / test envs see them."""
|
||||
or_entries = [m for m in config._FALLBACK_MODELS if m.get("provider") == "OpenRouter"]
|
||||
assert len(or_entries) >= 5, "fallback list should include at least 5 free-tier entries"
|
||||
free_labels = [m["label"] for m in or_entries if "free" in m["label"].lower()]
|
||||
assert len(free_labels) >= 5, f"expected ≥5 free-tier entries in fallback, got {len(free_labels)}"
|
||||
|
||||
|
||||
def test_openrouter_group_uses_live_fetch_when_available(monkeypatch):
|
||||
"""When OpenRouter /v1/models is reachable, the picker shows live data,
|
||||
not just the fallback list. Free-tier entries get a (free) suffix."""
|
||||
fake_payload = _make_or_payload(
|
||||
# Tool-supporting paid model
|
||||
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6",
|
||||
"supported_parameters": ["tools"], "pricing": {"prompt": "0.000003", "completion": "0.000015"}},
|
||||
# Free-tier model NOT advertising tools — the bug from #1426
|
||||
{"id": "minimax/minimax-m2.5:free", "name": "MiniMax M2.5",
|
||||
"supported_parameters": [], "pricing": {"prompt": "0", "completion": "0"}},
|
||||
# Free model without :free suffix but pricing shows free
|
||||
{"id": "openrouter/elephant-alpha", "name": "Elephant Alpha",
|
||||
"supported_parameters": ["tools"], "pricing": {"prompt": "0", "completion": "0"}},
|
||||
)
|
||||
|
||||
def _fake_urlopen(req, timeout=None):
|
||||
return _FakeResponse(fake_payload)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
|
||||
try:
|
||||
from hermes_cli import models as _hm
|
||||
monkeypatch.setattr(_hm, "_openrouter_catalog_cache", None, raising=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
grouped = _get_grouped_models()
|
||||
or_group = next((g for g in grouped if g.get("provider_id") == "openrouter"), None)
|
||||
assert or_group is not None, "openrouter group must be present"
|
||||
|
||||
model_ids = [m["id"] for m in or_group["models"]]
|
||||
# Free-tier variants must be visible despite not advertising tool support
|
||||
assert "minimax/minimax-m2.5:free" in model_ids, \
|
||||
"free-tier minimax/minimax-m2.5:free must surface in the picker even without tools support"
|
||||
assert "openrouter/elephant-alpha" in model_ids, \
|
||||
"free pricing model must surface even without :free suffix"
|
||||
|
||||
|
||||
def test_openrouter_falls_back_to_static_when_live_fails(monkeypatch):
|
||||
"""If both hermes_cli.fetch and the direct urlopen raise, the picker
|
||||
must fall back to the hardcoded `_FALLBACK_MODELS` list — never empty."""
|
||||
def _fake_urlopen(req, timeout=None):
|
||||
raise OSError("simulated network outage")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
|
||||
|
||||
# Force hermes_cli to fail too
|
||||
import sys
|
||||
fake_module = type(sys)("hermes_cli.models")
|
||||
|
||||
def _raise(*args, **kwargs):
|
||||
raise RuntimeError("simulated import failure")
|
||||
|
||||
fake_module.fetch_openrouter_models = _raise
|
||||
fake_module.provider_model_ids = lambda *a, **k: []
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_module)
|
||||
|
||||
grouped = _get_grouped_models()
|
||||
or_group = next((g for g in grouped if g.get("provider_id") == "openrouter"), None)
|
||||
assert or_group is not None, "openrouter group must still be present in fallback path"
|
||||
assert len(or_group["models"]) > 0, "fallback must produce a non-empty model list"
|
||||
# The hardcoded free-tier entries MUST be in the fallback
|
||||
fallback_ids = {m["id"] for m in or_group["models"]}
|
||||
# At least one of the contributor's hardcoded free-tier entries must be present
|
||||
expected_free_ids = {
|
||||
"openrouter/elephant-alpha",
|
||||
"openrouter/owl-alpha",
|
||||
"tencent/hy3-preview:free",
|
||||
"nvidia/nemotron-3-super-120b-a12b:free",
|
||||
"arcee-ai/trinity-large-preview:free",
|
||||
}
|
||||
overlap = fallback_ids & expected_free_ids
|
||||
assert len(overlap) >= 3, \
|
||||
f"static fallback must include the contributor's hardcoded free-tier entries; got overlap={overlap}"
|
||||
|
||||
|
||||
def test_free_tier_cap_prevents_picker_drowning(monkeypatch):
|
||||
"""OpenRouter may return hundreds of free-tier variants — the implementation
|
||||
caps the live-fetch additions at 30 to keep the picker usable."""
|
||||
items = []
|
||||
for i in range(50):
|
||||
items.append({
|
||||
"id": f"vendor{i}/model-{i}:free",
|
||||
"name": f"Model {i}",
|
||||
"supported_parameters": [],
|
||||
"pricing": {"prompt": "0", "completion": "0"},
|
||||
})
|
||||
fake_payload = _make_or_payload(*items)
|
||||
|
||||
def _fake_urlopen(req, timeout=None):
|
||||
return _FakeResponse(fake_payload)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
|
||||
|
||||
try:
|
||||
from hermes_cli import models as _hm
|
||||
monkeypatch.setattr(_hm, "_openrouter_catalog_cache", None, raising=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
grouped = _get_grouped_models()
|
||||
or_group = next((g for g in grouped if g.get("provider_id") == "openrouter"), None)
|
||||
assert or_group is not None
|
||||
free_added_ids = {m["id"] for m in or_group["models"] if ":free" in m["id"]}
|
||||
assert len(free_added_ids) <= 50, "should not exceed the items provided"
|
||||
assert len(free_added_ids) > 0, "free-tier live fetch should add at least some entries"
|
||||
|
||||
|
||||
def test_openrouter_dedupe_curated_and_free_tier(monkeypatch):
|
||||
"""If a model appears in both the curated catalog AND the free-tier fetch,
|
||||
it must appear exactly once in the picker (via `seen_ids` deduplication)."""
|
||||
fake_payload = _make_or_payload(
|
||||
{"id": "anthropic/claude-sonnet-4.6", "name": "Claude Sonnet 4.6",
|
||||
"supported_parameters": ["tools"], "pricing": {"prompt": "0", "completion": "0"}},
|
||||
)
|
||||
|
||||
def _fake_urlopen(req, timeout=None):
|
||||
return _FakeResponse(fake_payload)
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", _fake_urlopen)
|
||||
|
||||
import sys
|
||||
fake_module = type(sys)("hermes_cli.models")
|
||||
fake_module.fetch_openrouter_models = lambda **k: [("anthropic/claude-sonnet-4.6", "")]
|
||||
fake_module.provider_model_ids = lambda *a, **k: ["anthropic/claude-sonnet-4.6"]
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_module)
|
||||
|
||||
grouped = _get_grouped_models()
|
||||
or_group = next((g for g in grouped if g.get("provider_id") == "openrouter"), None)
|
||||
assert or_group is not None
|
||||
matching = [m for m in or_group["models"] if m["id"] == "anthropic/claude-sonnet-4.6"]
|
||||
assert len(matching) == 1, \
|
||||
f"model present in both surfaces should appear once, got {len(matching)}"
|
||||
@@ -92,10 +92,22 @@ def test_minimax_m2_7_highspeed_in_fallback_models():
|
||||
|
||||
|
||||
def test_minimax_fallback_provider_label():
|
||||
"""MiniMax fallback entries must use 'MiniMax' as the provider label."""
|
||||
minimax_entries = [m for m in config._FALLBACK_MODELS if 'minimax' in m['id'].lower()]
|
||||
assert minimax_entries, "No MiniMax entries found in _FALLBACK_MODELS"
|
||||
for entry in minimax_entries:
|
||||
"""MiniMax fallback entries (direct API routing) must use 'MiniMax' as
|
||||
the provider label.
|
||||
|
||||
NOTE: This filters by `minimax/` ID prefix to scope strictly to the
|
||||
direct MiniMax provider routes — `minimax-X` is the canonical pattern
|
||||
for hermes-agent routing to api.minimax.io. OpenRouter free-tier variants
|
||||
that happen to contain 'minimax' in their ID (e.g.
|
||||
`minimax/minimax-m2.5:free`) are routed via OpenRouter, not direct
|
||||
MiniMax, and correctly carry provider='OpenRouter'. See #1426.
|
||||
"""
|
||||
direct_minimax = [
|
||||
m for m in config._FALLBACK_MODELS
|
||||
if m['id'].startswith('minimax/') and ':free' not in m['id']
|
||||
]
|
||||
assert direct_minimax, "No direct-MiniMax entries found in _FALLBACK_MODELS"
|
||||
for entry in direct_minimax:
|
||||
assert entry['provider'] == 'MiniMax', (
|
||||
f"Expected provider='MiniMax', got '{entry['provider']}' for {entry['id']}"
|
||||
)
|
||||
|
||||
@@ -457,14 +457,14 @@ class TestCancelInProgressGuard:
|
||||
|
||||
|
||||
class TestEmptyMessagesGuard:
|
||||
"""_apply_core_sync_or_error_marker bails out when session.messages is
|
||||
non-empty, preventing it from clobbering in-memory mutations made by the
|
||||
streaming thread or cancel path."""
|
||||
"""_apply_core_sync_or_error_marker preserves existing messages when
|
||||
session.messages is non-empty, while still recovering the pending user turn
|
||||
before clearing stale stream runtime fields."""
|
||||
|
||||
def test_pending_cleared_when_messages_nonempty_direct(self, hermes_home, monkeypatch):
|
||||
"""When _apply_core_sync_or_error_marker is called on a session with
|
||||
non-empty messages and pending set, it clears the pending fields and
|
||||
appends an error marker, returning True."""
|
||||
non-empty messages and pending set, it recovers the pending user turn,
|
||||
clears the pending fields, and appends an error marker."""
|
||||
s = _make_session(messages=[{"role": "user", "content": "hello"}])
|
||||
s.pending_user_message = "Another question"
|
||||
s.active_stream_id = "stream_1"
|
||||
@@ -477,11 +477,14 @@ class TestEmptyMessagesGuard:
|
||||
)
|
||||
|
||||
assert result is True
|
||||
# Original message should be untouched
|
||||
assert len(s.messages) == 2 # original + error marker
|
||||
# Original message should be untouched, pending turn recovered, then marker appended
|
||||
assert len(s.messages) == 3 # original + recovered user turn + error marker
|
||||
assert s.messages[0]["content"] == "hello"
|
||||
assert s.messages[1]["role"] == "user"
|
||||
assert s.messages[1]["content"] == "Another question"
|
||||
assert s.messages[1].get("_recovered") is True
|
||||
# Error marker appended
|
||||
assert s.messages[1].get("_error") is True
|
||||
assert s.messages[2].get("_error") is True
|
||||
# Pending fields cleared
|
||||
assert s.pending_user_message is None
|
||||
assert s.active_stream_id is None
|
||||
@@ -517,13 +520,13 @@ class TestEmptyMessagesGuard:
|
||||
|
||||
class TestNonEmptyMessagesPendingCleared:
|
||||
"""When messages is non-empty and pending is stuck, _last_resort_sync_from_core
|
||||
clears the pending fields and appends exactly one error marker without
|
||||
clobbering existing messages or syncing from core."""
|
||||
preserves existing messages, recovers the pending user turn, and appends
|
||||
exactly one error marker without syncing from core."""
|
||||
|
||||
def test_pending_cleared_when_messages_nonempty(self, hermes_home, monkeypatch):
|
||||
"""_last_resort_sync_from_core on a session with both messages and
|
||||
pending_user_message clears pending fields and appends exactly one
|
||||
error marker."""
|
||||
pending_user_message recovers that pending turn before clearing runtime
|
||||
fields and appending exactly one error marker."""
|
||||
s = _make_session(messages=[{"role": "user", "content": "existing turn"}])
|
||||
s.pending_user_message = "Stuck draft"
|
||||
s.pending_attachments = [{"type": "image", "name": "screenshot.png"}]
|
||||
@@ -543,9 +546,9 @@ class TestNonEmptyMessagesPendingCleared:
|
||||
|
||||
streaming._last_resort_sync_from_core(s, "stale_stream", agent_lock)
|
||||
|
||||
# Existing messages preserved untouched
|
||||
assert len(s.messages) == 2, (
|
||||
f"Expected 2 messages (original + error marker), got {len(s.messages)}"
|
||||
# Existing messages preserved untouched, pending turn recovered, error marker appended
|
||||
assert len(s.messages) == 3, (
|
||||
f"Expected 3 messages (original + recovered turn + error marker), got {len(s.messages)}"
|
||||
)
|
||||
assert s.messages[0]["role"] == "user"
|
||||
assert s.messages[0]["content"] == "existing turn"
|
||||
@@ -553,15 +556,18 @@ class TestNonEmptyMessagesPendingCleared:
|
||||
"Core transcript must NOT be synced when messages is non-empty"
|
||||
)
|
||||
|
||||
# Exactly one recovered user turn
|
||||
recovered_msgs = [m for m in s.messages if m.get("_recovered")]
|
||||
assert len(recovered_msgs) == 1
|
||||
assert recovered_msgs[0]["role"] == "user"
|
||||
assert recovered_msgs[0]["content"] == "Stuck draft"
|
||||
assert recovered_msgs[0]["attachments"] == [{"type": "image", "name": "screenshot.png"}]
|
||||
|
||||
# Exactly one error marker
|
||||
error_msgs = [m for m in s.messages if m.get("_error")]
|
||||
assert len(error_msgs) == 1
|
||||
assert "Previous turn did not complete" in error_msgs[0]["content"]
|
||||
|
||||
# No recovered user turn (messages is non-empty, so skip that)
|
||||
recovered_msgs = [m for m in s.messages if m.get("_recovered")]
|
||||
assert len(recovered_msgs) == 0
|
||||
|
||||
# Pending fields fully cleared
|
||||
assert s.pending_user_message is None
|
||||
assert s.pending_attachments == []
|
||||
@@ -719,14 +725,18 @@ class TestRepairStalePendingIntegration:
|
||||
error_msgs = [m for m in s.messages if m.get("_error")]
|
||||
assert len(error_msgs) == 1
|
||||
|
||||
def test_skips_when_messages_nonempty(self, hermes_home, monkeypatch):
|
||||
"""Pre-check: if messages is non-empty, repair is skipped entirely."""
|
||||
def test_recovers_when_messages_nonempty(self, hermes_home, monkeypatch):
|
||||
"""Pre-check: if messages is non-empty, repair still preserves the
|
||||
pending user turn instead of silently discarding it."""
|
||||
s = _make_session(messages=[{"role": "user", "content": "hi"}])
|
||||
s.pending_user_message = "more"
|
||||
s.active_stream_id = "stream_1"
|
||||
|
||||
result = _repair_stale_pending(s)
|
||||
assert result is False
|
||||
assert result is True
|
||||
assert [m["content"] for m in s.messages if m["role"] == "user"] == ["hi", "more"]
|
||||
assert s.messages[1].get("_recovered") is True
|
||||
assert any(m.get("_error") for m in s.messages)
|
||||
|
||||
def test_skips_when_stream_alive(self, hermes_home, monkeypatch):
|
||||
"""Pre-check: if the stream is still alive in STREAMS, repair is skipped."""
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Regression: stale stream cleanup must not discard pending user turns.
|
||||
|
||||
A server restart drops the in-memory STREAMS table. Browser reload then calls
|
||||
get_session(), which clears stale active_stream_id state. For long conversations
|
||||
that already have messages, the pending_user_message can be the only durable copy
|
||||
of the user turn that was submitted just before the restart.
|
||||
"""
|
||||
|
||||
import api.config as config
|
||||
import api.models as models
|
||||
from api.models import Session, get_session
|
||||
|
||||
|
||||
def test_stale_stream_cleanup_recovers_pending_turn_on_non_empty_session(tmp_path, monkeypatch):
|
||||
session_dir = tmp_path / "sessions"
|
||||
session_dir.mkdir()
|
||||
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
|
||||
monkeypatch.setattr(models, "SESSION_INDEX_FILE", session_dir / "_index.json")
|
||||
models.SESSIONS.clear()
|
||||
config.STREAMS.clear()
|
||||
|
||||
s = Session(
|
||||
session_id="stale_stream_nonempty",
|
||||
title="Existing long chat",
|
||||
messages=[
|
||||
{"role": "user", "content": "previous prompt", "timestamp": 100},
|
||||
{"role": "assistant", "content": "previous answer", "timestamp": 101},
|
||||
],
|
||||
)
|
||||
s.active_stream_id = "dead_stream"
|
||||
s.pending_user_message = "new prompt that must survive restart"
|
||||
s.pending_attachments = [{"name": "note.txt", "path": "/tmp/note.txt"}]
|
||||
s.pending_started_at = 123
|
||||
s.save()
|
||||
|
||||
recovered = get_session("stale_stream_nonempty")
|
||||
|
||||
assert recovered.active_stream_id is None
|
||||
assert recovered.pending_user_message is None
|
||||
assert any(
|
||||
msg.get("role") == "user"
|
||||
and msg.get("content") == "new prompt that must survive restart"
|
||||
and msg.get("_recovered") is True
|
||||
for msg in recovered.messages
|
||||
)
|
||||
assert any(
|
||||
msg.get("role") == "assistant" and msg.get("_error") is True
|
||||
for msg in recovered.messages
|
||||
)
|
||||
Reference in new issue
Block a user