From c8b7adf81d924bd1f16fcfb902ee05753fcaf185 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 22 May 2026 23:07:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(0131):=20data=5Ftable=5Fcpp=5Fviz=20migrat?= =?UTF-8?q?ion=20+=20botones=20acci=C3=B3n=20+=207=20nuevos=20tests=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CMakeLists.txt | 6 + app.md | 7 +- main.cpp | 291 ++++++++++++++++++++++++++++++-------- tests/test_connect_e2e.py | 193 +++++++++++++++++++++++++ 4 files changed, 433 insertions(+), 64 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 91fc6a3..80f6fe7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,12 @@ if(TARGET fn_table_viz) target_link_libraries(agents_dashboard PRIVATE fn_table_viz) endif() +# fn_module_data_table: render_grid_stage0 + ColumnSpec + TableEvent (issue 0131) +# Module lives in cpp/modules/data_table/. Optional: #if __has_include guard applies. +if(TARGET fn_module_data_table) + target_link_libraries(agents_dashboard PRIVATE fn_module_data_table) +endif() + if(WIN32) set_target_properties(agents_dashboard PROPERTIES WIN32_EXECUTABLE TRUE) # secret_store.cpp uses CryptProtectData / CryptUnprotectData (crypt32) diff --git a/app.md b/app.md index 0ce06e0..edba605 100644 --- a/app.md +++ b/app.md @@ -7,13 +7,14 @@ icon: phosphor: "robot" accent: "#8b5cf6" tags: [agents, dashboard, sse, http-client, imgui] -version: 0.1.0 +version: 0.2.0 uses_functions: - http_request_cpp_core - http_get_json_cpp_core - sse_client_cpp_core - secret_store_cpp_infra - logger_cpp_core + - data_table_cpp_viz uses_types: [] framework: "imgui" entry_point: "main.cpp" @@ -38,7 +39,7 @@ Frontend C++ ImGui para gestionar agentes Matrix de agents_and_robots via HTTPS ## Panels - **Connection** — base_url + apikey input (masked), Test button, LED status SSE. Save credentials cifradas en `local_files/agents_dashboard.db`. -- **Agents** — tabla con id, status (icon colored), uptime, msg_24h, botones Start/Stop/Restart/Logs por fila. +- **Agents** — tabla data_table_cpp_viz (render_grid_stage0) con 11 columnas: Status(Badge), ID, Name, Uptime, Msg/24h, Start/Stop/Restart/Clear Memory/Del Cache/Logs (Buttons). Modals de confirmacion para acciones destructivas. - **Logs** — selector agente + tail buffer SSE (5000 lineas), autoscroll, pause. - **Status Feed** — panel colapsable con eventos del `/sse/status` en tiempo real. @@ -73,7 +74,9 @@ cmake --build cpp/build/windows --target agents_dashboard -j - `sse_client_cpp_core` — /sse/agents/{id}/logs + /sse/status - `secret_store_cpp_infra` — DPAPI Windows / XOR Linux para apikey en SQLite - `logger_cpp_core` — logging en memoria + archivo +- `data_table_cpp_viz` — render_grid_stage0 para tabla Agents con Badge/Button renderers ## Capability growth log v0.1.0 (2026-05-22) — Paneles Connection + Agents + Logs + Status Feed. HTTPS+apikey, SSE reconnect, DPAPI credentials. +v0.2.0 (2026-05-22) — Migrado tabla Agents a data_table_cpp_viz (issue 0131). Botones Start/Stop/Restart/Clear Memory/Del Cache/Logs por fila. Endpoints clear_memory + delete_cache. Campos uptime_seconds + messages_24h reales. diff --git a/main.cpp b/main.cpp index a7caaca..72f1594 100644 --- a/main.cpp +++ b/main.cpp @@ -2,11 +2,11 @@ // // Panels: // Connection — base_url + apikey input, Test button, SSE status LED -// Agents — data table (id, status, uptime, msg_24h, actions) +// Agents — data table (id, status, uptime, msg_24h, actions) via data_table_cpp_viz // Logs — SSE tail buffer for /sse/agents/{id}/logs // Status Feed — SSE events from /sse/status (collapsible) // -// Issue 0129. Registry functions used: +// Issue 0129 + 0131. Registry functions used: // http_request_cpp_core, http_get_json_cpp_core, // sse_client_cpp_core, data_table_cpp_viz, logger_cpp_core, // secret_store_cpp_infra @@ -21,6 +21,12 @@ #include "infra/secret_store.h" #include "nlohmann/json.hpp" +// data_table_cpp_viz: render_grid_stage0 + declarative column specs (issue 0131) +#if __has_include("viz/data_table_grid.h") +# include "viz/data_table_grid.h" +# define HAS_DATA_TABLE 1 +#endif + // SQLite (vendored via fn_framework) #include @@ -115,6 +121,16 @@ struct AppState { std::mutex action_mu; std::string action_feedback; long long action_feedback_ts = 0; + + // Confirmation modals for destructive actions (clear_memory, delete_cache) + std::string confirm_agent_id; // agent being confirmed + std::string confirm_action; // "clear_memory" | "delete_cache" + bool confirm_open = false; + +#ifdef HAS_DATA_TABLE + // Per-panel data_table State for the agents grid + data_table::State agents_tbl_state; +#endif }; static AppState g_state; @@ -243,10 +259,9 @@ static std::string make_url(const AppState& s, const std::string& path) { } // Parse agents JSON array from /agents endpoint. -// Backend shape (agents_and_robots/internal/api/handlers.go): +// Backend shape (agents_and_robots/internal/api/handlers.go v0.2): // { id, name, version, desc, enabled: bool, running: bool, pid: int, -// instances: int, config_path } -// status is derived; uptime/msg_24h not provided yet (v0.2 backend work). +// instances: int, config_path, uptime_seconds: int64, messages_24h: int } static std::vector parse_agents(const std::string& body) { std::vector rows; auto j = json::parse(body, nullptr, false); @@ -260,8 +275,9 @@ static std::vector parse_agents(const std::string& body) { if (running) r.status = "running"; else if (!enabled) r.status = "disabled"; else r.status = "stopped"; - r.uptime_s = 0; - r.msg_24h = a.value("instances", 0); // hack: show instances until backend exposes msg_24h + // v0.2: real uptime_seconds and messages_24h from backend + r.uptime_s = a.value("uptime_seconds", (long long)0); + r.msg_24h = a.value("messages_24h", 0); rows.push_back(std::move(r)); } return rows; @@ -560,6 +576,15 @@ static const char* status_icon(const std::string& s) { return TI_CIRCLE_DOTTED; } +// --------------------------------------------------------------------------- +// Agents panel — rendered via data_table_cpp_viz (render_grid_stage0). +// Columns: Status | ID | Name | Uptime | Msg/24h | Start | Stop | Restart | +// ClearMem | DelCache | Logs +// Actions are Button renderer columns; events handled after render_grid_stage0. +// +// Fallback (compile without fn_module_data_table): plain text list. +// --------------------------------------------------------------------------- + static void draw_agents_panel(AppState& s) { if (!ImGui::Begin(TI_ROBOT " Agents", &g_show_agents)) { ImGui::End(); @@ -585,87 +610,229 @@ static void draw_agents_panel(AppState& s) { ImGui::Separator(); - // Table - ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | - ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY | - ImGuiTableFlags_SizingStretchProp; - float available_h = ImGui::GetContentRegionAvail().y; - if (ImGui::BeginTable("##agents_tbl", 6, flags, ImVec2(0, available_h))) { - ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 70); - ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); - ImGui::TableSetupColumn("Uptime", ImGuiTableColumnFlags_WidthFixed, 65); - ImGui::TableSetupColumn("Msg/24h",ImGuiTableColumnFlags_WidthFixed, 65); - ImGui::TableSetupColumn("Actions",ImGuiTableColumnFlags_WidthFixed, 120); - ImGui::TableHeadersRow(); + // Confirmation modal for destructive actions + if (s.confirm_open) { + ImGui::OpenPopup("##confirm_action"); + s.confirm_open = false; + } + if (ImGui::BeginPopupModal("##confirm_action", nullptr, + ImGuiWindowFlags_AlwaysAutoResize)) { + if (s.confirm_action == "clear_memory") { + ImGui::Text("Clear memory for agent '%s'?", s.confirm_agent_id.c_str()); + ImGui::TextDisabled("This will delete all messages and facts."); + } else { + ImGui::Text("Delete cache for agent '%s'?", s.confirm_agent_id.c_str()); + ImGui::TextDisabled("This will remove crypto/ and cache/ directories."); + } + ImGui::Separator(); + if (ImGui::Button("Confirm", ImVec2(120, 0))) { + agent_action(s, s.confirm_agent_id, s.confirm_action); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } +#ifdef HAS_DATA_TABLE + // ----- data_table_cpp_viz path (issue 0131 migration) ----- + // Column layout: + // 0=Status 1=ID 2=Name 3=Uptime 4=Msg24h + // 5=Start 6=Stop 7=Restart 8=ClearMem 9=DelCache 10=Logs + static constexpr int N_COLS = 11; + static const char* kHeaders[N_COLS] = { + "Status", "ID", "Name", "Uptime", "Msg/24h", + "Start", "Stop", "Restart", "Clear Memory", "Del Cache", "Logs" + }; + static const data_table::ColumnType kTypes[N_COLS] = { + data_table::ColumnType::String, data_table::ColumnType::String, + data_table::ColumnType::String, data_table::ColumnType::String, + data_table::ColumnType::Int, + data_table::ColumnType::String, data_table::ColumnType::String, + data_table::ColumnType::String, data_table::ColumnType::String, + data_table::ColumnType::String, data_table::ColumnType::String + }; + static const int kSrcForEff[N_COLS] = { 0,1,2,3,4, 5,6,7,8,9,10 }; + + // Build column specs (Badge for Status, Button for action columns) + static data_table::TableInput main_t; + if (main_t.column_specs.empty()) { + main_t.column_specs.resize(N_COLS); + // Status: Badge renderer + auto& cs_status = main_t.column_specs[0]; + cs_status.id = "status"; + cs_status.renderer = data_table::CellRenderer::Badge; + cs_status.badges = { + { "running", "#22c55e", "" }, + { "stopped", "#6b7280", "" }, + { "disabled", "#374151", "" }, + { "crashed", "#ef4444", "" }, + }; + // Text columns + for (int c : {1,2,3,4}) { + main_t.column_specs[c].id = kHeaders[c]; + main_t.column_specs[c].renderer = data_table::CellRenderer::Text; + } + // Button columns (indices 5..10) + const char* btn_actions[] = { "start","stop","restart","clear_memory","delete_cache","logs" }; + const char* btn_labels[] = { TI_PLAYER_PLAY " Start", + TI_PLAYER_STOP " Stop", + TI_REFRESH " Restart", + TI_TRASH " Clear Mem", + TI_FOLDER_MINUS " Del Cache", + TI_TERMINAL " Logs" }; + for (int i = 0; i < 6; ++i) { + int c = 5 + i; + main_t.column_specs[c].id = btn_actions[i]; + main_t.column_specs[c].renderer = data_table::CellRenderer::Button; + main_t.column_specs[c].button_action = btn_actions[i]; + main_t.column_specs[c].button_label = btn_labels[i]; + } + // Tooltip for destructive buttons + main_t.column_specs[8].tooltip = "Delete all messages and facts for this agent"; + main_t.column_specs[8].tooltip_on_hover = true; + main_t.column_specs[9].tooltip = "Remove crypto/ and cache/ directories"; + main_t.column_specs[9].tooltip_on_hover = true; + } + + // Snapshot agents under lock + std::vector snapshot; + std::string agents_err; + { std::lock_guard lk(s.agents_mu); std::string filter = s.filter_buf; for (auto& row : s.agents) { - // Filter if (!filter.empty() && - row.id.find(filter) == std::string::npos && + row.id.find(filter) == std::string::npos && row.display_name.find(filter) == std::string::npos) { continue; } + snapshot.push_back(row); + } + agents_err = s.agents_error; + } - ImGui::TableNextRow(); + // Build flat cell array (row-major, N_COLS per row) + int n_rows = (int)snapshot.size(); + std::vector cell_strs; + cell_strs.reserve((size_t)n_rows * N_COLS); + for (auto& row : snapshot) { + bool is_running = (row.status == "running"); + cell_strs.push_back(row.status); // 0 Status + cell_strs.push_back(row.id); // 1 ID + cell_strs.push_back(row.display_name); // 2 Name + cell_strs.push_back(format_uptime(row.uptime_s)); // 3 Uptime + cell_strs.push_back(std::to_string(row.msg_24h)); // 4 Msg/24h + // Button cells: show label only if action is applicable + cell_strs.push_back(!is_running ? "start" : ""); // 5 Start (disabled when running) + cell_strs.push_back(is_running ? "stop" : ""); // 6 Stop (disabled when stopped) + cell_strs.push_back("restart"); // 7 Restart always available + cell_strs.push_back("clear_memory"); // 8 ClearMem + cell_strs.push_back("delete_cache"); // 9 DelCache + cell_strs.push_back("logs"); // 10 Logs + } + // Build pointer array + std::vector cells_ptr; + cells_ptr.reserve(cell_strs.size()); + for (auto& s_str : cell_strs) cells_ptr.push_back(s_str.c_str()); - // Status column - ImGui::TableSetColumnIndex(0); - ImGui::PushStyleColor(ImGuiCol_Text, status_color(row.status)); - ImGui::TextUnformatted(status_icon(row.status)); - ImGui::SameLine(); - ImGui::TextUnformatted(row.status.c_str()); - ImGui::PopStyleColor(); + // All rows visible (filtering already applied above) + std::vector visible_rows(n_rows); + for (int i = 0; i < n_rows; ++i) visible_rows[i] = i; - // ID - ImGui::TableSetColumnIndex(1); - ImGui::TextUnformatted(row.id.c_str()); + // Collect events from render_grid_stage0 + std::vector events; + float available_h = ImGui::GetContentRegionAvail().y; + // Constrain height so the scroll region doesn't eat the whole panel + ImVec2 tbl_size(0, available_h); - // Name - ImGui::TableSetColumnIndex(2); - ImGui::TextUnformatted(row.display_name.c_str()); + // Pass height hint via State display — we force Table view mode + s.agents_tbl_state.display = data_table::ViewMode::Table; - // Uptime - ImGui::TableSetColumnIndex(3); - ImGui::TextUnformatted(format_uptime(row.uptime_s).c_str()); + // Need at least 1 row for the API to be happy + if (n_rows > 0) { + render_grid_stage0("##agents_tbl", + s.agents_tbl_state, + cells_ptr.empty() ? nullptr : cells_ptr.data(), + n_rows, N_COLS, N_COLS, + kHeaders, kTypes, kSrcForEff, + visible_rows, main_t, &events); + } else { + ImGui::TextDisabled("(no agents match filter)"); + } - // Msg 24h - ImGui::TableSetColumnIndex(4); - ImGui::Text("%d", row.msg_24h); + // Handle button events + for (auto& ev : events) { + if (ev.kind != data_table::TableEventKind::ButtonClick) continue; + if (ev.row < 0 || ev.row >= n_rows) continue; + const std::string& agent_id = snapshot[ev.row].id; + const std::string& act = ev.action_id; - // Actions - ImGui::TableSetColumnIndex(5); + if (act == "start") { + agent_action(s, agent_id, "start"); + } else if (act == "stop") { + agent_action(s, agent_id, "stop"); + } else if (act == "restart") { + agent_action(s, agent_id, "restart"); + } else if (act == "clear_memory") { + // Require confirmation + s.confirm_agent_id = agent_id; + s.confirm_action = "clear_memory"; + s.confirm_open = true; + } else if (act == "delete_cache") { + // Require confirmation + s.confirm_agent_id = agent_id; + s.confirm_action = "delete_cache"; + s.confirm_open = true; + } else if (act == "logs") { + snprintf(s.log_agent_id, sizeof(s.log_agent_id), "%s", agent_id.c_str()); + db_save_state(s, "log_agent_id", agent_id.c_str()); + start_log_sse(s, agent_id); + g_show_logs = true; + } + } + +#else + // ----- Fallback: plain text list (no fn_module_data_table) ----- + { + std::lock_guard lk(s.agents_mu); + std::string filter = s.filter_buf; + for (auto& row : s.agents) { + if (!filter.empty() && + row.id.find(filter) == std::string::npos && + row.display_name.find(filter) == std::string::npos) { + continue; + } ImGui::PushID(row.id.c_str()); - + ImGui::Text("[%s] %s uptime=%s msg24h=%d", + row.status.c_str(), row.id.c_str(), + format_uptime(row.uptime_s).c_str(), row.msg_24h); + ImGui::SameLine(); bool is_running = (row.status == "running"); - if (!is_running) { - if (ImGui::SmallButton(TI_PLAYER_PLAY " Start")) { - agent_action(s, row.id, "start"); - } - } else { - if (ImGui::SmallButton(TI_PLAYER_STOP " Stop")) { - agent_action(s, row.id, "stop"); - } - } + if (!is_running && ImGui::SmallButton("Start")) agent_action(s, row.id, "start"); + if (is_running && ImGui::SmallButton("Stop")) agent_action(s, row.id, "stop"); ImGui::SameLine(); - if (ImGui::SmallButton(TI_REFRESH " Restart")) { - agent_action(s, row.id, "restart"); - } + if (ImGui::SmallButton("Restart")) agent_action(s, row.id, "restart"); ImGui::SameLine(); - if (ImGui::SmallButton(TI_TERMINAL " Logs")) { + if (ImGui::SmallButton("Logs")) { snprintf(s.log_agent_id, sizeof(s.log_agent_id), "%s", row.id.c_str()); - db_save_state(s, "log_agent_id", row.id.c_str()); start_log_sse(s, row.id); g_show_logs = true; } - + ImGui::SameLine(); + if (ImGui::SmallButton("ClearMem")) { + s.confirm_agent_id = row.id; s.confirm_action = "clear_memory"; s.confirm_open = true; + } + ImGui::SameLine(); + if (ImGui::SmallButton("DelCache")) { + s.confirm_agent_id = row.id; s.confirm_action = "delete_cache"; s.confirm_open = true; + } ImGui::PopID(); } - ImGui::EndTable(); } +#endif // HAS_DATA_TABLE ImGui::End(); } diff --git a/tests/test_connect_e2e.py b/tests/test_connect_e2e.py index 08c2c13..9fd7f60 100644 --- a/tests/test_connect_e2e.py +++ b/tests/test_connect_e2e.py @@ -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}"