diff --git a/api/routes.py b/api/routes.py
index 1477e5b0..9016636f 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -3291,7 +3291,34 @@ def handle_get(handler, parsed) -> bool:
import datetime
identity_map = _load_gateway_session_identity_map()
sessions_path = _gateway_session_metadata_path()
- running = bool(identity_map)
+
+ # Detect whether the gateway process is alive, independent of
+ # connected messaging platforms. An empty identity_map just
+ # means zero platforms connected, not that the gateway is down.
+ #
+ # agent_health.build_agent_health_payload() is the authoritative
+ # signal: it reads gateway.status runtime metadata and returns a
+ # tri-state `alive` field (True/False/None). This avoids the
+ # false-negative where the gateway is running but has zero active
+ # messaging sessions (empty identity_map).
+ #
+ # `alive` tri-state semantics:
+ # True → gateway process is alive
+ # False → gateway metadata exists but process is down
+ # None → no gateway metadata/status available; this WebUI
+ # setup is probably not configured with a gateway
+ health = build_agent_health_payload()
+ alive = health.get("alive")
+ if alive is True:
+ running = True
+ configured = True
+ elif alive is False:
+ running = False
+ configured = True
+ else: # alive is None → gateway not configured / unavailable
+ running = bool(identity_map)
+ configured = False
+
platforms_set: set[str] = set()
for meta in identity_map.values():
raw = meta.get("raw_source") or meta.get("platform") or ""
@@ -3318,6 +3345,7 @@ def handle_get(handler, parsed) -> bool:
pass
return j(handler, {
"running": running,
+ "configured": configured,
"platforms": platforms,
"last_active": last_active,
"session_count": len(identity_map),
diff --git a/static/panels.js b/static/panels.js
index 12531130..be20e724 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -5413,6 +5413,10 @@ function loadGatewayStatus(){
if(!card) return;
api('/api/gateway/status').then(r=>{
if(!r) return;
+ if(!r.configured){
+ card.innerHTML=`
Gateway not configured
`;
+ return;
+ }
if(!r.running){
card.innerHTML=`Gateway not running
`;
return;
diff --git a/tests/test_gateway_status_agent_health.py b/tests/test_gateway_status_agent_health.py
new file mode 100644
index 00000000..843f7cd5
--- /dev/null
+++ b/tests/test_gateway_status_agent_health.py
@@ -0,0 +1,247 @@
+"""Regression coverage: /api/gateway/status uses agent_health payload as
+the authoritative 'running' signal (#d0568682 / parent review t_9098e3db).
+
+Before the fix, the handler called gateway.status.get_running_pid() directly
+and fell back to bool(identity_map) when the module was unavailable. The fix
+makes it consult agent_health.build_agent_health_payload() so the tri-state
+`alive` field is the single source of truth for gateway process health.
+
+Tests use handle_get + monkeypatched build_agent_health_payload() and
+_load_gateway_session_identity_map() to isolate the gateway status route
+from real filesystem state.
+"""
+
+from __future__ import annotations
+
+import json
+from urllib.parse import urlparse
+
+
+# ── FakeHandler (mirrors test_1560_password_env_var_no_op._FakeHandler) ────────
+
+class _FakeHandler:
+ """Minimal BaseHTTPRequestHandler stand-in for routes.handle_get."""
+
+ def __init__(self):
+ self.status = None
+ self.sent_headers: list[tuple[str, str]] = []
+ self.body = bytearray()
+ self.wfile = self
+
+ def send_response(self, code):
+ self.status = code
+
+ def send_header(self, key, value):
+ self.sent_headers.append((key, value))
+
+ def end_headers(self):
+ pass
+
+ def write(self, data):
+ """Accumulate bytes written to wfile."""
+ self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8"))
+
+ def get_json(self):
+ """Parse the accumulated body as JSON."""
+ return json.loads(self.body.decode("utf-8"))
+
+
+# ── Helpers ──────────────────────────────────────────────────────────────────
+
+def _call_gateway_status(monkeypatch, agent_health_alive, identity_map=None):
+ """Invoke handle_get for /api/gateway/status and return the parsed JSON.
+
+ monkeypatches build_agent_health_payload to return the given `alive` value
+ and _load_gateway_session_identity_map to return the given identity_map.
+ """
+ from api import routes
+
+ monkeypatch.setattr(
+ routes,
+ "build_agent_health_payload",
+ lambda: {
+ "alive": agent_health_alive,
+ "checked_at": "2026-05-06T12:00:00+00:00",
+ "details": {},
+ },
+ )
+
+ if identity_map is not None:
+ monkeypatch.setattr(
+ routes,
+ "_load_gateway_session_identity_map",
+ lambda: identity_map,
+ )
+
+ handler = _FakeHandler()
+ parsed = urlparse("http://example.com/api/gateway/status")
+ routes.handle_get(handler, parsed)
+ return handler.get_json()
+
+
+# ── Acceptance criteria tests ─────────────────────────────────────────────────
+
+def test_gateway_status_running_true_when_agent_health_alive_and_no_sessions(monkeypatch):
+ """AC1: alive=true + empty identity_map → running=true, configured=true, platforms=[]"""
+ result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={})
+ assert result["running"] is True
+ assert result["configured"] is True
+ assert result["platforms"] == []
+
+
+def test_gateway_status_running_false_when_agent_health_alive_false_and_no_sessions(monkeypatch):
+ """AC2: alive=false + empty identity_map → running=false, configured=true, platforms=[]"""
+ result = _call_gateway_status(monkeypatch, agent_health_alive=False, identity_map={})
+ assert result["running"] is False
+ assert result["configured"] is True
+ assert result["platforms"] == []
+
+
+def test_gateway_status_running_false_when_agent_health_alive_none_and_no_sessions(monkeypatch):
+ """When alive=None (not configured): fall back to identity_map heuristic,
+ and set configured=false so frontend can show 'not configured' state."""
+ result = _call_gateway_status(monkeypatch, agent_health_alive=None, identity_map={})
+ assert result["running"] is False
+ assert result["configured"] is False
+ assert result["platforms"] == []
+
+
+def test_gateway_status_running_true_and_platforms_when_agent_health_alive_and_sessions(monkeypatch):
+ """AC3: alive=true + sessions with platforms → running=true, configured=true, platforms populated"""
+ identity_map = {
+ "sess_a": {"raw_source": "telegram", "platform": "telegram"},
+ "sess_b": {"raw_source": "discord", "platform": "discord"},
+ }
+ result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map=identity_map)
+ assert result["running"] is True
+ assert result["configured"] is True
+ assert len(result["platforms"]) == 2
+ names = {p["name"] for p in result["platforms"]}
+ assert names == {"telegram", "discord"}
+
+
+# ── Edge case tests ───────────────────────────────────────────────────────────
+
+def test_gateway_status_alive_none_falls_back_to_identity_map_heuristic(monkeypatch):
+ """When alive=None (not configured) but sessions exist, running reflects identity_map.
+ configured=false tells the frontend to show 'not configured' state."""
+ from api import routes
+
+ monkeypatch.setattr(
+ routes,
+ "build_agent_health_payload",
+ lambda: {"alive": None, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}},
+ )
+ monkeypatch.setattr(
+ routes,
+ "_load_gateway_session_identity_map",
+ lambda: {"sess_c": {"raw_source": "telegram", "platform": "telegram"}},
+ )
+
+ handler = _FakeHandler()
+ parsed = urlparse("http://example.com/api/gateway/status")
+ routes.handle_get(handler, parsed)
+ result = handler.get_json()
+ # Fallback to identity_map: sessions exist → running=true
+ assert result["running"] is True
+ # But configured=false because alive was None (no gateway metadata)
+ assert result["configured"] is False
+
+
+def test_gateway_status_handles_corrupted_sessions_json(monkeypatch):
+ """Edge: sessions.json is corrupted → identity_map empty, rely on agent_health alone."""
+ from api import routes
+
+ monkeypatch.setattr(
+ routes,
+ "build_agent_health_payload",
+ lambda: {"alive": True, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}},
+ )
+ # _load_gateway_session_identity_map already returns {} on JSON parse failure;
+ # we monkeypatch it to return {} to simulate corrupted file.
+ monkeypatch.setattr(routes, "_load_gateway_session_identity_map", lambda: {})
+
+ handler = _FakeHandler()
+ parsed = urlparse("http://example.com/api/gateway/status")
+ routes.handle_get(handler, parsed)
+ result = handler.get_json()
+ assert result["running"] is True
+ assert result["platforms"] == []
+ assert result["session_count"] == 0
+
+
+def test_gateway_status_blank_platform_fields_empty_platforms_running_true(monkeypatch):
+ """Edge: sessions exist but all have blank/missing platform fields → platforms=[], running=true."""
+ from api import routes
+
+ monkeypatch.setattr(
+ routes,
+ "build_agent_health_payload",
+ lambda: {"alive": True, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}},
+ )
+ monkeypatch.setattr(
+ routes,
+ "_load_gateway_session_identity_map",
+ lambda: {
+ "sess_d": {"raw_source": "", "platform": ""},
+ "sess_e": {}, # no platform field at all
+ },
+ )
+
+ handler = _FakeHandler()
+ parsed = urlparse("http://example.com/api/gateway/status")
+ routes.handle_get(handler, parsed)
+ result = handler.get_json()
+ assert result["running"] is True
+ assert result["platforms"] == []
+
+
+# ── Existing behavior preservation tests ──────────────────────────────────────
+
+def test_gateway_status_running_false_when_agent_health_down_even_with_sessions(monkeypatch):
+ """When agent_health says alive=false, running should be false regardless of sessions."""
+ from api import routes
+
+ monkeypatch.setattr(
+ routes,
+ "build_agent_health_payload",
+ lambda: {"alive": False, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}},
+ )
+ monkeypatch.setattr(
+ routes,
+ "_load_gateway_session_identity_map",
+ lambda: {"sess_f": {"raw_source": "telegram", "platform": "telegram"}},
+ )
+
+ handler = _FakeHandler()
+ parsed = urlparse("http://example.com/api/gateway/status")
+ routes.handle_get(handler, parsed)
+ result = handler.get_json()
+ # Running should be false even though sessions exist — agent_health is authoritative
+ assert result["running"] is False
+ # But configured=true because alive=False means gateway metadata exists
+ assert result["configured"] is True
+ # But platforms should still be extracted from sessions
+ assert len(result["platforms"]) == 1
+ assert result["platforms"][0]["name"] == "telegram"
+
+
+def test_gateway_status_missing_r_field_handled_by_frontend(monkeypatch):
+ """Edge: response always has 'running' and 'configured' fields.
+ Frontend handles missing field via catch block. This test verifies the backend
+ always includes both fields in responses."""
+ result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={})
+ assert "running" in result
+ assert "configured" in result
+
+
+def test_gateway_status_last_active_empty_when_alive_and_no_sessions_path(monkeypatch):
+ """Bonus: alive=true + identity_map={} → last_active is empty string.
+ This guards the 'if running and sessions_path.exists()' guard from being
+ silently removed in a future refactor that might expose a stale timestamp."""
+ result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={})
+ assert result["running"] is True
+ assert result["configured"] is True
+ # In test context, sessions_path won't exist (no real filesystem),
+ # so last_active must be empty.
+ assert result["last_active"] == ""
\ No newline at end of file