mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-26 11:40:26 +00:00
Merge pull request #1626 from nesquena/stage-292
Release v0.50.292 — 12-PR batch (multi-tab SSE + subpath routes + 3 follow-ups + UX polish)
This commit is contained in:
@@ -1,5 +1,55 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.292] — 2026-05-04
|
||||
|
||||
### Fixed (12 PRs — multi-tab SSE + subpath routes + cross-source lineage + paste UX + 3 follow-ups)
|
||||
|
||||
- **Multi-tab SSE no longer splits stream tokens between tabs** (#1598 by @Michaelyklam, closes #1584) — `api/config.py` introduces a `StreamChannel` broadcast class to replace the single-consumer `queue.Queue` previously stored in `STREAMS[stream_id]`. With the old design, the same session in two tabs was racing to consume tokens from one queue, so one tab might receive `H` while the other received `allo`. The new channel buffers events while no subscriber is connected (so the first tab sees the stream tail that arrived during the gap), and once one or more tabs are subscribed it broadcasts every event to all of them. `_handle_sse_stream()` calls `subscribe()` on connect and `unsubscribe()` in a `finally` block on disconnect/error. Per-stream wiring updated at all three producer callsites (`_handle_chat_start`, `_handle_btw`, `_handle_background`). Per Opus advisor on stage-292: replay-while-subscribing now happens inside the lock to prevent an event-ordering inversion when a 2nd tab subscribes mid-stream.
|
||||
|
||||
- **Frontend routes now work under subpath mounts like `/hermes/`** (#1601 by @Michaelyklam) — auth redirect Location header (`api/auth.py`), 401-redirect helpers (`static/ui.js`, `static/workspace.js`), direct fetch/EventSource URLs (`static/{boot,messages,sessions}.js`), and the SMD vendor module import (`static/index.html`) all switched from root-absolute (`/login`, `/api/...`, `/static/...`) to mount-relative (`login`, `api/...`, `static/...`). Where appropriate, the mount-relative URL is anchored against `document.baseURI || location.href` so the `<base href>` element correctly resolves it under deep SPA routes. Per Opus advisor on stage-292: the gateway SSE probe in `static/sessions.js:1440` now also uses `document.baseURI || location.href` for parity with the other 5 callsites in this PR, ensuring it doesn't 404 under subpath at deep routes. Self-hosters running WebUI behind a reverse proxy or container ingress at a path prefix can now have everything work without Caddy/nginx rewrite workarounds.
|
||||
|
||||
- **Streaming markdown now formats live segments under subpath mounts** (#1600 by @Michaelyklam) — `static/index.html` SMD module import switched to mount-relative form. `static/messages.js` fallback path (when `window.smd` isn't loaded) now passes the visible segment through `renderMd(fallbackText)` for the FIRST live segment as well as post-tool segments — previously the first segment was inserted as raw `parsed.displayText`, leaving markdown visible until the assistant's turn completed.
|
||||
|
||||
- **Cross-source session continuations stay separate in the sidebar** (#1602 by @ai-ag2026) — `api/agent_sessions.py:_is_continuation_session()` now refuses to collapse parent/child where `parent.source != child.source`. A WebUI session continuing from a Telegram/CLI compression-chained parent stays visible as its own WebUI row instead of inheriting the old parent's title and source metadata. Non-continuation child rows now also expose `parent_title` + `parent_source` so the surface can show the lineage without losing the child's own identity.
|
||||
|
||||
- **Paste no longer drops text when clipboard has both text and image** (#1622 by @s905060, closes #1620) — `static/boot.js` paste handler used to intercept on any `image/*` clipboard item, calling `preventDefault()` and attaching the image as a screenshot. Pasting from rich-text sources (Notes, Word, Slack, browser selections) attaches a rendered preview alongside the plain text — so the handler swallowed the text payload and only the rogue image was attached. Now defers to the browser's default text-paste when the clipboard also carries `text/plain` or `text/html` string items, and only intercepts when the clipboard is image-only (true screenshot paste). Image filter also tightened to `kind === 'file'` so string items advertising an image MIME (e.g. `text/html` with embedded data URIs) aren't misclassified.
|
||||
|
||||
- **Forked session sidebar indicator is now recognizable and less noisy** (#1621 by @franksong2702, fixes #1613) — replaced the permanent `⑂` OCR glyph with the existing `git-branch` SVG icon, made the indicator subtle (.35 opacity) until row hover/focus/active states (.85 opacity), changed the tooltip to prefer the parent session title with a truncated-id fallback, and removed the hidden click-to-parent behavior from the sidebar row (was unpredictable). The `/branch` command and fork data model are unchanged.
|
||||
|
||||
- **Update banner now shows tracked branches in labels** (#1605 by @ai-ag2026) — `static/ui.js` and `static/panels.js` use a new `_formatUpdateTargetStatus(label, info)` formatter that includes `info.branch` parenthetical, so `WebUI (origin/master): 0 updates, Agent (origin/main): 32 updates` is displayed in mixed states instead of the generic `Agent: 32 updates` that could be misread as the WebUI being behind. Settings panel uses a typeof-guarded fallback to a local formatter for back-compat with older boot states.
|
||||
|
||||
- **Update compare URLs preserve git remote names ending in g/i/t** (#1603 by @ai-ag2026) — `api/updates.py` was using `str.rstrip('.git')` for the remote URL trim, which is a CHARACTER-CLASS strip — `'hermes-webui.git'` became `'hermes-webu'` (it strips trailing `g`, then `i`, then `.`, then more `i`'s, then `u`...). The updated logic checks `endswith('.git')` and slices the literal suffix, leaving `hermes-webui`/`hermes-agent` and any other remote name intact. Both HTTPS and SSH origin forms covered.
|
||||
|
||||
- **`_pending_started_at` truthy-check fallback** (#1599 by @Sanjays2402, closes #1595) — `api/streaming.py:2058` tightens the per-turn duration fallback from `is not None` to a truthy check so `None`, missing-attr, and an explicit `0` all uniformly fall back to `time.time()`. Closes the loop on the v0.50.290 retro lesson — the v0.50.290 contributor's source-string assertion that pinned the old `is not None` form is removed by this PR. Behavioral assertions on the duration fallback remain.
|
||||
|
||||
- **pytest config-path isolation** (#1597 by @Michaelyklam) — Hermes Agent sessions can set `HERMES_CONFIG_PATH` to the real `~/.hermes/config.yaml` before invoking pytest, so onboarding/provider tests could read/write the developer's live config. `tests/conftest.py` now overrides `HERMES_CONFIG_PATH` to point at the isolated test home before any product modules are imported. `api/providers.py:_clean_provider_key_from_config()` switches from import-time-bound `_get_config_path` to call-time resolution through `api.config._get_config_path()` so monkeypatches and tests work correctly.
|
||||
|
||||
- **Cron worker no longer silently ignores profile-context failures** (#1608 by @franksong2702, closes #1578) — `_run_cron_tracked()` no longer wraps `cron_profile_context_for_home(profile_home).__enter__()` in a `try/except Exception` that silently sets `ctx = None`. A silent fallback in the worker thread leaves the job running unpinned against process-global `HERMES_HOME`, silently corrupting cross-profile state — same class of bug as #1573. Lets the exception propagate (kill the worker thread) rather than corrupt cross-profile state. Source-level regression test catches any future re-introduction of the over-broad except clause.
|
||||
|
||||
- **TCP keepalive cleanup + macOS support** (#1609 by @franksong2702, closes #1583) — `server.py` cleanup follow-up to v0.50.289. Deletes the dead `QuietHTTPServer.server_bind()` override (TCP_KEEP* setsockopts on the listening socket are no-ops without SO_KEEPALIVE, which can't be set on a passive socket anyway). Splits `Handler.setup()` into proper ordering — TCP_NODELAY first, then SO_KEEPALIVE, then per-platform timing parameters: Linux uses `TCP_KEEPIDLE/INTVL/CNT`, macOS uses `TCP_KEEPALIVE`. Previously, on macOS, the entire try block aborted on the first `AttributeError` from `TCP_KEEPIDLE` and SO_KEEPALIVE was never applied — connections never had keepalive at all on Mac.
|
||||
|
||||
### Tests
|
||||
|
||||
4117 → **4142 passing** (+25 new regression tests across all 12 PRs). 0 regressions. Full suite in ~125s.
|
||||
|
||||
### Pre-release verification
|
||||
|
||||
- **Opus advisor**: SHIP verdict. Two SHOULD-FIX items absorbed in-release per <20-LOC defensive policy: (1) #1598 ordering race fixed by moving offline-buffer replay inside the subscribe lock; (2) #1601 sessions.js:1440 gateway SSE probe switched to `document.baseURI || location.href` for parity with PR's other 5 callsites.
|
||||
- **JS syntax**: all 6 modified .js files checked clean with `node -c`.
|
||||
- **Browser API sanity**: 11/11 endpoints OK on stage server.
|
||||
- **CHANGELOG / ROADMAP / TESTING**: stamps updated for v0.50.292 / 4142 baseline.
|
||||
|
||||
### Authors
|
||||
|
||||
- @Michaelyklam — 4 PRs (#1597, #1598, #1600, #1601)
|
||||
- @ai-ag2026 — 3 PRs (#1602, #1603, #1605)
|
||||
- @franksong2702 — 3 PRs (#1608, #1609, #1621)
|
||||
- @Sanjays2402 — 1 PR (#1599)
|
||||
- @s905060 — 1 PR (#1622)
|
||||
|
||||
Closes #1578, #1583, #1584, #1595, #1613, #1620.
|
||||
|
||||
|
||||
## [v0.50.291] — 2026-05-04
|
||||
|
||||
### Fixed (1 PR — "What's new?" link 404 — closes #1579)
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> Web companion to the Hermes Agent CLI. Same workflows, browser-native.
|
||||
>
|
||||
> Last updated: v0.50.291 (May 04, 2026) — 4117 tests collected
|
||||
> Last updated: v0.50.292 (May 04, 2026) — 4142 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.291, May 04, 2026*
|
||||
*Total automated tests collected: 4117*
|
||||
*Last updated: v0.50.292, May 04, 2026*
|
||||
*Total automated tests collected: 4142*
|
||||
*Regression gate: tests/test_regressions.py*
|
||||
*Run: pytest tests/ -v --timeout=60*
|
||||
*Source: <repo>/*
|
||||
|
||||
+13
-1
@@ -79,9 +79,18 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
should continue the same visible conversation rather than becoming a
|
||||
separate child-session row. Plain parent/child links that started before the
|
||||
parent's ended boundary remain child sessions.
|
||||
|
||||
Do not collapse lineage across raw sources. A WebUI session that continues
|
||||
from a Telegram/CLI/etc. parent must remain visible as its own surface-owned
|
||||
conversation; otherwise the tip inherits the root's title/source metadata and
|
||||
can disappear under messaging/sidebar policies.
|
||||
"""
|
||||
if not parent or not child:
|
||||
return False
|
||||
parent_source = str(parent.get('source') or '').strip().lower()
|
||||
child_source = str(child.get('source') or '').strip().lower()
|
||||
if parent_source and child_source and parent_source != child_source:
|
||||
return False
|
||||
if parent.get('end_reason') not in {'compression', 'cli_close'}:
|
||||
return False
|
||||
ended_at = parent.get('ended_at')
|
||||
@@ -133,10 +142,13 @@ def _project_agent_session_rows(rows: list[dict]) -> list[dict]:
|
||||
if not parent_id:
|
||||
continue
|
||||
children_by_parent.setdefault(parent_id, []).append(row)
|
||||
if _is_continuation_session(rows_by_id.get(parent_id), row):
|
||||
parent = rows_by_id.get(parent_id)
|
||||
if _is_continuation_session(parent, row):
|
||||
continuation_child_ids.add(row['id'])
|
||||
else:
|
||||
row['relationship_type'] = 'child_session'
|
||||
row['parent_title'] = parent.get('title') if parent else None
|
||||
row['parent_source'] = parent.get('source') if parent else None
|
||||
parent_root = _continuation_root_id(rows_by_id, parent_id)
|
||||
if parent_root:
|
||||
row['_parent_lineage_root_id'] = parent_root
|
||||
|
||||
+1
-1
@@ -256,7 +256,7 @@ def check_auth(handler, parsed) -> bool:
|
||||
# safe='/' keeps path separators readable; everything else (including
|
||||
# `?`, `&`, `=`) gets percent-encoded.
|
||||
_next = _urlparse.quote(_path_with_query, safe='/')
|
||||
handler.send_header('Location', '/login?next=' + _next)
|
||||
handler.send_header('Location', 'login?next=' + _next)
|
||||
handler.end_headers()
|
||||
return False
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -2745,6 +2746,57 @@ _INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html"
|
||||
LOCK = threading.Lock()
|
||||
SESSIONS_MAX = 100
|
||||
CHAT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class StreamChannel:
|
||||
"""Broadcast SSE events to every connected browser tab for a stream.
|
||||
|
||||
While no tab is connected, events are buffered so the first/reconnected
|
||||
subscriber still receives the stream tail that arrived during the gap.
|
||||
Once one or more subscribers are attached, new events are broadcast to all
|
||||
of them instead of being consumed destructively by a single queue reader.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._subscribers: list[queue.Queue] = []
|
||||
self._offline_buffer: list[tuple[str, object]] = []
|
||||
|
||||
def subscribe(self) -> queue.Queue:
|
||||
q: queue.Queue = queue.Queue()
|
||||
with self._lock:
|
||||
# Replay buffered events to the new subscriber INSIDE the lock so a
|
||||
# concurrent put_nowait() can't broadcast a newer event before we
|
||||
# finish replaying the older buffered tail. queue.Queue.put_nowait
|
||||
# is non-blocking on an unbounded queue, so holding the lock here
|
||||
# is safe. Per Opus advisor on stage-292.
|
||||
for item in self._offline_buffer:
|
||||
q.put_nowait(item)
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: queue.Queue) -> None:
|
||||
with self._lock:
|
||||
try:
|
||||
self._subscribers.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def put_nowait(self, item: tuple[str, object]) -> None:
|
||||
with self._lock:
|
||||
subscribers = list(self._subscribers)
|
||||
if not subscribers:
|
||||
self._offline_buffer.append(item)
|
||||
return
|
||||
self._offline_buffer.clear()
|
||||
for q in subscribers:
|
||||
q.put_nowait(item)
|
||||
|
||||
|
||||
def create_stream_channel() -> StreamChannel:
|
||||
return StreamChannel()
|
||||
|
||||
|
||||
STREAMS: dict = {}
|
||||
STREAMS_LOCK = threading.Lock()
|
||||
CANCEL_FLAGS: dict = {}
|
||||
|
||||
+7
-1
@@ -593,7 +593,13 @@ def _clean_provider_key_from_config(provider_id: str) -> None:
|
||||
from api.config import _cfg_lock
|
||||
|
||||
try:
|
||||
config_path = _get_config_path()
|
||||
# Resolve through api.config at call time instead of the function imported
|
||||
# at module load. Several tests (and some profile flows) monkeypatch the
|
||||
# config module's path resolver after api.providers has already been
|
||||
# imported; using the stale imported reference can clean the wrong
|
||||
# config.yaml.
|
||||
import api.config as _config
|
||||
config_path = _config._get_config_path()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
+20
-16
@@ -198,14 +198,10 @@ def _run_cron_tracked(job, profile_home=None):
|
||||
# (threads have no TLS, so get_active_hermes_home() can't resolve).
|
||||
ctx = None
|
||||
if profile_home is not None:
|
||||
try:
|
||||
from api.profiles import cron_profile_context_for_home
|
||||
from api.profiles import cron_profile_context_for_home
|
||||
|
||||
ctx = cron_profile_context_for_home(profile_home)
|
||||
ctx.__enter__()
|
||||
except Exception:
|
||||
logger.exception("Failed to pin profile %s for cron run", profile_home)
|
||||
ctx = None
|
||||
ctx = cron_profile_context_for_home(profile_home)
|
||||
ctx.__enter__()
|
||||
|
||||
try:
|
||||
success, output, final_response, error = run_job(job)
|
||||
@@ -332,6 +328,7 @@ from api.config import (
|
||||
get_reasoning_status,
|
||||
set_reasoning_display,
|
||||
set_reasoning_effort,
|
||||
create_stream_channel,
|
||||
)
|
||||
from api.helpers import (
|
||||
require,
|
||||
@@ -3649,9 +3646,10 @@ def _handle_list_dir(handler, parsed):
|
||||
|
||||
def _handle_sse_stream(handler, parsed):
|
||||
stream_id = parse_qs(parsed.query).get("stream_id", [""])[0]
|
||||
q = STREAMS.get(stream_id)
|
||||
if q is None:
|
||||
stream = STREAMS.get(stream_id)
|
||||
if stream is None:
|
||||
return j(handler, {"error": "stream not found"}, status=404)
|
||||
subscriber = stream.subscribe() if hasattr(stream, "subscribe") else stream
|
||||
handler.send_response(200)
|
||||
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
||||
handler.send_header("Cache-Control", "no-cache")
|
||||
@@ -3661,7 +3659,7 @@ def _handle_sse_stream(handler, parsed):
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event, data = q.get(timeout=30)
|
||||
event, data = subscriber.get(timeout=30)
|
||||
except queue.Empty:
|
||||
handler.wfile.write(b": heartbeat\n\n")
|
||||
handler.wfile.flush()
|
||||
@@ -3671,6 +3669,12 @@ def _handle_sse_stream(handler, parsed):
|
||||
break
|
||||
except _CLIENT_DISCONNECT_ERRORS:
|
||||
pass
|
||||
finally:
|
||||
if subscriber is not stream and hasattr(stream, "unsubscribe"):
|
||||
try:
|
||||
stream.unsubscribe(subscriber)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
@@ -4812,9 +4816,9 @@ def _handle_btw(handler, body):
|
||||
stream_id = uuid.uuid4().hex
|
||||
ephemeral.active_stream_id = stream_id
|
||||
ephemeral.save()
|
||||
q = queue.Queue()
|
||||
stream = create_stream_channel()
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = q
|
||||
STREAMS[stream_id] = stream
|
||||
from api.background import track_btw
|
||||
track_btw(body["session_id"], ephemeral.session_id, stream_id, question)
|
||||
thr = threading.Thread(
|
||||
@@ -4858,9 +4862,9 @@ def _handle_background(handler, body):
|
||||
stream_id = uuid.uuid4().hex
|
||||
bg.active_stream_id = stream_id
|
||||
bg.save()
|
||||
q = queue.Queue()
|
||||
stream = create_stream_channel()
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = q
|
||||
STREAMS[stream_id] = stream
|
||||
task_id = uuid.uuid4().hex[:8]
|
||||
from api.background import track_background, complete_background
|
||||
parent_sid = body["session_id"]
|
||||
@@ -4974,9 +4978,9 @@ def _handle_chat_start(handler, body):
|
||||
s.pending_started_at = time.time()
|
||||
s.save()
|
||||
set_last_workspace(workspace)
|
||||
q = queue.Queue()
|
||||
stream = create_stream_channel()
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = q
|
||||
STREAMS[stream_id] = stream
|
||||
thr = threading.Thread(
|
||||
target=_run_agent_streaming,
|
||||
args=(s.session_id, msg, model, workspace, stream_id, attachments),
|
||||
|
||||
+4
-2
@@ -2054,8 +2054,10 @@ def _run_agent_streaming(
|
||||
agent.ephemeral_system_prompt = _personality_prompt
|
||||
_pending_started_at = getattr(s, 'pending_started_at', None)
|
||||
# Normal chat-start sets pending_started_at before spawning this thread;
|
||||
# fallback to now only for recovered/legacy flows where that marker is absent.
|
||||
_turn_started_at = _pending_started_at if _pending_started_at is not None else time.time()
|
||||
# fallback to now only for recovered/legacy flows where that marker is absent
|
||||
# or has been zeroed out (e.g. via a buggy migration / manual file edit).
|
||||
# Truthy-check covers None, missing-attr, and 0 uniformly.
|
||||
_turn_started_at = _pending_started_at if _pending_started_at else time.time()
|
||||
_previous_messages = list(s.messages or [])
|
||||
_previous_context_messages = list(_session_context_messages(s))
|
||||
_pre_compression_count = getattr(
|
||||
|
||||
+3
-3
@@ -207,9 +207,9 @@ def _check_repo(path, name):
|
||||
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')
|
||||
remote_url = remote_url.replace(':', '/', 1).replace('git@', 'https://', 1)
|
||||
if remote_url and remote_url.endswith('.git'):
|
||||
remote_url = remote_url[:-4]
|
||||
|
||||
return {
|
||||
'name': name,
|
||||
|
||||
@@ -27,19 +27,6 @@ class QuietHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
request_queue_size = 64
|
||||
|
||||
def server_bind(self):
|
||||
"""Set socket options to prevent TIME_WAIT and CLOSE-WAIT accumulation."""
|
||||
# Enable address reuse to avoid "Address already in use" errors
|
||||
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
# Enable TCP keepalive to detect dead connections (Linux)
|
||||
try:
|
||||
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) # Start probing after 60s idle
|
||||
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) # Probe every 10s
|
||||
self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) # Drop after 3 failed probes
|
||||
except (OSError, AttributeError):
|
||||
pass # TCP_KEEP* may not be available on all platforms
|
||||
super().server_bind()
|
||||
|
||||
def handle_error(self, request, client_address):
|
||||
"""Override to suppress logging for common client disconnect errors."""
|
||||
exc_type, exc_value, _ = sys.exc_info()
|
||||
@@ -63,19 +50,31 @@ class Handler(BaseHTTPRequestHandler):
|
||||
timeout = 30 # seconds — kills idle/incomplete connections to prevent thread exhaustion
|
||||
|
||||
def setup(self):
|
||||
"""Set additional socket options for each connection."""
|
||||
"""Set socket options for each accepted connection."""
|
||||
super().setup()
|
||||
# Enable TCP keepalive on the connection socket (not just server socket)
|
||||
# TCP_NODELAY — universal, disables Nagle for HTTP latency
|
||||
try:
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
except OSError:
|
||||
pass
|
||||
# SO_KEEPALIVE — universal master switch (must be set before timing params)
|
||||
try:
|
||||
import socket
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) # Disable Nagle's algorithm
|
||||
# Aggressive keepalive: start after 10s idle, probe every 5s, drop after 3 failures
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5)
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
|
||||
self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
except (OSError, AttributeError):
|
||||
pass # May not be available on all platforms
|
||||
except OSError:
|
||||
pass
|
||||
# Per-platform timing parameters
|
||||
if hasattr(socket, 'TCP_KEEPIDLE'): # Linux
|
||||
try:
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 5)
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3)
|
||||
except OSError:
|
||||
pass
|
||||
elif hasattr(socket, 'TCP_KEEPALIVE'): # macOS
|
||||
try:
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 10)
|
||||
except OSError:
|
||||
pass
|
||||
_ver_suffix = WEBUI_VERSION.removeprefix('v')
|
||||
server_version = ('HermesWebUI/' + _ver_suffix) if _ver_suffix != 'unknown' else 'HermesWebUI'
|
||||
def log_message(self, fmt, *args): pass # suppress default Apache-style log
|
||||
|
||||
+10
-3
@@ -965,8 +965,15 @@ document.addEventListener('keydown',async e=>{
|
||||
});
|
||||
$('msg').addEventListener('paste',e=>{
|
||||
const items=Array.from(e.clipboardData?.items||[]);
|
||||
const imageItems=items.filter(i=>i.type.startsWith('image/'));
|
||||
if(!imageItems.length)return;
|
||||
// When the clipboard carries BOTH text and an image (common from Notes,
|
||||
// Word, browsers, Slack — the OS attaches a rendered preview alongside
|
||||
// the plain text), prefer the text and let the browser paste normally.
|
||||
// Only intercept when the clipboard is image-only (true screenshot paste).
|
||||
// Tighten the image filter to kind==='file' so string items advertising an
|
||||
// image MIME (e.g. text/html with an embedded data URI) are not misclassified.
|
||||
const hasText=items.some(i=>i.kind==='string'&&(i.type==='text/plain'||i.type==='text/html'));
|
||||
const imageItems=items.filter(i=>i.kind==='file'&&i.type.startsWith('image/'));
|
||||
if(!imageItems.length||hasText)return;
|
||||
e.preventDefault();
|
||||
const files=imageItems.map(i=>{
|
||||
const blob=i.getAsFile();
|
||||
@@ -1287,7 +1294,7 @@ function applyBotName(){
|
||||
// ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards)
|
||||
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
||||
const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||
const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||
api(_checkUrl).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||
}
|
||||
// Fetch active profile
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
<!-- ES module imports do not support the integrity= attribute (W3C limitation); -->
|
||||
<!-- version is pinned in the vendored file path; hash documented above for audit. -->
|
||||
<script type="module">
|
||||
import * as smd from '/static/vendor/smd.min.js';
|
||||
import * as smd from 'static/vendor/smd.min.js';
|
||||
// SRI verification happens at the ES module level via importmap or SW; pinning version in URL.
|
||||
// sha384 of smd.min.js @0.2.15: sha384-T6r95ocN9t3W8tUK2Fa6FPaO7bJryyjyW0WCalrUnpgtm2qXr5xcN4vwPYEJ6vHa
|
||||
window.smd = smd;
|
||||
|
||||
+8
-5
@@ -640,9 +640,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
_smdWrite(displayText);
|
||||
} else {
|
||||
// Fallback: smd not loaded yet, reconnect session, or smd unavailable — use renderMd
|
||||
assistantBody.innerHTML = (segmentStart===0
|
||||
// for every live segment. Without this, the first segment inserts raw
|
||||
// parsed.displayText and users see unformatted markdown until done.
|
||||
const fallbackText = segmentStart===0
|
||||
? parsed.displayText
|
||||
: renderMd ? renderMd(assistantText.slice(segmentStart)) : assistantText.slice(segmentStart)) || '';
|
||||
: _stripXmlToolCalls(assistantText.slice(segmentStart));
|
||||
assistantBody.innerHTML = renderMd ? renderMd(fallbackText) : esc(fallbackText);
|
||||
}
|
||||
}
|
||||
scrollIfPinned();
|
||||
@@ -1393,7 +1396,7 @@ function startApprovalPolling(sid) {
|
||||
stopApprovalPolling();
|
||||
// ── SSE (preferred): long-lived connection, server pushes instantly ──
|
||||
try {
|
||||
const es = new EventSource('/api/approval/stream?session_id=' + encodeURIComponent(sid));
|
||||
const es = new EventSource(new URL('api/approval/stream?session_id=' + encodeURIComponent(sid), document.baseURI || location.href).href);
|
||||
let _fallbackActive = false;
|
||||
|
||||
es.addEventListener('initial', e => {
|
||||
@@ -1755,7 +1758,7 @@ function startClarifyPolling(sid) {
|
||||
|
||||
// SSE primary path: long-lived connection pushes events instantly.
|
||||
try {
|
||||
_clarifyEventSource = new EventSource('/api/clarify/stream?session_id=' + encodeURIComponent(sid));
|
||||
_clarifyEventSource = new EventSource(new URL('api/clarify/stream?session_id=' + encodeURIComponent(sid), document.baseURI || location.href).href);
|
||||
} catch(e) {
|
||||
_startClarifyFallbackPoll(sid);
|
||||
return;
|
||||
@@ -1873,7 +1876,7 @@ function sendBrowserNotification(title,body){
|
||||
|
||||
function attachBtwStream(parentSid, streamId, question){
|
||||
if(!parentSid||!streamId) return;
|
||||
const src=new EventSource('/api/chat/stream?stream_id='+encodeURIComponent(streamId));
|
||||
const src=new EventSource(new URL('api/chat/stream?stream_id='+encodeURIComponent(streamId), document.baseURI||location.href).href);
|
||||
let answer='';
|
||||
let btwRow=null;
|
||||
let _streamDone=false;
|
||||
|
||||
+7
-2
@@ -3606,8 +3606,13 @@ async function checkUpdatesNow(){
|
||||
if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';}
|
||||
} else {
|
||||
const parts=[];
|
||||
if(data.webui&&data.webui.behind>0) parts.push('WebUI: '+data.webui.behind);
|
||||
if(data.agent&&data.agent.behind>0) parts.push('Agent: '+data.agent.behind);
|
||||
const formatUpdatePart=(typeof _formatUpdateTargetStatus==='function')
|
||||
? _formatUpdateTargetStatus
|
||||
: ((label,info)=>info&&info.behind>0?label+': '+info.behind:null);
|
||||
const webuiPart=formatUpdatePart('WebUI',data.webui);
|
||||
const agentPart=formatUpdatePart('Agent',data.agent);
|
||||
if(webuiPart) parts.push(webuiPart);
|
||||
if(agentPart) parts.push(agentPart);
|
||||
if(parts.length){
|
||||
if(status){status.textContent=t('settings_updates_available').replace('{count}',parts.join(', '));status.style.color='var(--accent)';}
|
||||
// Also trigger the update banner
|
||||
|
||||
+19
-8
@@ -1437,7 +1437,7 @@ async function probeGatewaySSEStatus(){
|
||||
if(_gatewayProbeInFlight || !window._showCliSessions) return;
|
||||
_gatewayProbeInFlight = true;
|
||||
try{
|
||||
const resp = await fetch('/api/sessions/gateway/stream?probe=1', { credentials:'same-origin' });
|
||||
const resp = await fetch(new URL('api/sessions/gateway/stream?probe=1', document.baseURI || location.href).href, { credentials:'same-origin' });
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if(resp.ok && data.watcher_running){
|
||||
stopGatewayPollFallback();
|
||||
@@ -1692,6 +1692,21 @@ function _sidebarLineageKeyForRow(s){
|
||||
return s._lineage_key||s._lineage_root_id||s.lineage_root_id||s.parent_session_id||s.session_id||null;
|
||||
}
|
||||
|
||||
function _truncatedSessionId(sid){
|
||||
sid=String(sid||'').trim();
|
||||
if(!sid) return '';
|
||||
if(sid.length<=16) return sid;
|
||||
return sid.slice(0,12)+'...';
|
||||
}
|
||||
|
||||
function _sessionTitleForForkParent(parentSid){
|
||||
if(!parentSid||!Array.isArray(_allSessions)) return '';
|
||||
const parent=_allSessions.find(item=>item&&item.session_id===parentSid);
|
||||
const title=parent&&String(parent.title||'').trim();
|
||||
if(!title||title==='Untitled') return '';
|
||||
return title;
|
||||
}
|
||||
|
||||
function _attachChildSessionsToSidebarRows(collapsedRows, rawSessions){
|
||||
const rows=(collapsedRows||[]).filter(s=>!_isChildSession(s)).map(s=>({...s}));
|
||||
const visibleBySid=new Map();
|
||||
@@ -2069,13 +2084,9 @@ function renderSessionListFromCache(){
|
||||
if(s.parent_session_id){
|
||||
const branchInd=document.createElement('span');
|
||||
branchInd.className='session-branch-indicator';
|
||||
branchInd.textContent='\u2442'; // ⑂
|
||||
branchInd.title=(typeof t==='function'?t('forked_from'):'Forked from')+' '+s.parent_session_id;
|
||||
branchInd.style.cursor='pointer';
|
||||
branchInd.onclick=(e)=>{
|
||||
e.stopPropagation();
|
||||
if(typeof loadSession==='function') loadSession(s.parent_session_id);
|
||||
};
|
||||
branchInd.innerHTML=li('git-branch',12);
|
||||
const parentLabel=_sessionTitleForForkParent(s.parent_session_id)||_truncatedSessionId(s.parent_session_id);
|
||||
branchInd.title=(typeof t==='function'?t('forked_from'):'Forked from')+' '+parentLabel;
|
||||
titleRow.appendChild(branchInd);
|
||||
}
|
||||
const title=document.createElement('span');
|
||||
|
||||
@@ -2448,6 +2448,29 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
|
||||
}
|
||||
.session-pin-indicator svg{width:10px;height:10px;}
|
||||
|
||||
/* ── Fork lineage indicator (inline, subtle until row focus/hover) ── */
|
||||
.session-branch-indicator{
|
||||
flex-shrink:0;
|
||||
width:12px;
|
||||
height:12px;
|
||||
color:var(--muted);
|
||||
line-height:1;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
opacity:.35;
|
||||
pointer-events:none;
|
||||
transition:opacity .15s ease,color .15s ease;
|
||||
}
|
||||
.session-branch-indicator svg{width:12px;height:12px;}
|
||||
.session-item:hover .session-branch-indicator,
|
||||
.session-item:focus-within .session-branch-indicator,
|
||||
.session-item.menu-open .session-branch-indicator{
|
||||
opacity:.85;
|
||||
color:var(--text);
|
||||
}
|
||||
.session-item.active .session-branch-indicator{color:var(--accent-text);}
|
||||
|
||||
/* ── Cron alert badge ── */
|
||||
.cron-badge{position:absolute;top:2px;right:2px;background:#e53e3e;color:#fff;font-size:9px;font-weight:700;min-width:14px;height:14px;line-height:14px;text-align:center;border-radius:7px;padding:0 3px;}
|
||||
.cron-new-dot{width:7px;height:7px;border-radius:50%;background:var(--success,#22c55e);flex-shrink:0;animation:cron-dot-pulse 2s ease-in-out infinite;}
|
||||
|
||||
+14
-7
@@ -9,10 +9,10 @@ const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
|
||||
// single-threaded so only one done event fires at a time in practice.
|
||||
let _queueDrainSid=null;
|
||||
const $=id=>document.getElementById(id);
|
||||
// Redirect to /login when the server responds with 401 (auth session expired).
|
||||
// Handles iOS PWA standalone mode where a server-side 302→/login would break
|
||||
// out of the PWA shell into Safari instead of navigating within it.
|
||||
function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;}
|
||||
// Redirect to login when the server responds with 401 (auth session expired).
|
||||
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
|
||||
// escaping to the personal site root /login.
|
||||
function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;}
|
||||
function _getSessionQueue(sid, create=false){
|
||||
if(!sid) return [];
|
||||
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
|
||||
@@ -2844,10 +2844,17 @@ async function refreshSession() {
|
||||
} catch(e) { setStatus('Refresh failed: ' + e.message); }
|
||||
}
|
||||
// ── Update banner ──
|
||||
function _formatUpdateTargetStatus(label,info){
|
||||
if(!info||!(info.behind>0)) return null;
|
||||
const branch=info.branch?` (${info.branch})`:'';
|
||||
return `${label}${branch}: ${info.behind} update${info.behind>1?'s':''}`;
|
||||
}
|
||||
function _showUpdateBanner(data){
|
||||
const parts=[];
|
||||
if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`);
|
||||
if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`);
|
||||
const webuiPart=_formatUpdateTargetStatus('WebUI',data.webui);
|
||||
const agentPart=_formatUpdateTargetStatus('Agent',data.agent);
|
||||
if(webuiPart) parts.push(webuiPart);
|
||||
if(agentPart) parts.push(agentPart);
|
||||
if(!parts.length)return;
|
||||
const msg=$('updateMsg');
|
||||
if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available';
|
||||
@@ -2976,7 +2983,7 @@ async function _waitForServerThenReload(opts){
|
||||
await new Promise(r=>setTimeout(r, interval));
|
||||
while(Date.now()<deadline){
|
||||
try{
|
||||
const r=await fetch('/health',{cache:'no-store'});
|
||||
const r=await fetch(new URL('health', document.baseURI||location.href).href,{cache:'no-store'});
|
||||
if(r.ok){
|
||||
let data={};
|
||||
try{ data=await r.json(); }catch(_){}
|
||||
|
||||
+3
-3
@@ -9,10 +9,10 @@ async function api(path,opts={}){
|
||||
try{
|
||||
const res=await fetch(url.href,{credentials:'include',headers:{'Content-Type':'application/json'},...opts});
|
||||
if(!res.ok){
|
||||
// 401 means the auth session expired. Redirect to /login so the user can
|
||||
// 401 means the auth session expired. Redirect to login so the user can
|
||||
// re-authenticate. This is especially important for iOS PWA (standalone mode)
|
||||
// where a server-side 302 → /login opens in Safari instead of within the PWA.
|
||||
if(res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;}
|
||||
// and for subpath mounts like /hermes/, where /login escapes to the site root.
|
||||
if(res.status===401){window.location.href='login?next='+encodeURIComponent(window.location.pathname+window.location.search);return;}
|
||||
const text=await res.text();
|
||||
// Parse JSON error body and surface the human-readable message,
|
||||
// rather than showing raw JSON like {"error":"Profile 'x' does not exist."}
|
||||
|
||||
@@ -69,6 +69,10 @@ os.environ['HERMES_WEBUI_STATE_DIR'] = str(TEST_STATE_DIR)
|
||||
os.environ['HERMES_WEBUI_DEFAULT_WORKSPACE'] = str(TEST_WORKSPACE)
|
||||
os.environ['HERMES_HOME'] = str(TEST_STATE_DIR)
|
||||
os.environ['HERMES_BASE_HOME'] = str(TEST_STATE_DIR)
|
||||
# Hermes Agent sessions may inherit HERMES_CONFIG_PATH pointing at the live
|
||||
# ~/.hermes/config.yaml. Override it before any product modules are imported so
|
||||
# tests that read/write config.yaml stay inside the isolated test home.
|
||||
os.environ['HERMES_CONFIG_PATH'] = str(TEST_STATE_DIR / 'config.yaml')
|
||||
|
||||
# ── Server script: always relative to repo root ───────────────────────────
|
||||
SERVER_SCRIPT = REPO_ROOT / 'server.py'
|
||||
@@ -297,6 +301,7 @@ def test_server():
|
||||
"HERMES_WEBUI_DEFAULT_WORKSPACE": str(TEST_WORKSPACE),
|
||||
"HERMES_WEBUI_DEFAULT_MODEL": "openai/gpt-5.4-mini",
|
||||
"HERMES_HOME": str(TEST_STATE_DIR),
|
||||
"HERMES_CONFIG_PATH": str(TEST_STATE_DIR / 'config.yaml'),
|
||||
# Belt-and-suspenders: HERMES_BASE_HOME hard-locks _DEFAULT_HERMES_HOME
|
||||
# in api/profiles.py to the test state dir regardless of profile switching
|
||||
# or any os.environ mutation that happens inside the server process.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
Tests for issue #1038 — iOS PWA auth-expiry redirect.
|
||||
|
||||
When a 401 is returned by any API endpoint, the client-side JS should redirect
|
||||
to /login rather than showing a raw error toast. On iOS PWA standalone mode a
|
||||
server-side 302→/login breaks out of the PWA shell into Safari, so the fix is
|
||||
client-side: workspace.js api() intercepts 401 before throwing and calls
|
||||
window.location.href = '/login'.
|
||||
to login rather than showing a raw error toast. On iOS PWA standalone mode a
|
||||
server-side 302→login can break out of the PWA shell into Safari, so the fix is
|
||||
client-side: workspace.js api() intercepts 401 before throwing and calls a
|
||||
relative login URL that also works under subpath mounts like /hermes/.
|
||||
|
||||
These are static regression tests that verify the JS source contains the
|
||||
correct guard patterns.
|
||||
@@ -27,13 +27,15 @@ def _ui_js() -> str:
|
||||
|
||||
class TestPWAAuthRedirect:
|
||||
def test_workspace_js_has_401_redirect(self):
|
||||
"""api() in workspace.js must redirect to /login on 401."""
|
||||
"""api() in workspace.js must redirect to login on 401."""
|
||||
src = _workspace_js()
|
||||
# Guard must appear inside the !res.ok block, before throwing
|
||||
assert "res.status===401" in src, \
|
||||
"workspace.js api() must check res.status===401"
|
||||
assert "window.location.href='/login" in src or 'window.location.href="/login' in src, \
|
||||
"workspace.js api() must redirect to /login on 401"
|
||||
assert "window.location.href='login" in src or 'window.location.href="login' in src, \
|
||||
"workspace.js api() must redirect to login on 401"
|
||||
assert "window.location.href='/login" not in src and 'window.location.href="/login' not in src, \
|
||||
"workspace.js api() must not escape subpath mounts by redirecting to root /login"
|
||||
|
||||
def test_workspace_js_401_before_throw(self):
|
||||
"""The 401 redirect must come before any error throw."""
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for #1620 — Cmd+V always attaches an image when clipboard contains both text and image.
|
||||
|
||||
The composer paste handler in `static/boot.js` previously intercepted any paste
|
||||
event whose clipboard carried an `image/*` item, called `e.preventDefault()`,
|
||||
and attached the image as a screenshot. When the clipboard came from a rich-text
|
||||
source (Notes, Word, Slack, browser selection), macOS/Windows/Linux attach a
|
||||
rendered preview image alongside the plain text — so the handler swallowed the
|
||||
text payload and only the rogue image was attached.
|
||||
|
||||
The fix:
|
||||
• Skip image-attach when the clipboard also carries `text/plain` or `text/html`
|
||||
string items (rich-text source — let the browser paste text normally).
|
||||
• Tighten the image filter to `kind === 'file'` so string items advertising an
|
||||
image MIME are not misclassified as a true screenshot paste.
|
||||
|
||||
These tests guard the handler shape against regression by static-analyzing
|
||||
`static/boot.js`. They follow the same pattern as `test_issue1095_pasted_images.py`.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def _read_boot_js() -> str:
|
||||
with open(os.path.join('static', 'boot.js')) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def _paste_handler_body() -> str:
|
||||
"""Extract the body of the #msg paste handler for assertions."""
|
||||
src = _read_boot_js()
|
||||
m = re.search(r"\$\('msg'\)\.addEventListener\('paste',\s*e\s*=>\s*\{", src)
|
||||
assert m, "#msg paste handler not found in static/boot.js"
|
||||
# Walk braces from the opening { to find the matching close.
|
||||
start = m.end() - 1
|
||||
depth = 0
|
||||
for i in range(start, len(src)):
|
||||
c = src[i]
|
||||
if c == '{':
|
||||
depth += 1
|
||||
elif c == '}':
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[start:i + 1]
|
||||
raise AssertionError("Unbalanced braces in #msg paste handler")
|
||||
|
||||
|
||||
class TestPasteHandlerTextWithImage:
|
||||
"""Regression suite for #1620."""
|
||||
|
||||
def test_handler_detects_text_in_clipboard(self):
|
||||
"""Handler must inspect string items for text/plain or text/html so it can
|
||||
defer to the browser's default text-paste behavior when text is present."""
|
||||
body = _paste_handler_body()
|
||||
assert "kind==='string'" in body or 'kind === "string"' in body or "kind === 'string'" in body, (
|
||||
"paste handler must check items[].kind === 'string' to detect text payload"
|
||||
)
|
||||
assert "'text/plain'" in body, "paste handler must check for text/plain"
|
||||
assert "'text/html'" in body, "paste handler must check for text/html"
|
||||
|
||||
def test_image_filter_requires_kind_file(self):
|
||||
"""Image filter must require kind === 'file' to avoid misclassifying string
|
||||
items that advertise an image MIME (e.g. text/html with embedded data URIs)."""
|
||||
body = _paste_handler_body()
|
||||
# The image filter line must combine kind==='file' with type.startsWith('image/').
|
||||
assert re.search(
|
||||
r"kind\s*===\s*'file'\s*&&\s*[a-zA-Z_$][\w$]*\.type\.startsWith\('image/'\)",
|
||||
body,
|
||||
), "imageItems filter must use kind === 'file' && type.startsWith('image/')"
|
||||
|
||||
def test_handler_skips_attach_when_text_present(self):
|
||||
"""The early-return guard must short-circuit when text is in the clipboard,
|
||||
so the browser's default text-paste runs and no image is attached."""
|
||||
body = _paste_handler_body()
|
||||
# Guard shape: if(!imageItems.length || hasText) return;
|
||||
assert re.search(
|
||||
r"if\s*\(\s*!\s*imageItems\.length\s*\|\|\s*hasText\s*\)\s*return\s*;",
|
||||
body,
|
||||
), "guard must early-return when there are no image files OR text is present"
|
||||
|
||||
def test_handler_still_intercepts_pure_screenshot_paste(self):
|
||||
"""Pure-screenshot paste (image-only clipboard) must still call preventDefault()
|
||||
and route through addFiles() so the screenshot attaches as a file."""
|
||||
body = _paste_handler_body()
|
||||
assert 'e.preventDefault()' in body, "handler must still preventDefault on image-only paste"
|
||||
assert 'addFiles(files)' in body, "handler must still call addFiles(files) for screenshots"
|
||||
assert 'screenshot-' in body, "handler must still synthesize screenshot-<ts> filename"
|
||||
|
||||
def test_handler_does_not_use_loose_image_check(self):
|
||||
"""The pre-fix loose check `i.type.startsWith('image/')` (without kind==='file')
|
||||
must not be the imageItems filter — that was the source of the bug."""
|
||||
body = _paste_handler_body()
|
||||
# Find the imageItems assignment line.
|
||||
m = re.search(r"const\s+imageItems\s*=\s*items\.filter\([^)]*\)", body)
|
||||
assert m, "imageItems filter not found"
|
||||
filter_expr = m.group(0)
|
||||
assert "kind==='file'" in filter_expr or "kind === 'file'" in filter_expr, (
|
||||
"imageItems filter must be tightened with kind === 'file' (regression for #1620)"
|
||||
)
|
||||
|
||||
def test_handler_does_not_lose_status_message(self):
|
||||
"""The image_pasted status message must still be emitted on the screenshot path."""
|
||||
body = _paste_handler_body()
|
||||
assert "setStatus(t('image_pasted')" in body, (
|
||||
"handler must still emit the image_pasted status on screenshot attach"
|
||||
)
|
||||
@@ -6,7 +6,7 @@ Verifies:
|
||||
3. Frontend /branch slash command is registered
|
||||
4. forkFromMessage function exists in commands.js
|
||||
5. Fork button (git-branch icon) is rendered in ui.js message actions
|
||||
6. Parent session indicator (⑂) is rendered in sessions.js sidebar
|
||||
6. Parent session indicator uses a subtle git-branch icon in sessions.js sidebar
|
||||
7. i18n keys exist for all branch-related strings
|
||||
8. git-branch icon exists in icons.js
|
||||
"""
|
||||
@@ -228,12 +228,14 @@ def test_sidebar_parent_indicator():
|
||||
"sessions.js should check parent_session_id"
|
||||
assert 'session-branch-indicator' in src, \
|
||||
"Should have session-branch-indicator class"
|
||||
assert '\\u2442' in src, \
|
||||
"Should use ⑂ character for parent indicator"
|
||||
assert "li('git-branch',12)" in src, \
|
||||
"Sidebar parent indicator should use the git-branch icon"
|
||||
assert '\\u2442' not in src, \
|
||||
"Sidebar parent indicator should not use the opaque OCR double-backslash glyph"
|
||||
|
||||
|
||||
def test_parent_indicator_clickable():
|
||||
"""Verify parent indicator navigates to parent session on click."""
|
||||
def test_parent_indicator_not_clickable():
|
||||
"""Verify parent indicator is informational, not hidden navigation."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
# Find the parent indicator block
|
||||
@@ -243,8 +245,34 @@ def test_parent_indicator_clickable():
|
||||
)
|
||||
assert parent_block, "Could not find parent indicator block"
|
||||
block = parent_block.group(0)
|
||||
assert 'loadSession(' in block, \
|
||||
"Parent indicator should call loadSession on click"
|
||||
assert 'loadSession(' not in block, \
|
||||
"Parent indicator should not navigate to the parent from the sidebar"
|
||||
assert 'onclick' not in block, \
|
||||
"Parent indicator should not register a hidden click target"
|
||||
|
||||
|
||||
def test_parent_indicator_tooltip_uses_parent_title_fallback():
|
||||
"""Tooltip should prefer a parent title and only fall back to a short id."""
|
||||
with open('static/sessions.js') as f:
|
||||
src = f.read()
|
||||
assert 'function _sessionTitleForForkParent' in src, \
|
||||
"sessions.js should resolve a user-facing parent title"
|
||||
assert 'function _truncatedSessionId' in src, \
|
||||
"sessions.js should fall back to a truncated id, not raw session_id"
|
||||
assert "_sessionTitleForForkParent(s.parent_session_id)||_truncatedSessionId(s.parent_session_id)" in src, \
|
||||
"parent indicator tooltip must prefer title and fall back to truncated id"
|
||||
|
||||
|
||||
def test_parent_indicator_hover_only_style():
|
||||
"""The sidebar lineage indicator should be visually subdued until row hover/focus."""
|
||||
with open('static/style.css') as f:
|
||||
src = f.read()
|
||||
assert '.session-branch-indicator' in src, \
|
||||
"Missing session branch indicator CSS"
|
||||
assert 'opacity:.35' in src, \
|
||||
"Fork lineage indicator should be subdued at rest"
|
||||
assert '.session-item:hover .session-branch-indicator' in src, \
|
||||
"Fork lineage indicator should become visible on row hover"
|
||||
|
||||
|
||||
# ── Frontend: i18n keys ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -150,9 +150,11 @@ class TestFrontendSSEImplementation:
|
||||
"startApprovalPolling must create an EventSource for SSE"
|
||||
|
||||
def test_sse_url_matches_backend(self):
|
||||
"""Frontend SSE URL must match backend /api/approval/stream route."""
|
||||
assert "/api/approval/stream" in MESSAGES_JS, \
|
||||
"EventSource must connect to /api/approval/stream"
|
||||
"""Frontend SSE URL must match backend approval stream route."""
|
||||
assert "api/approval/stream" in MESSAGES_JS, \
|
||||
"EventSource must connect to the approval stream endpoint"
|
||||
assert "EventSource('/api/approval/stream" not in MESSAGES_JS, \
|
||||
"EventSource URL must stay relative for subpath mounts"
|
||||
|
||||
def test_initial_event_listener(self):
|
||||
"""Frontend must listen for 'initial' SSE events."""
|
||||
|
||||
@@ -79,7 +79,8 @@ class TestClarifySSEFrontendCode:
|
||||
|
||||
def test_uses_event_source(self):
|
||||
assert "new EventSource" in self.js
|
||||
assert "/api/clarify/stream" in self.js
|
||||
assert "api/clarify/stream" in self.js
|
||||
assert "EventSource('/api/clarify/stream" not in self.js
|
||||
|
||||
def test_frontend_listens_initial_event(self):
|
||||
assert "'initial'" in self.js or '"initial"' in self.js
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_extension_route_remains_behind_webui_auth(monkeypatch):
|
||||
# when constructing the redirect Location header.
|
||||
assert check_auth(extension, SimpleNamespace(path="/extensions/app.js", query="")) is False
|
||||
assert extension.status == 302
|
||||
assert extension.header("Location") == "/login?next=/extensions/app.js"
|
||||
assert extension.header("Location") == "login?next=/extensions/app.js"
|
||||
|
||||
# Existing core static assets remain public; extension assets intentionally
|
||||
# do not share that exemption because they are administrator-supplied code.
|
||||
|
||||
@@ -773,6 +773,61 @@ def test_agent_session_source_normalization_contract():
|
||||
assert normalized['raw_source'] is None
|
||||
|
||||
|
||||
def test_cross_source_parent_child_is_not_collapsed_into_root_metadata(cleanup_test_sessions):
|
||||
"""A WebUI continuation from a messaging parent must keep WebUI metadata.
|
||||
|
||||
Regression for a production case where a WebUI session continued from a
|
||||
Telegram compression chain and was projected as the old Telegram root,
|
||||
inheriting the wrong title/source and hiding from the expected sidebar view.
|
||||
"""
|
||||
from api.agent_sessions import read_importable_agent_session_rows
|
||||
|
||||
conn = _ensure_state_db()
|
||||
root_sid = 'gw_tg_cross_source_root_001'
|
||||
webui_sid = 'webui_cross_source_tip_001'
|
||||
now = time.time()
|
||||
cleanup_test_sessions.extend([root_sid, webui_sid])
|
||||
try:
|
||||
_insert_agent_session_row(
|
||||
conn,
|
||||
session_id=root_sid,
|
||||
source='telegram',
|
||||
title='Old Telegram Root',
|
||||
started_at=now - 20,
|
||||
ended_at=now - 10,
|
||||
end_reason='compression',
|
||||
messages=2,
|
||||
)
|
||||
_insert_agent_session_row(
|
||||
conn,
|
||||
session_id=webui_sid,
|
||||
source='webui',
|
||||
title='Current WebUI Work',
|
||||
started_at=now - 9,
|
||||
parent_session_id=root_sid,
|
||||
messages=2,
|
||||
)
|
||||
|
||||
rows = read_importable_agent_session_rows(_get_state_db_path(), exclude_sources=None)
|
||||
by_id = {row['id']: row for row in rows}
|
||||
|
||||
assert webui_sid in by_id
|
||||
assert root_sid in by_id
|
||||
webui = by_id[webui_sid]
|
||||
assert webui.get('title') == 'Current WebUI Work'
|
||||
assert webui.get('source') == 'webui'
|
||||
assert webui.get('session_source') == 'webui'
|
||||
assert webui.get('source_label') == 'WebUI'
|
||||
assert webui.get('relationship_type') == 'child_session'
|
||||
assert webui.get('parent_title') == 'Old Telegram Root'
|
||||
finally:
|
||||
try:
|
||||
_remove_test_sessions(conn, root_sid, webui_sid)
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_gateway_watcher_uses_normalized_source_metadata(monkeypatch):
|
||||
"""SSE snapshots use the same normalized source contract as /api/sessions."""
|
||||
conn = _ensure_state_db()
|
||||
|
||||
@@ -50,12 +50,14 @@ class TestApiRetryOnNetworkError:
|
||||
"api() must limit to 3 attempts max (attempt < 2)"
|
||||
|
||||
def test_api_preserves_401_redirect(self):
|
||||
"""api() must still redirect to /login on 401 (auth expired)."""
|
||||
"""api() must still redirect to login on 401 without escaping subpath mounts."""
|
||||
src = _src()
|
||||
assert "res.status===401" in src, \
|
||||
"api() must still check for 401 status"
|
||||
assert "/login?next=" in src, \
|
||||
"api() must still redirect to /login on 401"
|
||||
assert "login?next=" in src, \
|
||||
"api() must still redirect to login on 401"
|
||||
assert "/login?next=" not in src, \
|
||||
"api() must not escape subpath mounts by redirecting to root /login"
|
||||
|
||||
def test_api_preserves_error_parsing(self):
|
||||
"""api() must still parse JSON error bodies for non-200 responses."""
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import io
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
from api.config import STREAMS, STREAMS_LOCK, create_stream_channel
|
||||
from api.routes import _handle_sse_stream
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.headers = []
|
||||
self.wfile = io.BytesIO()
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, key, value):
|
||||
self.headers.append((key, value))
|
||||
|
||||
def end_headers(self):
|
||||
return None
|
||||
|
||||
|
||||
def test_stream_channel_broadcasts_each_event_to_every_subscriber():
|
||||
stream = create_stream_channel()
|
||||
q1 = stream.subscribe()
|
||||
q2 = stream.subscribe()
|
||||
|
||||
try:
|
||||
stream.put_nowait(("token", {"text": "H"}))
|
||||
stream.put_nowait(("token", {"text": "allo"}))
|
||||
stream.put_nowait(("stream_end", {"status": "done"}))
|
||||
|
||||
assert q1.get(timeout=1) == ("token", {"text": "H"})
|
||||
assert q1.get(timeout=1) == ("token", {"text": "allo"})
|
||||
assert q1.get(timeout=1) == ("stream_end", {"status": "done"})
|
||||
|
||||
assert q2.get(timeout=1) == ("token", {"text": "H"})
|
||||
assert q2.get(timeout=1) == ("token", {"text": "allo"})
|
||||
assert q2.get(timeout=1) == ("stream_end", {"status": "done"})
|
||||
finally:
|
||||
stream.unsubscribe(q1)
|
||||
stream.unsubscribe(q2)
|
||||
|
||||
|
||||
def test_same_stream_in_two_tabs_receives_identical_token_sequence():
|
||||
stream_id = "multitab-stream"
|
||||
stream = create_stream_channel()
|
||||
with STREAMS_LOCK:
|
||||
STREAMS[stream_id] = stream
|
||||
|
||||
handlers = [_FakeHandler(), _FakeHandler()]
|
||||
threads = [
|
||||
threading.Thread(
|
||||
target=_handle_sse_stream,
|
||||
args=(handler, SimpleNamespace(query=f"stream_id={stream_id}")),
|
||||
daemon=True,
|
||||
)
|
||||
for handler in handlers
|
||||
]
|
||||
|
||||
try:
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
|
||||
stream.put_nowait(("token", {"text": "H"}))
|
||||
stream.put_nowait(("token", {"text": "allo"}))
|
||||
stream.put_nowait(("stream_end", {"status": "done"}))
|
||||
|
||||
for thread in threads:
|
||||
thread.join(timeout=1)
|
||||
assert not thread.is_alive(), "every tab should finish the same SSE stream"
|
||||
|
||||
for handler in handlers:
|
||||
payload = handler.wfile.getvalue().decode("utf-8")
|
||||
assert handler.status == 200
|
||||
assert '"text": "H"' in payload
|
||||
assert '"text": "allo"' in payload
|
||||
assert "event: stream_end" in payload
|
||||
finally:
|
||||
with STREAMS_LOCK:
|
||||
STREAMS.pop(stream_id, None)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Regression coverage for pytest isolation of Hermes config paths."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_pytest_overrides_inherited_hermes_config_path():
|
||||
"""A live-agent HERMES_CONFIG_PATH must never leak into WebUI tests.
|
||||
|
||||
Hermes agents commonly run with HERMES_CONFIG_PATH pointing at the real
|
||||
~/.hermes/config.yaml. The test harness must replace it with the isolated
|
||||
test home before product modules are imported, otherwise provider/onboarding
|
||||
tests can mutate the user's real config.
|
||||
"""
|
||||
test_state_dir = Path(os.environ["HERMES_WEBUI_TEST_STATE_DIR"])
|
||||
assert Path(os.environ["HERMES_CONFIG_PATH"]) == test_state_dir / "config.yaml"
|
||||
@@ -197,3 +197,33 @@ def test_cron_run_does_not_silently_swallow_profile_resolution_errors():
|
||||
"HERMES_HOME. Let the exception propagate (500 the request) rather "
|
||||
"than corrupt cross-profile state silently."
|
||||
)
|
||||
|
||||
|
||||
def test_cron_worker_does_not_silently_fall_back_on_profile_context_failure():
|
||||
"""_run_cron_tracked must NOT silently set ctx=None when
|
||||
cron_profile_context_for_home(...).__enter__() raises.
|
||||
|
||||
A silent fallback in the worker thread would leave the job running
|
||||
unpinned against process-global HERMES_HOME, silently corrupting
|
||||
cross-profile state — the same class of bug as #1573. We'd rather
|
||||
let the exception propagate and kill the worker thread than risk
|
||||
that.
|
||||
|
||||
Source-level assertion to catch any future re-introduction of the
|
||||
over-broad except clause around the context setup.
|
||||
"""
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parent.parent / "api" / "routes.py").read_text(encoding="utf-8")
|
||||
|
||||
idx = src.find("def _run_cron_tracked(job, profile_home=None):")
|
||||
assert idx != -1, "_run_cron_tracked not found"
|
||||
body = src[idx : idx + 2000]
|
||||
|
||||
# The profile-context setup must NOT be wrapped in try/except that
|
||||
# silently falls back to ctx=None.
|
||||
assert "except Exception" not in body[:body.find("run_job(job)")], (
|
||||
"_run_cron_tracked silently falls back to ctx=None when "
|
||||
"cron_profile_context_for_home(...).__enter__() raises. That leaves "
|
||||
"the worker thread unpinned against process-global HERMES_HOME. "
|
||||
"Let the exception propagate rather than corrupt cross-profile state."
|
||||
)
|
||||
|
||||
@@ -107,6 +107,19 @@ class TestIndexHtmlSmdScript:
|
||||
"streaming-markdown must be loaded with type=\"module\" (it is an ES module)"
|
||||
)
|
||||
|
||||
def test_smd_vendor_import_is_mount_agnostic(self):
|
||||
assert "static/vendor/smd.min.js" in INDEX_HTML, (
|
||||
"index.html must load the vendored streaming-markdown module"
|
||||
)
|
||||
assert "from '/static/vendor/smd.min.js'" not in INDEX_HTML, (
|
||||
"streaming-markdown import must not be root-absolute; root-absolute "
|
||||
"static paths break subpath deployments such as /hermes/"
|
||||
)
|
||||
assert 'from "/static/vendor/smd.min.js"' not in INDEX_HTML, (
|
||||
"streaming-markdown import must not be root-absolute; root-absolute "
|
||||
"static paths break subpath deployments such as /hermes/"
|
||||
)
|
||||
|
||||
|
||||
# ── 2. Closure variable declarations ─────────────────────────────────────────
|
||||
|
||||
@@ -270,6 +283,17 @@ class TestScheduleRenderSmdPath:
|
||||
"renderMd fallback must still exist in _scheduleRender when smd unavailable"
|
||||
)
|
||||
|
||||
def test_fallback_formats_first_segment_with_render_md(self):
|
||||
fn = self.get_fn()
|
||||
assert fn, "_scheduleRender not found"
|
||||
assert "const fallbackText" in fn, (
|
||||
"_scheduleRender fallback should choose the visible segment text once"
|
||||
)
|
||||
assert "renderMd(fallbackText)" in fn, (
|
||||
"When smd is unavailable, the first live segment must still be "
|
||||
"formatted with renderMd instead of inserting raw parsed.displayText"
|
||||
)
|
||||
|
||||
def test_smd_new_parser_called_lazily(self):
|
||||
fn = self.get_fn()
|
||||
assert fn and "_smdNewParser(" in fn, (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Regression tests for frontend routing under subpath mounts like /hermes/."""
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_workspace_api_401_redirect_uses_relative_login_path():
|
||||
src = read("static/workspace.js")
|
||||
assert "res.status===401" in src
|
||||
assert "window.location.href='login?next='" in src, (
|
||||
"workspace api() must redirect to relative login?next= so /hermes/ "
|
||||
"does not escape to the personal site root /login."
|
||||
)
|
||||
assert "window.location.href='/login?next='" not in src
|
||||
|
||||
|
||||
def test_ui_401_redirect_helper_uses_relative_login_path():
|
||||
src = read("static/ui.js")
|
||||
assert "function _redirectIfUnauth" in src
|
||||
assert "window.location.href='login?next='" in src, (
|
||||
"UI auth-expiry redirect must stay under the current subpath mount."
|
||||
)
|
||||
assert "window.location.href='/login?next='" not in src
|
||||
|
||||
|
||||
def test_server_auth_redirect_uses_relative_login_path_with_encoded_next():
|
||||
src = read("api/auth.py")
|
||||
assert "handler.send_header('Location', 'login?next=' + _next)" in src
|
||||
assert "handler.send_header('Location', '/login?next='" not in src
|
||||
assert "safe='/'" in src, "the relative redirect must keep the existing next= encoding fix"
|
||||
|
||||
|
||||
def test_direct_frontend_fetches_are_relative_to_current_mount():
|
||||
for path in ("static/boot.js", "static/sessions.js", "static/ui.js"):
|
||||
src = read(path)
|
||||
assert "fetch('/api/" not in src, (
|
||||
f"{path} must not fetch root /api/* because /hermes/ is subpath mounted."
|
||||
)
|
||||
assert 'fetch("/api/' not in src
|
||||
assert "fetch('/health'" not in read("static/ui.js")
|
||||
assert "new URL('health'" in read("static/ui.js")
|
||||
|
||||
|
||||
def test_direct_frontend_event_sources_are_relative_to_current_mount():
|
||||
src = read("static/messages.js")
|
||||
assert "EventSource('/api/" not in src
|
||||
assert 'EventSource("/api/' not in src
|
||||
for endpoint in ("api/approval/stream", "api/clarify/stream", "api/chat/stream"):
|
||||
assert endpoint in src
|
||||
assert "new URL(" in src
|
||||
|
||||
|
||||
def test_static_vendor_import_is_relative_to_current_mount():
|
||||
src = read("static/index.html")
|
||||
assert "import * as smd from 'static/vendor/smd.min.js'" in src
|
||||
assert "import * as smd from '/static/vendor/smd.min.js'" not in src
|
||||
@@ -21,10 +21,6 @@ def test_streaming_done_payload_includes_backend_turn_duration():
|
||||
"Turn duration should be measured from the persisted pending_started_at "
|
||||
"start time, not only from browser-local state."
|
||||
)
|
||||
assert "if _pending_started_at is not None else time.time()" in STREAMING_PY, (
|
||||
"The fallback should preserve explicit timestamp values and only use now "
|
||||
"when pending_started_at is absent."
|
||||
)
|
||||
assert "recovered/legacy flows" in STREAMING_PY, (
|
||||
"The missing-start fallback should be documented so it is not mistaken "
|
||||
"for the primary timing path."
|
||||
|
||||
@@ -28,6 +28,58 @@ def read(rel):
|
||||
|
||||
# ── api/updates.py ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestUpdateChecker:
|
||||
def test_repo_url_strips_only_dot_git_suffix(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
def fake_run(args, cwd, timeout=10):
|
||||
if args[0] == 'fetch':
|
||||
return '', True
|
||||
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
||||
return 'origin/master', True
|
||||
if args[:2] == ['rev-list', '--count']:
|
||||
return '0', True
|
||||
if args[0] == 'merge-base':
|
||||
return 'abcdef1234567890', True
|
||||
if args[:2] == ['rev-parse', '--short']:
|
||||
return 'abcdef1', True
|
||||
if args[:2] == ['remote', 'get-url']:
|
||||
return 'https://github.com/nesquena/hermes-webui.git', True
|
||||
return '', True
|
||||
|
||||
monkeypatch.setattr(upd, '_run_git', fake_run)
|
||||
result = upd._check_repo(tmp_path, 'webui')
|
||||
|
||||
assert result['repo_url'] == 'https://github.com/nesquena/hermes-webui'
|
||||
|
||||
def test_repo_url_converts_ssh_and_strips_only_dot_git_suffix(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
def fake_run(args, cwd, timeout=10):
|
||||
if args[0] == 'fetch':
|
||||
return '', True
|
||||
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
||||
return 'origin/main', True
|
||||
if args[:2] == ['rev-list', '--count']:
|
||||
return '0', True
|
||||
if args[0] == 'merge-base':
|
||||
return 'abcdef1234567890', True
|
||||
if args[:2] == ['rev-parse', '--short']:
|
||||
return 'abcdef1', True
|
||||
if args[:2] == ['remote', 'get-url']:
|
||||
return 'git@github.com:NousResearch/hermes-agent.git', True
|
||||
return '', True
|
||||
|
||||
monkeypatch.setattr(upd, '_run_git', fake_run)
|
||||
result = upd._check_repo(tmp_path, 'agent')
|
||||
|
||||
assert result['repo_url'] == 'https://github.com/NousResearch/hermes-agent'
|
||||
|
||||
|
||||
class TestConflictError:
|
||||
"""#813 — conflict error must include flag + recovery command."""
|
||||
|
||||
@@ -349,13 +401,14 @@ class TestUiJsUpdateBanner:
|
||||
)
|
||||
|
||||
def test_wait_for_server_polls_health(self):
|
||||
"""_waitForServerThenReload() must fetch /health to determine readiness."""
|
||||
"""_waitForServerThenReload() must fetch health to determine readiness."""
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'function\s+_waitForServerThenReload\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "_waitForServerThenReload() not found"
|
||||
fn = m.group(0)
|
||||
assert '/health' in fn, (
|
||||
"_waitForServerThenReload must poll /health to detect server readiness"
|
||||
assert "new URL('health'" in fn, (
|
||||
"_waitForServerThenReload must poll the mount-relative health endpoint "
|
||||
"to detect server readiness"
|
||||
)
|
||||
assert 'location.reload' in fn, (
|
||||
"_waitForServerThenReload must call location.reload() once the server is ready"
|
||||
@@ -396,6 +449,24 @@ class TestUiJsUpdateBanner:
|
||||
)
|
||||
|
||||
|
||||
class TestUpdateBannerUx:
|
||||
def test_update_banner_includes_repo_branch_labels(self):
|
||||
src = read('static/ui.js')
|
||||
assert 'function _formatUpdateTargetStatus' in src
|
||||
assert 'info.branch' in src
|
||||
assert "_formatUpdateTargetStatus('WebUI',data.webui)" in src
|
||||
assert "_formatUpdateTargetStatus('Agent',data.agent)" in src
|
||||
|
||||
def test_settings_update_check_uses_same_repo_branch_formatter(self):
|
||||
src = read('static/panels.js')
|
||||
m = re.search(r'async function checkUpdatesNow\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "checkUpdatesNow() not found"
|
||||
fn = m.group(0)
|
||||
assert '_formatUpdateTargetStatus' in fn
|
||||
assert "formatUpdatePart('WebUI',data.webui)" in fn
|
||||
assert "formatUpdatePart('Agent',data.agent)" in fn
|
||||
|
||||
|
||||
# ── static/index.html ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestIndexHtmlBanner:
|
||||
|
||||
@@ -6,7 +6,7 @@ initial implementation built the outer `next` parameter via:
|
||||
_next = quote(path, safe='/:@!$&\'()*+,;=')
|
||||
if query:
|
||||
_next += '?' + query
|
||||
location = '/login?next=' + quote(_next, safe='/:@!$&\'()*+,;=?')
|
||||
location = 'login?next=' + quote(_next, safe='/:@!$&\'()*+,;=?')
|
||||
|
||||
Two problems with this shape:
|
||||
|
||||
@@ -45,7 +45,7 @@ def test_login_redirect_uses_path_only_safe_encoding():
|
||||
original `safe='/:@!$&\'()*+,;=?'` shape."""
|
||||
src = (REPO / "api" / "auth.py").read_text(encoding="utf-8")
|
||||
|
||||
redirect_idx = src.find("/login?next=")
|
||||
redirect_idx = src.find("login?next=")
|
||||
assert redirect_idx != -1, "login redirect missing"
|
||||
block = src[max(0, redirect_idx - 1200) : redirect_idx + 600]
|
||||
|
||||
@@ -73,7 +73,7 @@ def _build_redirect_like_check_auth(path: str, query: str) -> str:
|
||||
if query:
|
||||
_path_with_query += "?" + query
|
||||
_next = _urlparse.quote(_path_with_query, safe="/")
|
||||
return "/login?next=" + _next
|
||||
return "login?next=" + _next
|
||||
|
||||
|
||||
def _browser_searchparams_get_next(location: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user