feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Cherry-pick the Hermes Web Console from gary-the-ai/hermes-web-console-gui. React + TypeScript frontend with Vite, Python aiohttp backend API. Components: - web_console/ — React frontend (chat, sessions, memory, settings, skills, gateway config, cron, workspace, tools, browser, insights pages) - gateway/web_console/ — Python backend API (23 endpoints, SSE event bus, 11 service modules) - gateway/platforms/api_server_ui.py — embedded browser UI for API server - gateway/platforms/api_server.py — route registration refactored into _register_routes(), web console mounted via maybe_register_web_console() - run-gui.sh / setup-gui.sh — one-command launch and setup scripts - tests/gateway/test_api_server_gui_mount.py — 4 integration tests (passing) - tests/web_console/ — 13 backend test files (51 passing) - docs/plans/ — implementation plan, API schema, frontend architecture Fix: added missing ModelContextError class and CRON_MIN_CONTEXT_TOKENS to cron/scheduler.py (pre-existing import bug). Closes #325
This commit is contained in:
281
tests/web_console/test_memory_api.py
Normal file
281
tests/web_console/test_memory_api.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Tests for the web console memory and session-search APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.api.memory import MEMORY_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
from gateway.web_console.services import memory_service as memory_service_module
|
||||
from gateway.web_console.services.memory_service import MemoryService
|
||||
|
||||
|
||||
class FakeMemoryService:
|
||||
def __init__(self) -> None:
|
||||
self.payloads = {
|
||||
"memory": {
|
||||
"target": "memory",
|
||||
"enabled": True,
|
||||
"entries": ["Project prefers pytest."],
|
||||
"entry_count": 1,
|
||||
"usage": {"text": "1% — 22/2200 chars", "percent": 1, "current_chars": 22, "char_limit": 2200},
|
||||
"path": "/tmp/MEMORY.md",
|
||||
},
|
||||
"user": {
|
||||
"target": "user",
|
||||
"enabled": True,
|
||||
"entries": ["User likes concise answers."],
|
||||
"entry_count": 1,
|
||||
"usage": {"text": "2% — 30/1375 chars", "percent": 2, "current_chars": 30, "char_limit": 1375},
|
||||
"path": "/tmp/USER.md",
|
||||
},
|
||||
}
|
||||
self.search_payload = {
|
||||
"success": True,
|
||||
"query": "deploy OR docker",
|
||||
"results": [{"session_id": "sess-1", "summary": "We fixed the deploy issue.", "source": "cli", "when": "today", "model": "hermes"}],
|
||||
"count": 1,
|
||||
"sessions_searched": 1,
|
||||
}
|
||||
|
||||
def get_memory(self, *, target="memory"):
|
||||
if target not in self.payloads:
|
||||
raise ValueError("bad target")
|
||||
return self.payloads[target]
|
||||
|
||||
def mutate_memory(self, *, action, target="memory", content=None, old_text=None):
|
||||
if target == "disabled":
|
||||
raise PermissionError("Local memory is disabled in config.")
|
||||
if target not in self.payloads:
|
||||
raise ValueError("bad target")
|
||||
if content == "fail" or old_text == "missing":
|
||||
return {
|
||||
**self.payloads[target],
|
||||
"success": False,
|
||||
"error": "No entry matched 'missing'.",
|
||||
"matches": ["candidate one"],
|
||||
}
|
||||
payload = dict(self.payloads[target])
|
||||
payload["success"] = True
|
||||
payload["message"] = f"{action} ok"
|
||||
if action == "add" and content:
|
||||
payload["entries"] = payload["entries"] + [content]
|
||||
payload["entry_count"] = len(payload["entries"])
|
||||
return payload
|
||||
|
||||
def search_sessions(self, *, query, role_filter=None, limit=3, current_session_id=None):
|
||||
if query == "explode":
|
||||
raise RuntimeError("boom")
|
||||
if query == "offline":
|
||||
return {"success": False, "error": "Session database not available."}
|
||||
payload = dict(self.search_payload)
|
||||
payload["query"] = query
|
||||
payload["role_filter"] = role_filter
|
||||
payload["limit"] = limit
|
||||
payload["current_session_id"] = current_session_id
|
||||
return payload
|
||||
|
||||
|
||||
class TestMemoryApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeMemoryService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[MEMORY_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_routes_return_structured_payloads(self):
|
||||
client = await self._make_client(FakeMemoryService())
|
||||
try:
|
||||
memory_resp = await client.get("/api/gui/memory")
|
||||
assert memory_resp.status == 200
|
||||
memory_payload = await memory_resp.json()
|
||||
assert memory_payload["ok"] is True
|
||||
assert memory_payload["memory"]["target"] == "memory"
|
||||
assert memory_payload["memory"]["entries"] == ["Project prefers pytest."]
|
||||
|
||||
profile_resp = await client.get("/api/gui/user-profile")
|
||||
assert profile_resp.status == 200
|
||||
profile_payload = await profile_resp.json()
|
||||
assert profile_payload["ok"] is True
|
||||
assert profile_payload["user_profile"]["target"] == "user"
|
||||
assert profile_payload["user_profile"]["entries"] == ["User likes concise answers."]
|
||||
|
||||
add_resp = await client.post("/api/gui/memory", json={"target": "user", "content": "Prefers dark mode."})
|
||||
assert add_resp.status == 200
|
||||
add_payload = await add_resp.json()
|
||||
assert add_payload["ok"] is True
|
||||
assert add_payload["memory"]["target"] == "user"
|
||||
assert add_payload["memory"]["message"] == "add ok"
|
||||
|
||||
replace_resp = await client.patch(
|
||||
"/api/gui/memory",
|
||||
json={"target": "memory", "old_text": "pytest", "content": "Project prefers pytest -q."},
|
||||
)
|
||||
assert replace_resp.status == 200
|
||||
replace_payload = await replace_resp.json()
|
||||
assert replace_payload["memory"]["message"] == "replace ok"
|
||||
|
||||
delete_resp = await client.delete("/api/gui/memory", json={"target": "memory", "old_text": "pytest"})
|
||||
assert delete_resp.status == 200
|
||||
delete_payload = await delete_resp.json()
|
||||
assert delete_payload["memory"]["message"] == "remove ok"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_search_route_and_structured_errors(self):
|
||||
client = await self._make_client(FakeMemoryService())
|
||||
try:
|
||||
search_resp = await client.get(
|
||||
"/api/gui/session-search?query=deploy%20OR%20docker&role_filter=user,assistant&limit=2¤t_session_id=sess-live"
|
||||
)
|
||||
assert search_resp.status == 200
|
||||
search_payload = await search_resp.json()
|
||||
assert search_payload["ok"] is True
|
||||
assert search_payload["search"]["query"] == "deploy OR docker"
|
||||
assert search_payload["search"]["count"] == 1
|
||||
assert search_payload["search"]["role_filter"] == "user,assistant"
|
||||
assert search_payload["search"]["limit"] == 2
|
||||
assert search_payload["search"]["current_session_id"] == "sess-live"
|
||||
|
||||
missing_query_resp = await client.get("/api/gui/session-search")
|
||||
assert missing_query_resp.status == 400
|
||||
assert (await missing_query_resp.json())["error"]["code"] == "missing_query"
|
||||
|
||||
invalid_limit_resp = await client.get("/api/gui/session-search?query=deploy&limit=0")
|
||||
assert invalid_limit_resp.status == 400
|
||||
assert (await invalid_limit_resp.json())["error"]["code"] == "invalid_search"
|
||||
|
||||
unavailable_resp = await client.get("/api/gui/session-search?query=offline")
|
||||
assert unavailable_resp.status == 503
|
||||
assert (await unavailable_resp.json())["error"]["code"] == "search_failed"
|
||||
|
||||
failed_resp = await client.get("/api/gui/session-search?query=explode")
|
||||
assert failed_resp.status == 500
|
||||
assert (await failed_resp.json())["error"]["code"] == "search_failed"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_routes_validate_payloads(self):
|
||||
client = await self._make_client(FakeMemoryService())
|
||||
try:
|
||||
invalid_json_resp = await client.post(
|
||||
"/api/gui/memory",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
assert (await invalid_json_resp.json())["error"]["code"] == "invalid_json"
|
||||
|
||||
failed_update_resp = await client.patch(
|
||||
"/api/gui/memory",
|
||||
json={"target": "memory", "old_text": "missing", "content": "fail"},
|
||||
)
|
||||
assert failed_update_resp.status == 400
|
||||
failed_update_payload = await failed_update_resp.json()
|
||||
assert failed_update_payload["error"]["code"] == "memory_update_failed"
|
||||
assert failed_update_payload["error"]["matches"] == ["candidate one"]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestMemoryService:
|
||||
def test_memory_service_formats_store_and_search_payloads(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
class FakeStore:
|
||||
def __init__(self):
|
||||
self.memory_entries = []
|
||||
self.user_entries = []
|
||||
self.memory_char_limit = 2200
|
||||
self.user_char_limit = 1375
|
||||
|
||||
@staticmethod
|
||||
def _path_for(target):
|
||||
return tmp_path / ("USER.md" if target == "user" else "MEMORY.md")
|
||||
|
||||
def _char_count(self, target):
|
||||
entries = self.user_entries if target == "user" else self.memory_entries
|
||||
return len("\n§\n".join(entries)) if entries else 0
|
||||
|
||||
def _char_limit(self, target):
|
||||
return self.user_char_limit if target == "user" else self.memory_char_limit
|
||||
|
||||
def add(self, target, content):
|
||||
entries = self.user_entries if target == "user" else self.memory_entries
|
||||
entries.append(content)
|
||||
return {"success": True, "message": "Entry added."}
|
||||
|
||||
def replace(self, target, old_text, content):
|
||||
entries = self.user_entries if target == "user" else self.memory_entries
|
||||
for index, entry in enumerate(entries):
|
||||
if old_text in entry:
|
||||
entries[index] = content
|
||||
return {"success": True, "message": "Entry replaced."}
|
||||
return {"success": False, "error": f"No entry matched '{old_text}'."}
|
||||
|
||||
def remove(self, target, old_text):
|
||||
entries = self.user_entries if target == "user" else self.memory_entries
|
||||
for index, entry in enumerate(entries):
|
||||
if old_text in entry:
|
||||
entries.pop(index)
|
||||
return {"success": True, "message": "Entry removed."}
|
||||
return {"success": False, "error": f"No entry matched '{old_text}'."}
|
||||
|
||||
monkeypatch.setattr(
|
||||
memory_service_module,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"memory": {
|
||||
"memory_enabled": True,
|
||||
"user_profile_enabled": True,
|
||||
"memory_char_limit": 2200,
|
||||
"user_char_limit": 1375,
|
||||
}
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
memory_service_module,
|
||||
"session_search",
|
||||
lambda **kwargs: json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"query": kwargs["query"],
|
||||
"results": [{"session_id": "sess-real", "summary": "Found prior discussion."}],
|
||||
"count": 1,
|
||||
"sessions_searched": 1,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
service = MemoryService(store=FakeStore(), db=object())
|
||||
|
||||
initial = service.get_memory(target="memory")
|
||||
assert initial["entries"] == []
|
||||
assert initial["enabled"] is True
|
||||
assert initial["usage"]["char_limit"] == 2200
|
||||
|
||||
added = service.mutate_memory(action="add", target="memory", content="Remember the deploy flag.")
|
||||
assert added["success"] is True
|
||||
assert added["entries"] == ["Remember the deploy flag."]
|
||||
assert added["path"].endswith("MEMORY.md")
|
||||
|
||||
profile = service.mutate_memory(action="add", target="user", content="User prefers terse updates.")
|
||||
assert profile["success"] is True
|
||||
assert profile["entries"] == ["User prefers terse updates."]
|
||||
assert profile["usage"]["char_limit"] == 1375
|
||||
|
||||
replaced = service.mutate_memory(action="replace", target="memory", old_text="deploy", content="Remember the deploy flag loudly.")
|
||||
assert replaced["entries"] == ["Remember the deploy flag loudly."]
|
||||
|
||||
search_payload = service.search_sessions(query="deploy", limit=2)
|
||||
assert search_payload["success"] is True
|
||||
assert search_payload["results"][0]["session_id"] == "sess-real"
|
||||
Reference in New Issue
Block a user