Hermes Web UI — Sprints 11-14: multi-provider models, settings, session QoL, alerts, polish

Sprint 11 (v0.13): multi-provider model support, streaming smoothness
- Dynamic model dropdown populated from configured API keys (OpenAI, Anthropic,
  Google, DeepSeek, GLM, Kimi, MiniMax, OpenRouter, Nous Portal)
- Scroll pinning during streaming (no forced scroll when user has scrolled up)
- All route handlers extracted to api/routes.py (server.py now ~76 lines)

Sprint 12 (v0.14): settings panel, SSE reconnect, session QoL
- Settings panel (gear icon) -- persist default model and workspace server-side
- SSE auto-reconnect on network blips
- Pin/star sessions to top of sidebar
- Import session from JSON export

Sprint 13 (v0.15): cron alerts, background errors, session duplicate, tab title
- Cron completion alerts: toast per completion + unread badge on Tasks tab
- Background agent error banner when a non-active session errors mid-stream
- Session duplicate button
- Browser tab title reflects active session name

Sprint 14 (v0.16): Mermaid diagrams, file ops, session archive/tags, timestamps
- Mermaid diagram rendering inline (dark theme, lazy CDN load)
- File rename (double-click in file tree) and create folder
- Session archive (hide without deleting, toggle to show)
- Session tags -- #hashtag in title becomes colored chip + click-to-filter
- Message timestamps (HH:MM on hover, full date as tooltip)

Test suite: 224 tests across 14 sprint files + regression gate, 0 failures.
This commit is contained in:
Hermes
2026-03-31 07:02:47 +00:00
parent 732d227b97
commit 7019c25021
29 changed files with 2871 additions and 1122 deletions

120
tests/test_sprint13.py Normal file
View File

@@ -0,0 +1,120 @@
"""
Sprint 13 Tests: cron recent endpoint, session duplicate, background alerts.
"""
import json, pathlib, urllib.error, urllib.request
BASE = "http://127.0.0.1:8788"
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:
return json.loads(e.read()), e.code
def make_session(created_list):
d, _ = post("/api/session/new", {})
sid = d["session"]["session_id"]
created_list.append(sid)
return sid, d["session"]
# ── Cron recent endpoint ──────────────────────────────────────────────────
def test_crons_recent_returns_200():
"""GET /api/crons/recent returns completions list."""
d, status = get("/api/crons/recent?since=0")
assert status == 200
assert 'completions' in d
assert isinstance(d['completions'], list)
assert 'since' in d
def test_crons_recent_with_future_since():
"""Completions list is empty when since is in the future."""
import time
d, _ = get(f"/api/crons/recent?since={time.time() + 99999}")
assert d['completions'] == []
def test_crons_recent_default_since():
"""Default since=0 returns all completions."""
d, status = get("/api/crons/recent")
assert status == 200
assert 'completions' in d
# ── Session duplicate ─────────────────────────────────────────────────────
def test_duplicate_session():
"""Duplicating a session creates a new one with same workspace/model."""
created = []
try:
sid, sess = make_session(created)
# Set a specific model on the session
post("/api/session/update", {
"session_id": sid, "model": "test/dup-model",
"workspace": sess["workspace"]
})
# Duplicate: create new session with same workspace/model
d2, status = post("/api/session/new", {
"workspace": sess["workspace"], "model": "test/dup-model"
})
assert status == 200
new_sid = d2["session"]["session_id"]
created.append(new_sid)
assert new_sid != sid
assert d2["session"]["model"] == "test/dup-model"
assert d2["session"]["workspace"] == sess["workspace"]
finally:
for s in created:
post("/api/session/delete", {"session_id": s})
# ── Session pinned field preserved across operations ──────────────────────
def test_pinned_survives_update():
"""Pinned status survives session update."""
created = []
try:
sid, sess = make_session(created)
post("/api/session/pin", {"session_id": sid, "pinned": True})
# Update workspace/model
post("/api/session/update", {
"session_id": sid, "model": "test/other",
"workspace": sess["workspace"]
})
d, _ = get(f"/api/session?session_id={sid}")
assert d["session"]["pinned"] is True
finally:
for s in created:
post("/api/session/delete", {"session_id": s})
# ── Workspace symlink validation ──────────────────────────────────────────
def test_workspace_add_rejects_nonexistent():
"""Adding a non-existent path returns 400."""
d, status = post("/api/workspaces/add", {"path": "/nonexistent/path/12345"})
assert status == 400
def test_workspace_add_accepts_real_dir():
"""Adding a real directory succeeds."""
import tempfile
tmp = tempfile.mkdtemp()
try:
d, status = post("/api/workspaces/add", {"path": tmp, "name": "test-ws"})
assert status == 200
assert d["ok"] is True
finally:
post("/api/workspaces/remove", {"path": tmp})
import shutil
shutil.rmtree(tmp, ignore_errors=True)