mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-24 18:50:15 +00:00
ad8e10304c
* fix: remove orphaned i18n keys from top-level LOCALES object Three Traditional Chinese translation keys (cmd_status, memory_saved, profile_delete_title) were placed outside any locale block between the en and ru blocks in static/i18n.js. They became top-level properties of the LOCALES object, causing them to appear as invalid language options in the Settings > Preferences dropdown. The correct translations already exist in the zh-Hant locale block. Fixes #1008 * fix: block stale SSE events from polluting new session's DOM - appendThinking(): guard with !S.session||!S.activeStreamId to drop events from a previous session's SSE stream during a session switch - appendLiveToolCard(): same guard for consistency - finalizeThinkingCard(): scroll thinking-card-body to top when scroll is pinned, so completed response is immediately visible - appendThinking(): auto-scroll thinking card body to bottom while streaming if user is watching (scroll pinned) * Fix empty agent sessions in sidebar * fix: resolve cron UI UX issues — icon ambiguity, toast overlap, running status Fixes #995 — three sub-issues in the Cron Jobs UI: 1. Dual play icons ambiguous: Resume button now shows a distinct play+bar icon (play triangle + vertical line) instead of the identical triangle used by Run now. 2. Toast notification overlapping header buttons: Added position:relative; z-index:10 to .main-view-header so it stacks above the fixed toast (z-index:100 within its layer). 3. No running status after trigger: After triggering a job, the status badge immediately shows 'running…' with a CSS spinner animation, and polls the cron list every 3s (up to 30s) to refresh when the job completes. - Added cron_status_running i18n key in all 5 locales (en, es, de, ru, zh, zh-Hant) - Added .detail-badge.running CSS class with spinner animation - New functions: _setCronDetailStatus(), _startCronRunningPoll() * fix(#1011): address review feedback — poll cleanup, badge persistence, 30s fallback - _clearCronDetail() now clears _cronRunningPoll interval on navigation - Poll re-applies 'running' badge after loadCrons() re-render (prevents flicker) - When poll ends (30s max), detail re-renders with actual status as fallback * feat: create folder and add space directly from UI (#782) - After creating a folder via the file tree New folder button, offer to add it as a space via confirm dialog - Add Create folder if it doesnt exist checkbox in the New Space form - Backend: support create flag in /api/workspaces/add to mkdir before validation - i18n: 4 new keys (folder_add_as_space_title/msg/btn, workspace_auto_create_folder) in all 6 locales * fix: validate workspace path before mkdir to prevent orphan directories Review feedback (critical): the previous code called mkdir() before validate_workspace_to_add(), which meant a rejected path (e.g. system dir) would leave an orphan directory on disk. New flow: 1. Resolve path and check against blocked system roots BEFORE any mutation 2. mkdir() only if path passes the blocklist check 3. Full validation (exists, is_dir) after mkdir Also imports _workspace_blocked_roots for the pre-mutation blocklist check. * fix(#1014): classify model-not-found errors with helpful message - Add model_not_found error type to streaming.py exception classifier - Detect 404, 'not found', 'does not exist', 'invalid model' patterns - Strip HTML tags from provider error messages (nginx 404 pages, etc.) - Add model_not_found branch to apperror handler in messages.js - Add i18n key model_not_found_label in all 6 locales - 15 tests covering detection, sanitization, frontend, and i18n * feat(ui): add live TPS stat to header Adds a TPS (Tokens Per Second) chip to the right of the header title bar that updates live while AI output is streaming. Metering (api/metering.py) - Tracks per-session output + reasoning tokens via GlobalMeter singleton - Per-session TPS = total_tokens / elapsed_time - Global TPS = average of active sessions' TPS values - HIGH/LOW are max/min of global_tps snapshots over a 60-minute rolling window (only recorded when > 0, so idle periods are excluded) - Thread-safe with a single lock Metering events emitted from streaming.py - Throttled at 100ms from token/reasoning/tool callbacks so the display updates rapidly during fast token streams - 1Hz ticker as fallback for slow streams (exits when no active sessions) - Final stats emitted on stream end Routes (api/routes.py) - Removed POST /api/metering/interval endpoint (dynamic interval via focus/blur was replaced with simple always-1s-when-active approach) UI (static/messages.js, index.html, style.css) - TPS chip in titlebar: shows 'N.N t/s . N.N high . N.N low' - Default: '0.0 t/s . 0.0 high' when idle - Display updates on every metering SSE event (throttled to 100ms) * feat: session restore speed + title gen reasoning hardening (#1025, #1026) PR #1025 (@franksong2702): Speed up large session restore paths - GET /api/session?messages=0 now parses only metadata before the messages array - Metadata-only loads no longer populate the full-session LRU cache - Frontend lazy fetch uses resolve_model=0 to avoid cold model-catalog lookup - Hard reload no longer waits for populateModelDropdown() before restoring session PR #1026 (@franksong2702): Harden auto title generation for reasoning models - Raises title-gen completion budget to 512 tokens (reasoning-safe) - Retries once with 1024 tokens on empty content / finish_reason:length - Applies retry to both auxiliary and active-agent fallback routes - Preserves underlying failure reason in title_status on local fallback Co-authored-by: Frank Song <franksong2702@gmail.com> * feat: session attention indicators in right slot + last_message_at timestamps (#1024) PR #1024 (@franksong2702): Polish session attention indicators - Streaming spinners and unread dots now reuse the right-side actions slot - Running/unread rows hide timestamps; idle/read rows keep right-aligned timestamps - Date group carets point down when expanded, right when collapsed - Pinned group no longer repeats pinned-star icon per row - Running indicators appear immediately after send (local busy state while /api/sessions catches up) - Sidebar sorting/grouping/timestamps now prefer last_message_at (derived from last real message) so metadata-only saves don't make old sessions appear under Today Co-authored-by: Frank Song <franksong2702@gmail.com> * docs: v0.50.207 release notes — 10 PRs, 2169 tests (+36) --------- Co-authored-by: bergeouss <bergeouss@users.noreply.github.com> Co-authored-by: Josh <josh@fyul.link> Co-authored-by: Frank Song <franksong2702@gmail.com> Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
632 lines
24 KiB
Python
632 lines
24 KiB
Python
"""
|
|
Tests for Phase 1: Real-time Gateway Session Sync.
|
|
|
|
Tests are ordered TDD-style:
|
|
1. Gateway sessions appear in /api/sessions when setting enabled
|
|
2. Gateway sessions excluded when setting disabled
|
|
3. Gateway sessions have correct metadata (source_tag, is_cli_session)
|
|
4. SSE stream endpoint opens and receives events
|
|
5. Watcher detects new sessions inserted into state.db
|
|
6. Settings UI has renamed label
|
|
"""
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import sqlite3
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
|
|
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
|
from tests._pytest_port import BASE
|
|
|
|
|
|
def get(path):
|
|
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
|
return json.loads(r.read()), r.status
|
|
|
|
|
|
def post(path, body=None):
|
|
data = json.dumps(body or {}).encode()
|
|
req = urllib.request.Request(BASE + path, data=data,
|
|
headers={"Content-Type": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=10) as r:
|
|
return json.loads(r.read()), r.status
|
|
except urllib.error.HTTPError as e:
|
|
try:
|
|
return json.loads(e.read()), e.code
|
|
except Exception:
|
|
return {}, e.code
|
|
|
|
|
|
def _get_test_state_dir():
|
|
"""Return the test state directory (matches conftest.py TEST_STATE_DIR).
|
|
|
|
conftest.py sets HERMES_WEBUI_TEST_STATE_DIR in the test-process environment
|
|
(via os.environ.setdefault) so that tests writing directly to state.db always
|
|
use the same path the test server was started with. If the env var is not
|
|
set (e.g. when running this file standalone), fall back to the conftest
|
|
formula: HERMES_HOME/webui-mvp-test.
|
|
"""
|
|
# Use _pytest_port which applies the same auto-derivation as conftest.py
|
|
from tests._pytest_port import TEST_STATE_DIR as _ptsd
|
|
return _ptsd
|
|
|
|
|
|
def _get_state_db_path():
|
|
"""Return path to the test state.db."""
|
|
return _get_test_state_dir() / 'state.db'
|
|
|
|
|
|
def _ensure_state_db():
|
|
"""Create state.db with sessions and messages tables if it doesn't exist.
|
|
Returns a connection. Does NOT delete existing data (safe for parallel tests).
|
|
"""
|
|
db_path = _get_state_db_path()
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(str(db_path))
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
id TEXT PRIMARY KEY,
|
|
source TEXT NOT NULL,
|
|
user_id TEXT,
|
|
model TEXT,
|
|
started_at REAL NOT NULL,
|
|
message_count INTEGER DEFAULT 0,
|
|
title TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT,
|
|
timestamp REAL NOT NULL
|
|
);
|
|
""")
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
def _insert_gateway_session(conn, session_id='20260401_120000_abcdefgh', source='telegram',
|
|
title='Telegram Chat', model='anthropic/claude-sonnet-4-5',
|
|
started_at=None, message_count=2):
|
|
"""Insert a gateway session into state.db."""
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(session_id, source, title, model, started_at or time.time(), message_count)
|
|
)
|
|
# Delete any existing messages for this session (idempotent re-insert)
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,))
|
|
# Insert some messages
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'user', ?, ?)",
|
|
(session_id, 'Hello from Telegram', started_at or time.time())
|
|
)
|
|
conn.execute(
|
|
"INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, 'assistant', ?, ?)",
|
|
(session_id, 'Hi there!', (started_at or time.time()) + 1)
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def _remove_test_sessions(conn, *session_ids):
|
|
"""Remove specific test sessions from state.db (parallel-safe cleanup)."""
|
|
for sid in session_ids:
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
|
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
|
conn.commit()
|
|
|
|
|
|
def _cleanup_state_db():
|
|
"""Remove state.db if it exists (only used for tests that need a blank slate)."""
|
|
db_path = _get_state_db_path()
|
|
for p in [db_path, db_path.parent / 'state.db-wal', db_path.parent / 'state.db-shm']:
|
|
try:
|
|
p.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
# ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
def test_gateway_sessions_appear_when_enabled():
|
|
"""Gateway sessions from state.db appear in /api/sessions when show_cli_sessions is on."""
|
|
conn = _ensure_state_db()
|
|
try:
|
|
_insert_gateway_session(conn, session_id='gw_test_tg_001', source='telegram', title='TG Test Chat')
|
|
|
|
# Enable the setting
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
sessions = data.get('sessions', [])
|
|
gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_tg_001']
|
|
assert len(gw_ids) == 1, f"Expected gateway session gw_test_tg_001, got {[s['session_id'] for s in sessions]}"
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, 'gw_test_tg_001')
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_gateway_sessions_without_messages_are_hidden_from_sidebar():
|
|
"""Regression: empty agent session rows must not appear as broken sidebar entries."""
|
|
conn = _ensure_state_db()
|
|
empty_sid = 'gw_empty_no_messages_001'
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(empty_sid, 'cron', 'Cron Session', 'openai/gpt-5', time.time(), 0),
|
|
)
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
|
|
conn.commit()
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
sessions = data.get('sessions', [])
|
|
assert empty_sid not in {s.get('session_id') for s in sessions}, (
|
|
"Agent sessions with no readable message rows should be filtered before "
|
|
"they reach the sidebar; otherwise clicking them fails during import."
|
|
)
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, empty_sid)
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_gateway_watcher_hides_sessions_without_messages(monkeypatch):
|
|
"""Regression: SSE watcher must use the same importable-agent filter."""
|
|
conn = _ensure_state_db()
|
|
empty_sid = 'gw_empty_watcher_001'
|
|
live_sid = 'gw_live_watcher_001'
|
|
try:
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO sessions (id, source, title, model, started_at, message_count) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(empty_sid, 'cron', 'Empty Cron Session', 'openai/gpt-5', time.time(), 0),
|
|
)
|
|
conn.execute("DELETE FROM messages WHERE session_id = ?", (empty_sid,))
|
|
_insert_gateway_session(
|
|
conn,
|
|
session_id=live_sid,
|
|
source='cron',
|
|
title='Live Cron Session',
|
|
message_count=0,
|
|
)
|
|
|
|
import api.gateway_watcher as gateway_watcher
|
|
|
|
monkeypatch.setattr(gateway_watcher, '_get_state_db_path', _get_state_db_path)
|
|
|
|
sessions = gateway_watcher._get_agent_sessions_from_db()
|
|
ids = {s.get('session_id') for s in sessions}
|
|
live = next((s for s in sessions if s.get('session_id') == live_sid), None)
|
|
|
|
assert empty_sid not in ids
|
|
assert live is not None
|
|
assert live.get('message_count') == 2, (
|
|
"Watcher should fall back to actual message rows when stored "
|
|
"message_count is zero, matching the sidebar route."
|
|
)
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, empty_sid, live_sid)
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def test_gateway_sessions_excluded_when_disabled():
|
|
"""Gateway sessions are NOT returned when show_cli_sessions is off."""
|
|
conn = _ensure_state_db()
|
|
try:
|
|
_insert_gateway_session(conn, session_id='gw_test_dc_001', source='discord', title='DC Test Chat')
|
|
|
|
# Ensure setting is off
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
sessions = data.get('sessions', [])
|
|
gw_ids = [s['session_id'] for s in sessions if s.get('session_id') == 'gw_test_dc_001']
|
|
assert len(gw_ids) == 0, "Gateway session should not appear when setting is off"
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, 'gw_test_dc_001')
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def test_gateway_session_has_correct_metadata():
|
|
"""Gateway sessions include source_tag and is_cli_session fields."""
|
|
conn = _ensure_state_db()
|
|
try:
|
|
_insert_gateway_session(conn, session_id='gw_meta_001', source='telegram', title='Meta Test')
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
sessions = data.get('sessions', [])
|
|
gw = next((s for s in sessions if s['session_id'] == 'gw_meta_001'), None)
|
|
assert gw is not None, "Gateway session not found"
|
|
assert gw.get('source_tag') == 'telegram', f"Expected source_tag=telegram, got {gw.get('source_tag')}"
|
|
assert gw.get('is_cli_session') is True, "is_cli_session should be True for agent sessions"
|
|
assert gw.get('title') == 'Meta Test'
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, 'gw_meta_001')
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_gateway_session_has_message_count():
|
|
"""Gateway sessions report correct message_count from state.db."""
|
|
conn = _ensure_state_db()
|
|
try:
|
|
_insert_gateway_session(conn, session_id='gw_msg_001', source='discord', title='Msg Count Test', message_count=5)
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
sessions = data.get('sessions', [])
|
|
gw = next((s for s in sessions if s['session_id'] == 'gw_msg_001'), None)
|
|
assert gw is not None
|
|
assert gw.get('message_count') == 5, f"Expected message_count=5, got {gw.get('message_count')}"
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, 'gw_msg_001')
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_gateway_sessions_multiple_sources():
|
|
"""Sessions from multiple gateway sources (telegram, discord, slack) all appear."""
|
|
conn = _ensure_state_db()
|
|
try:
|
|
_insert_gateway_session(conn, session_id='gw_multi_tg', source='telegram', title='TG Chat')
|
|
_insert_gateway_session(conn, session_id='gw_multi_dc', source='discord', title='DC Chat')
|
|
_insert_gateway_session(conn, session_id='gw_multi_sl', source='slack', title='SL Chat')
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
sessions = data.get('sessions', [])
|
|
gw_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')}
|
|
assert len(gw_ids) == 3, f"Expected 3 gateway sessions, got {len(gw_ids)}: {gw_ids}"
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, 'gw_multi_tg', 'gw_multi_dc', 'gw_multi_sl')
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_gateway_session_messages_readable():
|
|
"""Gateway session messages can be loaded via /api/session."""
|
|
conn = _ensure_state_db()
|
|
try:
|
|
_insert_gateway_session(conn, session_id='gw_read_001', source='telegram', title='Readable')
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
data, status = get(f'/api/session?session_id=gw_read_001')
|
|
assert status == 200
|
|
msgs = data.get('session', {}).get('messages', [])
|
|
assert len(msgs) >= 2, f"Expected at least 2 messages, got {len(msgs)}"
|
|
assert msgs[0].get('role') == 'user'
|
|
assert msgs[0].get('content') == 'Hello from Telegram'
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, 'gw_read_001')
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_importing_older_gateway_session_preserves_original_timestamps_and_order():
|
|
"""Importing an older gateway session should not bump it above newer WebUI sessions."""
|
|
conn = _ensure_state_db()
|
|
older_started_at = time.time() - 1800
|
|
imported_sid = 'gw_import_old_001'
|
|
newer_webui_sid = None
|
|
try:
|
|
newer_webui, status = post('/api/session/new', {'model': 'openai/gpt-5'})
|
|
assert status == 200, newer_webui
|
|
newer_webui_sid = newer_webui['session']['session_id']
|
|
|
|
rename, rename_status = post(
|
|
'/api/session/rename',
|
|
{'session_id': newer_webui_sid, 'title': 'Newer WebUI Session'},
|
|
)
|
|
assert rename_status == 200, rename
|
|
|
|
_insert_gateway_session(
|
|
conn,
|
|
session_id=imported_sid,
|
|
source='discord',
|
|
title='Older imported gateway session',
|
|
started_at=older_started_at,
|
|
)
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
imported, imported_status = post('/api/session/import_cli', {'session_id': imported_sid})
|
|
assert imported_status == 200, imported
|
|
imported_session = imported['session']
|
|
assert abs(imported_session['created_at'] - older_started_at) < 2, imported_session
|
|
assert abs(imported_session['updated_at'] - older_started_at) < 5, imported_session
|
|
|
|
sessions_payload, sessions_status = get('/api/sessions')
|
|
assert sessions_status == 200, sessions_payload
|
|
ordered_ids = [item['session_id'] for item in sessions_payload.get('sessions', [])]
|
|
assert newer_webui_sid in ordered_ids, ordered_ids
|
|
assert imported_sid in ordered_ids, ordered_ids
|
|
assert ordered_ids.index(newer_webui_sid) < ordered_ids.index(imported_sid), ordered_ids
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, imported_sid)
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
if imported_sid:
|
|
try:
|
|
post('/api/session/delete', {'session_id': imported_sid})
|
|
except Exception:
|
|
pass
|
|
if newer_webui_sid:
|
|
try:
|
|
post('/api/session/delete', {'session_id': newer_webui_sid})
|
|
except Exception:
|
|
pass
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
|
|
def test_gateway_sse_stream_endpoint_exists():
|
|
"""GET /api/sessions/gateway/stream returns a response (200 or 200-range)."""
|
|
# The SSE endpoint requires show_cli_sessions to be enabled
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
try:
|
|
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream')
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
assert r.status in (200, 204), f"Expected 200/204, got {r.status}"
|
|
# SSE should have content-type text/event-stream
|
|
ctype = r.headers.get('Content-Type', '')
|
|
assert 'text/event-stream' in ctype, f"Expected text/event-stream, got {ctype}"
|
|
except Exception as e:
|
|
# Timeout is acceptable — means the connection is held open (SSE behavior)
|
|
if 'timed out' in str(e).lower() or 'timeout' in str(e).lower():
|
|
pass # Good: SSE keeps the connection open
|
|
else:
|
|
raise
|
|
finally:
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_gateway_sse_stream_probe_reports_status():
|
|
"""Probe mode returns JSON watcher status instead of holding open an SSE stream."""
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
try:
|
|
req = urllib.request.Request(BASE + '/api/sessions/gateway/stream?probe=1')
|
|
with urllib.request.urlopen(req, timeout=5) as r:
|
|
assert r.status == 200, f"Expected 200, got {r.status}"
|
|
ctype = r.headers.get('Content-Type', '')
|
|
assert 'application/json' in ctype, f"Expected application/json, got {ctype}"
|
|
data = json.loads(r.read().decode('utf-8'))
|
|
assert data['enabled'] is True
|
|
assert 'watcher_running' in data
|
|
assert data['fallback_poll_ms'] == 30000
|
|
finally:
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_gateway_webui_sessions_not_duplicated():
|
|
"""If a session_id exists both in WebUI store and state.db, it's not duplicated."""
|
|
# Create a WebUI session with a known ID
|
|
body = {}
|
|
d, _ = post('/api/session/new', body)
|
|
webui_sid = d['session']['session_id']
|
|
|
|
try:
|
|
# Insert the same session_id into state.db as a gateway session
|
|
conn = _ensure_state_db()
|
|
_insert_gateway_session(conn, session_id=webui_sid, source='telegram', title='Dup Test')
|
|
conn.close()
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
sessions = data.get('sessions', [])
|
|
matching = [s for s in sessions if s['session_id'] == webui_sid]
|
|
assert len(matching) == 1, f"Expected 1 entry for {webui_sid}, got {len(matching)}"
|
|
finally:
|
|
try:
|
|
conn2 = sqlite3.connect(str(_get_state_db_path()))
|
|
_remove_test_sessions(conn2, webui_sid)
|
|
conn2.close()
|
|
except Exception:
|
|
pass
|
|
post('/api/session/delete', {'session_id': webui_sid})
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_gateway_sessions_no_state_db():
|
|
"""When state.db doesn't exist, /api/sessions works fine (no gateway sessions)."""
|
|
_cleanup_state_db()
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
try:
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
# Should succeed with just webui sessions (or empty)
|
|
assert 'sessions' in data
|
|
finally:
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
def test_cli_sessions_still_work():
|
|
"""CLI sessions (source='cli') still appear alongside gateway sessions."""
|
|
conn = _ensure_state_db()
|
|
try:
|
|
_insert_gateway_session(conn, session_id='cli_legacy_001', source='cli', title='CLI Legacy')
|
|
_insert_gateway_session(conn, session_id='gw_new_001', source='telegram', title='GW New')
|
|
|
|
post('/api/settings', {'show_cli_sessions': True})
|
|
|
|
data, status = get('/api/sessions')
|
|
assert status == 200
|
|
sessions = data.get('sessions', [])
|
|
agent_ids = {s['session_id'] for s in sessions if s.get('session_id') in ('cli_legacy_001', 'gw_new_001')}
|
|
assert len(agent_ids) == 2, f"Expected 2 agent sessions (cli + gateway), got {len(agent_ids)}"
|
|
finally:
|
|
try:
|
|
_remove_test_sessions(conn, 'cli_legacy_001', 'gw_new_001')
|
|
conn.close()
|
|
except Exception:
|
|
pass
|
|
post('/api/settings', {'show_cli_sessions': False})
|
|
|
|
|
|
# ── Unit tests for _gateway_sse_probe_payload ────────────────────────────────
|
|
# These replace the deleted repo-root test_gateway_sse_probe_unit.py and account
|
|
# for the watcher_alive check (thread existence + is_alive()).
|
|
|
|
import sys
|
|
import threading
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
from api.routes import _gateway_sse_probe_payload
|
|
|
|
|
|
def test_probe_payload_when_disabled():
|
|
"""Probe returns 404 when show_cli_sessions is False."""
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': False}, watcher=None)
|
|
assert status == 404
|
|
assert body['ok'] is False
|
|
assert body['enabled'] is False
|
|
assert body['watcher_running'] is False
|
|
assert body['error'] == 'agent sessions not enabled'
|
|
assert body['fallback_poll_ms'] == 30000
|
|
|
|
|
|
def test_probe_payload_when_watcher_missing():
|
|
"""Probe returns 503 when enabled but no watcher instance."""
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=None)
|
|
assert status == 503
|
|
assert body['ok'] is False
|
|
assert body['enabled'] is True
|
|
assert body['watcher_running'] is False
|
|
assert body['error'] == 'watcher not started'
|
|
assert body['fallback_poll_ms'] == 30000
|
|
|
|
|
|
def test_probe_payload_when_watcher_instance_no_thread():
|
|
"""Probe returns 503 when watcher exists but _thread attribute is missing/None."""
|
|
class _FakeWatcher:
|
|
_thread = None
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=_FakeWatcher())
|
|
assert status == 503
|
|
assert body['watcher_running'] is False
|
|
|
|
|
|
def test_probe_payload_when_watcher_thread_alive():
|
|
"""Probe returns 200 when enabled and watcher thread is alive."""
|
|
class _FakeWatcher:
|
|
pass
|
|
w = _FakeWatcher()
|
|
t = threading.Thread(target=lambda: None)
|
|
t.daemon = True
|
|
t.start()
|
|
w._thread = t
|
|
# Thread may finish fast — loop-start a live daemon thread for reliability
|
|
import time as _time
|
|
done = threading.Event()
|
|
live = threading.Thread(target=done.wait, daemon=True)
|
|
live.start()
|
|
w._thread = live
|
|
try:
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
|
|
assert status == 200
|
|
assert body['ok'] is True
|
|
assert body['watcher_running'] is True
|
|
assert body['fallback_poll_ms'] == 30000
|
|
finally:
|
|
done.set()
|
|
live.join(timeout=1)
|
|
|
|
|
|
def test_probe_payload_when_watcher_thread_dead():
|
|
"""Probe returns 503 when watcher instance exists but thread has exited."""
|
|
class _FakeWatcher:
|
|
pass
|
|
w = _FakeWatcher()
|
|
t = threading.Thread(target=lambda: None)
|
|
t.start()
|
|
t.join() # wait for it to finish
|
|
w._thread = t
|
|
body, status = _gateway_sse_probe_payload({'show_cli_sessions': True}, watcher=w)
|
|
assert status == 503
|
|
assert body['watcher_running'] is False
|
|
assert body['ok'] is False
|
|
|
|
|
|
def test_gateway_watcher_is_alive_public_method():
|
|
"""GatewayWatcher.is_alive() is the public API the probe uses. Cover all
|
|
three states: before start(), while running, after stop()."""
|
|
from api.gateway_watcher import GatewayWatcher
|
|
w = GatewayWatcher()
|
|
# Before start(): no thread
|
|
assert w.is_alive() is False, "is_alive() must be False before start()"
|
|
# After start(): thread running
|
|
w.start()
|
|
try:
|
|
assert w.is_alive() is True, "is_alive() must be True while running"
|
|
finally:
|
|
w.stop()
|
|
# After stop(): thread cleared
|
|
assert w.is_alive() is False, "is_alive() must be False after stop()"
|
|
|
|
|
|
def test_probe_payload_prefers_public_is_alive():
|
|
"""Regression guard: _gateway_sse_probe_payload must call watcher.is_alive()
|
|
rather than poking at _thread directly when the public method exists."""
|
|
calls = []
|
|
|
|
class _WatcherWithPublicApi:
|
|
def is_alive(self):
|
|
calls.append('is_alive')
|
|
return True
|
|
# _thread is deliberately absent — must not be accessed.
|
|
|
|
body, status = _gateway_sse_probe_payload(
|
|
{'show_cli_sessions': True},
|
|
watcher=_WatcherWithPublicApi(),
|
|
)
|
|
assert status == 200
|
|
assert body['watcher_running'] is True
|
|
assert calls == ['is_alive'], (
|
|
"probe must prefer the public is_alive() method over poking _thread"
|
|
)
|