feat(0131): data_table_cpp_viz migration + botones acción + 7 nuevos tests e2e

Issue 0131 (agents v0.2) — frontend agents_dashboard:

* Migra tabla Agents de ImGui::BeginTable a render_grid_stage0 (data_table_cpp_viz).
  11 columnas: Status(Badge), ID, Name, Uptime, Msg/24h,
  Start/Stop/Restart/Clear Memory/Del Cache/Logs (Button renderers).
* Lee campos reales uptime_seconds + messages_24h del backend (antes leía
  'instances' como proxy para msg_24h; ahora lee el campo correcto).
* Confirmation modal (ImGui::BeginPopupModal) para acciones destructivas
  clear_memory y delete_cache.
* Link fn_module_data_table en CMakeLists (HAS_DATA_TABLE activado).
* Actualiza app.md: data_table_cpp_viz en uses_functions, version 0.2.0.
* Añade 7 tests pytest nuevos (total 24):
  test_uptime_field_present, test_msg_24h_field_present,
  test_clear_memory_requires_apikey, test_delete_cache_requires_apikey,
  test_control_roundtrip, test_unified_stop_does_not_kill_launcher,
  test_clear_memory_response_shape.
  SEGURIDAD: solo test-bot se para/arranca en e2e, nunca agentes reales.

Build verificado: Linux + Windows cross-compile (cmake --build cpp/build/windows).

