5 Commits

Author SHA1 Message Date
egutierrez 73f80d9c6c chore: auto-commit (2 archivos)
- tests/__pycache__/test_connect_e2e.cpython-312-pytest-9.0.2.pyc
- tests/__pycache__/test_connect_e2e.cpython-310.pyc

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:38:16 +02:00
egutierrez e4a1c20fc2 feat(history): seed Logs + Status Feed with history on connect
Both panels were empty on Connect / agent select — only new events since
subscribe appeared. Backend already persists per-agent logs to
logs/<id>/YYYY-MM-DD.jsonl AND now keeps the last 100 status diffs in
a ring buffer (agents_and_robots 71b3b2b).

Frontend changes:
- fetch_log_history(s, agent_id, n) → GET /agents/{id}/logs?n=N, fills
  s.log_lines BEFORE SSE subscribe so context appears instantly.
  Handles the {count,id,lines:[...]} response shape from the backend.
- start_log_sse now spawns this fetch on entry; SSE adds new lines on top.
- fetch_status_history(s, n) → GET /status/recent?n=N, fills
  s.status_events with [hist]-tagged entries before the live SSE attaches.
- Connect handler dispatches fetch_status_history() in a worker thread
  alongside the existing start_status_sse + fetch_agents_async.

E2E (4 new, 29 total):
- test_status_recent_history_endpoint   — shape contract
- test_status_recent_captures_stop_start_events — drives stop/start on
  test-bot, asserts events appear in /status/recent within 5s. This is
  the "send actions and observe feed" loop the user requested.
- test_agent_logs_history_endpoint      — {count,id,lines} contract +
  lines>0 for long-running assistant-bot
- test_status_recent_unauthorized_without_bearer — auth boundary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:43:16 +02:00
egutierrez 9cade2f2f8 fix(crash): init data_table State.col_visible + col_order before render
Pressing Refresh Agents (or Test Connection — both trigger fetch + table
re-render) crashed the app with Windows exit code 5 (access violation).

Root cause: agents_tbl_state was default-constructed, so
State.col_visible (std::vector<bool>) and State.col_order
(std::vector<int>) were empty. render_grid_stage0 indexes them by column
index up to N_COLS=11 without bounds checking → undefined behaviour →
segfault on the first render after agents data populated.

Fix: at first render of the agents panel, assign col_visible=true * N_COLS,
fill col_order with [0..N_COLS), and ensure stages.size() >= 1. Same
pattern tql_apply.cpp uses (col_visible.assign(eff_cols, true)).

Diagnostic infra added (kept in place — minimal overhead):
- FN_DBG macro: fprintf(stderr, ...) + fflush. Survives crashes that
  fn_log's buffered file output doesn't.
- --auto-refresh CLI flag: triggers fetch_agents_async at frame 30,
  auto-exits at frame 180 (~3s @ 60Hz). Headless smoke for CI.
- DBG breadcrumbs through main → load_apikey → fn::run_app → render →
  fetch_agents_async (thread enter/request/response/parse/exit) → render
  table (pre/post). Each step flushes stderr immediately.

E2E regression guard: test_app_survives_auto_refresh_cycle. Runs the .exe
with --auto-refresh, asserts exit 0, asserts the breadcrumb chain reaches
both "fetch thread parsed" and "agents_panel POST-render" in stderr. 25
tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:31:25 +02:00
egutierrez 18b5ffdfd9 feat(apikey): auto-fetch from pass agentes/api-key when env empty
Launching from the App Hub (or any double-click) no longer needs
AGENTS_API_KEY manually injected. Order of resolution:

  1. AGENTS_API_KEY env var          → apikey_source = "env"
  2. `pass agentes/api-key` shell    → apikey_source = "pass"
  3. neither                          → apikey_source = "missing"

On Windows the fallback shells via `wsl.exe -e sh -c "pass ... | head -n1"`
so the secret stays in the WSL user's GnuPG keychain (never copied to a
Windows file). On Linux it's a direct popen of `pass ...`.

Failure mode: GPG agent locked → empty output → "missing" state in UI
with a "Retry pass" button (user runs `pass agentes/api-key` once to
unlock the agent, clicks Retry, app refetches without restart).