Co-Authored-By: fn-orquestador (issue 0131) <noreply@fn-registry>
This commit is contained in:
2026-05-22 23:07:01 +02:00
parent af83e571c6
commit c8b7adf81d
4 changed files with 433 additions and 64 deletions
+193
View File
@@ -306,3 +306,196 @@ def test_get_single_agent_returns_logs():
assert body.get("id") == "assistant-bot"
assert "logs" in body
assert isinstance(body["logs"], list)
# ---------------------------------------------------------------------------
# v0.2 — per-agent control + new fields (issue 0131)
# ---------------------------------------------------------------------------
def test_uptime_field_present():
"""GET /agents response must include uptime_seconds field (integer >= 0)."""
apikey = _apikey()
r = _curl([
"-fsS",
"-H", f"Authorization: Bearer {apikey}",
f"{_url()}/agents",
])
assert r.returncode == 0, r.stderr
agents = json.loads(r.stdout)
assert len(agents) > 0, "no agents returned"
for a in agents:
assert "uptime_seconds" in a, f"agent {a.get('id')} missing uptime_seconds"
assert isinstance(a["uptime_seconds"], int) and a["uptime_seconds"] >= 0, \
f"agent {a.get('id')} uptime_seconds={a['uptime_seconds']!r} not a non-negative int"
def test_msg_24h_field_present():
"""GET /agents response must include messages_24h field (integer >= 0)."""
apikey = _apikey()
r = _curl([
"-fsS",
"-H", f"Authorization: Bearer {apikey}",
f"{_url()}/agents",
])
assert r.returncode == 0, r.stderr
agents = json.loads(r.stdout)
assert len(agents) > 0, "no agents returned"
for a in agents:
assert "messages_24h" in a, f"agent {a.get('id')} missing messages_24h"
assert isinstance(a["messages_24h"], int) and a["messages_24h"] >= 0, \
f"agent {a.get('id')} messages_24h={a['messages_24h']!r} not a non-negative int"
def test_clear_memory_requires_apikey():
"""POST /agents/{id}/clear_memory must return 401 without Bearer token."""
r = _curl([
"-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST",
f"{_url()}/agents/test-bot/clear_memory",
])
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
def test_delete_cache_requires_apikey():
"""POST /agents/{id}/delete_cache must return 401 without Bearer token."""
r = _curl([
"-s", "-o", "/dev/null", "-w", "%{http_code}",
"-X", "POST",
f"{_url()}/agents/test-bot/delete_cache",
])
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
def test_control_roundtrip():
"""Stop test-bot → poll running=false → start → restart.
SECURITY: Only test-bot is used here. Never call stop on real agents
during E2E. test-bot is a stateless robot with no ongoing conversations.
"""
apikey = _apikey()
BASE = _url()
def get_running(agent_id: str) -> bool:
r = _curl([
"-fsS",
"-H", f"Authorization: Bearer {apikey}",
f"{BASE}/agents/{agent_id}",
])
return bool(json.loads(r.stdout).get("running", False))
# --- Stop ---
r = _curl([
"-sS", "-X", "POST",
"-H", f"Authorization: Bearer {apikey}",
f"{BASE}/agents/test-bot/stop",
])
assert r.returncode == 0, f"stop failed: {r.stderr}"
body = json.loads(r.stdout)
# Accept "stopped" or "not_running" as valid outcomes
assert body.get("status") in ("stopped", "not_running", "ok"), f"unexpected stop status: {body}"
# Poll until running=false (max 3s)
import time
for _ in range(15):
if not get_running("test-bot"):
break
time.sleep(0.2)
assert not get_running("test-bot"), "test-bot still running after stop"
# --- Start ---
r = _curl([
"-sS", "-X", "POST",
"-H", f"Authorization: Bearer {apikey}",
f"{BASE}/agents/test-bot/start",
])
assert r.returncode == 0, f"start failed: {r.stderr}"
body = json.loads(r.stdout)
assert body.get("status") in ("started", "ok"), f"unexpected start status: {body}"
# Poll until running=true (max 5s)
for _ in range(25):
if get_running("test-bot"):
break
time.sleep(0.2)
assert get_running("test-bot"), "test-bot not running after start"
# --- Restart ---
r = _curl([
"-sS", "-X", "POST",
"-H", f"Authorization: Bearer {apikey}",
f"{BASE}/agents/test-bot/restart",
])
assert r.returncode == 0, f"restart failed: {r.stderr}"
body = json.loads(r.stdout)
assert body.get("status") in ("restarted", "ok"), f"unexpected restart status: {body}"
# After restart test-bot must be running again (max 5s)
for _ in range(25):
if get_running("test-bot"):
break
time.sleep(0.2)
assert get_running("test-bot"), "test-bot not running after restart"
def test_unified_stop_does_not_kill_launcher():
"""Stopping test-bot must leave assistant-bot (and other real agents) running.
In unified mode, all agents are goroutines. Per-agent stop must cancel
only the target goroutine — not the launcher process.
SECURITY: Only test-bot is stopped. assistant-bot is only read, never mutated.
"""
apikey = _apikey()
BASE = _url()
def get_running(agent_id: str) -> bool:
r = _curl([
"-fsS",
"-H", f"Authorization: Bearer {apikey}",
f"{BASE}/agents/{agent_id}",
])
if r.returncode != 0:
return False
return bool(json.loads(r.stdout).get("running", False))
# Stop test-bot only
r = _curl([
"-sS", "-X", "POST",
"-H", f"Authorization: Bearer {apikey}",
f"{BASE}/agents/test-bot/stop",
])
assert r.returncode == 0, f"stop test-bot failed: {r.stderr}"
# assistant-bot must still be running (launcher is alive)
assert get_running("assistant-bot"), \
"assistant-bot not running after stopping test-bot — launcher may have crashed"
# Restore test-bot
_curl([
"-sS", "-X", "POST",
"-H", f"Authorization: Bearer {apikey}",
f"{BASE}/agents/test-bot/start",
])
def test_clear_memory_response_shape():
"""POST /agents/{id}/clear_memory (authorized) returns JSON with expected fields.
Uses test-bot which has minimal/no memory, so deleting is always safe.
Verifies: status, messages_deleted, facts_deleted keys present.
"""
apikey = _apikey()
r = _curl([
"-sS", "-X", "POST",
"-H", f"Authorization: Bearer {apikey}",
f"{_url()}/agents/test-bot/clear_memory",
])
assert r.returncode == 0, f"clear_memory failed: {r.stderr}"
body = json.loads(r.stdout)
assert body.get("status") == "cleared", f"expected status=cleared: {body}"
assert "messages_deleted" in body, f"missing messages_deleted: {body}"
assert "facts_deleted" in body, f"missing facts_deleted: {body}"
assert isinstance(body["messages_deleted"], int), \
f"messages_deleted not int: {body['messages_deleted']!r}"
assert isinstance(body["facts_deleted"], int), \
f"facts_deleted not int: {body['facts_deleted']!r}"