Connection panel shows the active source:
  ✓ loaded from AGENTS_API_KEY env var
  ✓ loaded via `pass agentes/api-key`
  ⚠ apikey not found (env empty + pass failed)

--connect-test uses the same two-tier resolution so e2e exercises the
production code path.

E2E: renamed test_connect_fails_without_apikey →
test_connect_falls_back_to_pass_when_env_empty. Verifies that with
empty env, the .exe still returns OK N. Skips if `pass` is locked.

All 24 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:16:22 +02:00
egutierrez aa88a1cb4a merge: 0131 v0.2 data_table_cpp_viz + action buttons + 7 e2e tests 2026-05-22 23:09:07 +02:00
4 changed files with 396 additions and 42 deletions
+276 -36
View File
@@ -40,6 +40,16 @@
#include <sstream>
#include <string>
#include <thread>
#ifdef _WIN32
# ifndef NOMINMAX
# define NOMINMAX
# endif
# ifndef WIN32_LEAN_AND_MEAN
# define WIN32_LEAN_AND_MEAN
# endif
# include <windows.h>
#endif
#include <vector>
using json = nlohmann::json;
@@ -83,8 +93,8 @@ struct AgentRow {
struct AppState {
// Connection
char base_url[512] = "https://agents.organic-machine.com";
char apikey_buf[256] = ""; // populated from AGENTS_API_KEY env at startup, never via UI
bool apikey_from_env = false;
char apikey_buf[256] = ""; // populated at startup from env OR `pass agentes/api-key`
std::string apikey_source; // "env" | "pass" | "missing"
bool connected = false;
std::string connect_error;
long long last_fetch_ms = 0;
@@ -207,21 +217,101 @@ static void db_load_connection(AppState& s) {
sqlite3_finalize(stmt);
}
// load_apikey_from_env reads AGENTS_API_KEY into s.apikey_buf. Trims trailing
// whitespace (env vars can carry \r on Windows when sourced from .bat).
static void load_apikey_from_env(AppState& s) {
// Helper: rstrip whitespace + control chars.
static void rstrip_ctrl(std::string& s) {
while (!s.empty() && (unsigned char)s.back() <= 0x20) s.pop_back();
}
// fetch_apikey_via_pass runs `pass agentes/api-key | head -n1` and returns the
// secret on stdout. On Windows the command runs INSIDE WSL via wsl.exe (pass
// lives in the WSL user's GnuPG keychain). Returns empty string on failure
// (pass not installed, GPG locked, entry missing).
static std::string fetch_apikey_via_pass() {
std::string out;
#ifdef _WIN32
// Spawn: wsl.exe -e sh -c "pass agentes/api-key 2>/dev/null | head -n1"
std::wstring cmdline =
L"wsl.exe -e sh -c \"pass agentes/api-key 2>/dev/null | head -n1\"";
SECURITY_ATTRIBUTES sa{};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE rd = nullptr, wr = nullptr;
if (!CreatePipe(&rd, &wr, &sa, 0)) return out;
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = wr;
si.hStdError = wr;
si.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION pi{};
std::wstring mutable_cmd = cmdline; // CreateProcessW needs writable buffer
BOOL ok = CreateProcessW(nullptr, mutable_cmd.data(), nullptr, nullptr,
TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi);
CloseHandle(wr);
if (!ok) {
CloseHandle(rd);
return out;
}
char buf[1024];
DWORD got = 0;
while (ReadFile(rd, buf, sizeof(buf), &got, nullptr) && got > 0)
out.append(buf, buf + got);
CloseHandle(rd);
WaitForSingleObject(pi.hProcess, 5000); // 5s max
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
#else
FILE* p = popen("pass agentes/api-key 2>/dev/null | head -n1", "r");
if (p) {
char buf[1024];
size_t n;
while ((n = std::fread(buf, 1, sizeof(buf), p)) > 0) out.append(buf, n);
pclose(p);
}
#endif
rstrip_ctrl(out);
// Sanity check: a 32-byte hex apikey is 64 chars. Reject anything shorter
// than 16 (would catch error messages like "Error: ...").
if (out.size() < 16) out.clear();
return out;
}
// load_apikey loads the apikey into s.apikey_buf with two-tier fallback:
// 1) AGENTS_API_KEY env var (apikey_source = "env")
// 2) `pass agentes/api-key` (apikey_source = "pass")
// 3) empty (apikey_source = "missing")
//
// This lets the app launch from the App Hub (or any double-click) without
// the user having to inject the env var manually — the apikey is fetched
// from the user's pass store on demand (GPG agent must be unlocked).
static void load_apikey(AppState& s) {
s.apikey_buf[0] = '\0';
s.apikey_source = "missing";
const char* k = std::getenv("AGENTS_API_KEY");
if (!k || !*k) {
s.apikey_from_env = false;
s.apikey_buf[0] = '\0';
return;
if (k && *k) {
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k);
size_t n = strlen(s.apikey_buf);
while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20)
s.apikey_buf[--n] = '\0';
if (n > 0) {
s.apikey_source = "env";
return;
}
}
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k);
size_t n = strlen(s.apikey_buf);
while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20) {
s.apikey_buf[--n] = '\0';
std::string from_pass = fetch_apikey_via_pass();
if (!from_pass.empty()) {
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", from_pass.c_str());
s.apikey_source = "pass";
}
s.apikey_from_env = (n > 0);
}
static void db_save_state(AppState& s, const char* key, const char* value) {
@@ -283,29 +373,47 @@ static std::vector<AgentRow> parse_agents(const std::string& body) {
return rows;
}
// FN_DBG: stderr + flush. Survives crashes (fn_log buffers).
#define FN_DBG(...) do { fprintf(stderr, "[DBG] " __VA_ARGS__); fputc('\n', stderr); fflush(stderr); } while(0)
// Fetch agents in background thread
static void fetch_agents_async(AppState& s) {
if (s.fetching) return;
FN_DBG("fetch_agents_async ENTER s.fetching=%d apikey_len=%zu",
(int)s.fetching, strlen(s.apikey_buf));
if (s.fetching) {
FN_DBG("fetch_agents_async SKIP already fetching");
return;
}
s.fetching = true;
std::thread([&s]() {
FN_DBG("fetch thread STARTED");
fn_http::Request req;
req.method = "GET";
req.url = make_url(s, "/agents");
req.bearer_token = s.apikey_buf;
req.timeout_ms = 8000;
FN_DBG("fetch thread requesting url=%s bearer_len=%zu", req.url.c_str(), req.bearer_token.size());
auto res = fn_http::request(req);
std::lock_guard<std::mutex> lk(s.agents_mu);
if (!res.error.empty()) {
s.agents_error = "Transport error: " + res.error;
} else if (res.status != 200) {
s.agents_error = "HTTP " + std::to_string(res.status);
} else {
s.agents = parse_agents(res.body);
s.agents_error.clear();
s.agents_fetched_ms = now_ms();
FN_DBG("fetch thread response status=%d err=[%s] body_len=%zu",
res.status, res.error.c_str(), res.body.size());
{
std::lock_guard<std::mutex> lk(s.agents_mu);
if (!res.error.empty()) {
s.agents_error = "Transport error: " + res.error;
} else if (res.status != 200) {
s.agents_error = "HTTP " + std::to_string(res.status);
} else {
FN_DBG("fetch thread parsing body...");
s.agents = parse_agents(res.body);
s.agents_error.clear();
s.agents_fetched_ms = now_ms();
FN_DBG("fetch thread parsed %zu rows", s.agents.size());
}
}
s.fetching = false;
FN_DBG("fetch thread DONE");
}).detach();
FN_DBG("fetch_agents_async EXIT (thread detached)");
}
// POST action to /agents/{id}/{action}
@@ -342,6 +450,35 @@ static void agent_action(AppState& s, const std::string& agent_id,
// SSE connections
// ---------------------------------------------------------------------------
// Fetch historical log tail via REST before subscribing to SSE.
// Returns synchronously; caller usually spawns it in a thread + starts SSE next.
static void fetch_log_history(AppState& s, const std::string& agent_id, int n) {
fn_http::Request req;
req.method = "GET";
req.url = make_url(s, "/agents/" + agent_id + "/logs?n=" + std::to_string(n));
req.bearer_token = s.apikey_buf;
req.timeout_ms = 5000;
auto res = fn_http::request(req);
if (!res.error.empty() || res.status != 200) {
FN_DBG("fetch_log_history %s failed: status=%d err=%s",
agent_id.c_str(), res.status, res.error.c_str());
return;
}
auto j = json::parse(res.body, nullptr, false);
if (j.is_discarded()) return;
// Endpoint returns {count, id, lines:[...]} (object) — not a raw array.
const auto* arr = j.is_array() ? &j : j.contains("lines") ? &j["lines"] : nullptr;
if (!arr || !arr->is_array()) return;
std::lock_guard<std::mutex> lk(s.log_mu);
for (auto& line : *arr) {
if (!line.is_string()) continue;
s.log_lines.push_back(line.get<std::string>());
while (s.log_lines.size() > 5000) s.log_lines.pop_front();
}
FN_DBG("fetch_log_history %s loaded %zu lines",
agent_id.c_str(), s.log_lines.size());
}
static void start_log_sse(AppState& s, const std::string& agent_id) {
s.log_sse.stop();
{
@@ -349,6 +486,11 @@ static void start_log_sse(AppState& s, const std::string& agent_id) {
s.log_lines.clear();
s.log_sse_agent_connected = agent_id;
}
// Populate historical tail BEFORE subscribing so the user sees context
// immediately instead of an empty panel until new lines appear.
if (!agent_id.empty()) {
std::thread([&s, agent_id]() { fetch_log_history(s, agent_id, 200); }).detach();
}
fn_sse::Config cfg;
cfg.url = make_url(s, "/sse/agents/" + agent_id + "/logs");
cfg.bearer_token = s.apikey_buf;
@@ -366,6 +508,30 @@ static void start_log_sse(AppState& s, const std::string& agent_id) {
});
}
// Fetch recent status events to seed the Status Feed panel on connect.
static void fetch_status_history(AppState& s, int n) {
fn_http::Request req;
req.method = "GET";
req.url = make_url(s, "/status/recent?n=" + std::to_string(n));
req.bearer_token = s.apikey_buf;
req.timeout_ms = 5000;
auto res = fn_http::request(req);
if (!res.error.empty() || res.status != 200) {
FN_DBG("fetch_status_history failed: status=%d err=%s",
res.status, res.error.c_str());
return;
}
auto j = json::parse(res.body, nullptr, false);
if (j.is_discarded() || !j.is_array()) return;
std::lock_guard<std::mutex> lk(s.status_mu);
for (auto& ev : j) {
std::string entry = "[hist] " + ev.dump();
s.status_events.push_front(entry);
while (s.status_events.size() > 200) s.status_events.pop_back();
}
FN_DBG("fetch_status_history loaded %zu events into feed", s.status_events.size());
}
static void start_status_sse(AppState& s) {
s.status_sse.stop();
fn_sse::Config cfg;
@@ -394,6 +560,9 @@ static void start_status_sse(AppState& s) {
// ---------------------------------------------------------------------------
static bool g_self_test = false;
static int g_auto_refresh_after_frames = 0; // >0: trigger fetch_agents_async after N frames
static int g_auto_exit_after_frames = 0; // >0: exit after N frames (for headless test)
static int g_frame_count = 0;
static bool run_self_test() {
fn_log::log_info("[self-test] checking subsystems...");
@@ -477,13 +646,21 @@ static void draw_connection_panel(AppState& s) {
ImGui::Text("API Key:");
ImGui::SameLine();
if (s.apikey_from_env) {
if (s.apikey_source == "env") {
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
TI_CHECK " loaded from AGENTS_API_KEY env (pass agentes/api-key)");
TI_CHECK " loaded from AGENTS_API_KEY env var");
} else if (s.apikey_source == "pass") {
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
TI_CHECK " loaded via `pass agentes/api-key`");
} else {
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
TI_ALERT_TRIANGLE " AGENTS_API_KEY env var missing");
ImGui::TextDisabled(" Launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>");
TI_ALERT_TRIANGLE " apikey not found (env empty + pass failed)");
ImGui::TextDisabled(" Make sure GPG agent is unlocked: `pass agentes/api-key`");
ImGui::TextDisabled(" Or launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>");
ImGui::SameLine();
if (ImGui::Button(TI_REFRESH " Retry pass")) {
load_apikey(s);
}
}
ImGui::Separator();
@@ -510,7 +687,10 @@ static void draw_connection_panel(AppState& s) {
s.connected = true;
fn_log::log_info("[connect] OK");
db_save_connection(s);
// Start SSEs
// Seed Status Feed with history BEFORE subscribing live, so the
// user sees recent activity from the moment Connect succeeds.
std::thread([&s]() { fetch_status_history(s, 100); }).detach();
// Start SSEs (Status live)
start_status_sse(s);
// Initial agents fetch
fetch_agents_async(s);
@@ -656,6 +836,20 @@ static void draw_agents_panel(AppState& s) {
};
static const int kSrcForEff[N_COLS] = { 0,1,2,3,4, 5,6,7,8,9,10 };
// First-render init for State: col_visible + col_order sized to N_COLS.
// Without this, render_grid_stage0 indexes into empty std::vector<bool>
// -> undefined behaviour -> Windows access-violation (exit 5).
if ((int)s.agents_tbl_state.col_visible.size() != N_COLS) {
s.agents_tbl_state.col_visible.assign(N_COLS, true);
}
if ((int)s.agents_tbl_state.col_order.size() != N_COLS) {
s.agents_tbl_state.col_order.resize(N_COLS);
for (int c = 0; c < N_COLS; ++c) s.agents_tbl_state.col_order[c] = c;
}
if ((int)s.agents_tbl_state.stages.size() < 1) {
s.agents_tbl_state.stages.resize(1);
}
// Build column specs (Badge for Status, Button for action columns)
static data_table::TableInput main_t;
if (main_t.column_specs.empty()) {
@@ -753,12 +947,27 @@ static void draw_agents_panel(AppState& s) {
// Need at least 1 row for the API to be happy
if (n_rows > 0) {
static int dbg_first = 1;
if (dbg_first) {
FN_DBG("agents_panel PRE-render n_rows=%d cells=%zu specs=%zu eff_h=%p eff_t=%p src=%p vis_sz=%zu",
n_rows, cells_ptr.size(), main_t.column_specs.size(),
(void*)kHeaders, (void*)kTypes, (void*)kSrcForEff, visible_rows.size());
for (int r = 0; r < n_rows && r < 2; ++r) {
for (int c = 0; c < N_COLS; ++c) {
const char* p = cells_ptr[r * N_COLS + c];
FN_DBG(" cell[%d][%d]=%s", r, c, p ? p : "(null)");
}
}
dbg_first = 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);
static int dbg_post = 1;
if (dbg_post) { FN_DBG("agents_panel POST-render events=%zu", events.size()); dbg_post = 0; }
} else {
ImGui::TextDisabled("(no agents match filter)");
}
@@ -942,6 +1151,20 @@ static void draw_status_feed_panel(AppState& s) {
// ---------------------------------------------------------------------------
static void render() {
g_frame_count++;
if (g_frame_count <= 3) {
FN_DBG("render frame=%d", g_frame_count);
}
// Headless test: simulate Refresh button click after N frames
if (g_auto_refresh_after_frames > 0 && g_frame_count == g_auto_refresh_after_frames) {
FN_DBG("AUTO-REFRESH triggered at frame %d", g_frame_count);
// Mark connected so the agents panel renders
g_state.connected = true;
// Simulate Connect-then-Refresh: populate base_url default, kick fetch.
fetch_agents_async(g_state);
}
draw_connection_panel(g_state);
if (g_show_agents) draw_agents_panel(g_state);
if (g_show_logs) draw_logs_panel(g_state);
@@ -954,6 +1177,12 @@ static void render() {
fetch_agents_async(g_state);
}
}
// Headless test: exit after N frames
if (g_auto_exit_after_frames > 0 && g_frame_count >= g_auto_exit_after_frames) {
FN_DBG("AUTO-EXIT at frame %d", g_frame_count);
std::exit(0);
}
}
// ---------------------------------------------------------------------------
@@ -979,8 +1208,12 @@ static int run_connect_test(const std::string& base_url) {
while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n;
apikey.resize(n);
}
// Fallback: try `pass agentes/api-key` if env is empty (same flow as UI).
if (apikey.empty()) {
fprintf(stderr, "FAIL AGENTS_API_KEY env var empty/missing\n");
apikey = fetch_apikey_via_pass();
}
if (apikey.empty()) {
fprintf(stderr, "FAIL apikey not found (env empty + pass failed)\n");
return 1;
}
@@ -1047,6 +1280,7 @@ static int run_connect_test(const std::string& base_url) {
}
int main(int argc, char** argv) {
FN_DBG("main ENTER argc=%d", argc);
// Self-test mode
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--self-test") == 0) {
@@ -1056,6 +1290,11 @@ int main(int argc, char** argv) {
if (strcmp(argv[i], "--connect-test") == 0 && i + 1 < argc) {
return run_connect_test(argv[i + 1]);
}
if (strcmp(argv[i], "--auto-refresh") == 0) {
g_auto_refresh_after_frames = 30; // ~0.5s @ 60Hz
g_auto_exit_after_frames = 180; // ~3s
FN_DBG("auto-refresh mode enabled");
}
}
if (g_self_test) {
@@ -1081,17 +1320,18 @@ int main(int argc, char** argv) {
cfg.panels = panels;
cfg.panel_count = sizeof(panels) / sizeof(panels[0]);
// Init DB and load saved base_url + read apikey from env (sourced from `pass agentes/api-key`).
// Init DB and load saved base_url + apikey (env first, fallback to `pass agentes/api-key`).
db_open(g_state);
db_load_connection(g_state);
load_apikey_from_env(g_state);
if (!g_state.apikey_from_env) {
fn_log::log_warn("[startup] AGENTS_API_KEY env var missing — backend calls will fail. "
"Launch with: AGENTS_API_KEY=$(pass agentes/api-key) ...");
}
FN_DBG("startup: db loaded base_url=%s", g_state.base_url);
load_apikey(g_state);
FN_DBG("startup: apikey_source=%s apikey_len=%zu",
g_state.apikey_source.c_str(), strlen(g_state.apikey_buf));
// Cleanup on exit
FN_DBG("startup: calling fn::run_app");
int ret = fn::run_app(cfg, render);
FN_DBG("fn::run_app returned %d", ret);
// Persist state
db_save_state(g_state, "log_autoscroll", g_state.log_autoscroll ? "1" : "0");
Binary file not shown.
+120 -6
View File
@@ -82,18 +82,132 @@ def test_connect_succeeds_with_valid_apikey():
assert n > 0, f"expected at least 1 agent, got {n}"
def test_connect_fails_without_apikey():
"""FAIL on stderr, exit 1, when AGENTS_API_KEY is empty."""
# Force-empty AGENTS_API_KEY; bypass WSLENV by clearing it too.
def test_status_recent_history_endpoint():
"""GET /status/recent returns a JSON array (may be empty after restart)."""
apikey = _apikey()
r = _curl([
"-fsS",
"-H", f"Authorization: Bearer {apikey}",
f"{_url()}/status/recent?n=10",
])
assert r.returncode == 0, r.stderr
body = json.loads(r.stdout)
assert isinstance(body, list)
def test_status_recent_captures_stop_start_events():
"""Triggering stop/start must produce 2 entries in /status/recent.
Drives the end-to-end "send actions and observe feed" flow the user
asked for: this is the same data path the C++ frontend uses to seed
its Status Feed panel on Connect.
"""
apikey = _apikey()
hdr = ["-H", f"Authorization: Bearer {apikey}"]
# Snapshot history length before
r0 = _curl(["-fsS", *hdr, f"{_url()}/status/recent?n=100"])
before = json.loads(r0.stdout)
# Drive events: stop then start test-bot.
_curl(["-sS", "-X", "POST", *hdr, f"{_url()}/agents/test-bot/stop"])
import time
time.sleep(2.5) # poller emits diff at most every 2s
_curl(["-sS", "-X", "POST", *hdr, f"{_url()}/agents/test-bot/start"])
time.sleep(2.5)
r1 = _curl(["-fsS", *hdr, f"{_url()}/status/recent?n=100"])
after = json.loads(r1.stdout)
# At least one new event captured (poller may coalesce or already had recents)
assert len(after) > len(before) or any(
e.get("agent_id") == "test-bot" for e in after[-5:]
), f"no test-bot event in feed; before={len(before)} after={len(after)}"
def test_agent_logs_history_endpoint():
"""GET /agents/{id}/logs?n=N returns {count, id, lines:[...]} — historical tail."""
apikey = _apikey()
r = _curl([
"-fsS",
"-H", f"Authorization: Bearer {apikey}",
f"{_url()}/agents/assistant-bot/logs?n=50",
])
assert r.returncode == 0, r.stderr
body = json.loads(r.stdout)
assert isinstance(body, dict)
assert body.get("id") == "assistant-bot"
lines = body.get("lines")
assert isinstance(lines, list)
# assistant-bot is long-running with persistent log file (logs/<id>/YYYY-MM-DD.jsonl)
assert len(lines) > 0, "expected at least 1 historical log line"
assert isinstance(lines[0], str)
def test_status_recent_unauthorized_without_bearer():
r = _curl(["-s", "-o", "/dev/null", "-w", "%{http_code}",
f"{_url()}/status/recent"])
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
def test_app_survives_auto_refresh_cycle():
"""Regression: app must NOT crash on Refresh Agents button click.
Bug history: v0.2 migration to data_table_cpp_viz left State.col_visible
and State.col_order uninitialized — render_grid_stage0 indexed into empty
std::vector<bool>, causing an access violation (Windows exit code 5).
The --auto-refresh CLI flag triggers fetch_agents_async + a full render
cycle from a headless GLFW window, then exits at frame 180 (~3s @ 60Hz).
Exit 0 means the agents panel rendered the live data without crashing.
"""
pass_check = subprocess.run(["pass", "agentes/api-key"],
capture_output=True, text=True, timeout=5)
if pass_check.returncode != 0 or not pass_check.stdout.strip():
pytest.skip("pass agentes/api-key not readable (GPG locked?)")
# WSL → Windows: launch the .exe and let it self-exit after 180 frames.
r = subprocess.run(
[str(_exe()), "--auto-refresh"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, (
f"app crashed (exit={r.returncode}); last stderr:\n"
+ "\n".join(r.stderr.splitlines()[-20:])
)
# Sanity: stderr must show that fetch_agents reached the parse step.
assert "fetch thread parsed" in r.stderr, (
f"fetch never reached parse; stderr:\n{r.stderr[-1000:]}"
)
# Sanity: render must have completed at least once (POST-render logged).
assert "agents_panel POST-render" in r.stderr, (
f"render_grid_stage0 crashed before completing; stderr:\n{r.stderr[-1000:]}"
)
def test_connect_falls_back_to_pass_when_env_empty():
"""When AGENTS_API_KEY env is empty, the .exe must fetch apikey via
`wsl.exe pass agentes/api-key` (or `pass` on Linux). This is what makes
launching from the App Hub work without manual env injection.
Skipped if `pass agentes/api-key` itself can't be read (GPG locked).
"""
# Verify pass is unlocked before testing the fallback
pass_check = subprocess.run(
["pass", "agentes/api-key"],
capture_output=True, text=True, timeout=5,
)
if pass_check.returncode != 0 or not pass_check.stdout.strip():
pytest.skip("pass agentes/api-key not readable (GPG locked?)")
# Force-empty AGENTS_API_KEY + bypass WSLENV propagation
r = subprocess.run(
[str(_exe()), "--connect-test", _url()],
capture_output=True,
text=True,
timeout=30,
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": "AGENTS_API_KEY"},
env={**os.environ, "AGENTS_API_KEY": "", "WSLENV": ""},
)
assert r.returncode != 0
assert "AGENTS_API_KEY" in r.stderr, f"stderr=[{r.stderr!r}]"
assert r.returncode == 0, f"pass fallback failed: stdout={r.stdout!r} stderr={r.stderr!r}"
assert r.stdout.startswith("OK "), f"stdout=[{r.stdout!r}]"
def test_connect_fails_on_bad_host():