diff --git a/dashboard/src/agentforge_dashboard/client.py b/dashboard/src/agentforge_dashboard/client.py index 93f4980..cdaa2ab 100644 --- a/dashboard/src/agentforge_dashboard/client.py +++ b/dashboard/src/agentforge_dashboard/client.py @@ -66,6 +66,9 @@ class CoreClient: def _post(self, path: str, body: dict) -> Any: r = self._client.post(path, json=body) if r.status_code in {404, 409, 422}: - return {"error": r.json()} + # Clave deliberadamente distinta de "error": el cuerpo de un AgentExecution + # correcto ya trae error=None, así que las páginas no podrían distinguir + # "ejecución sin error" de "la API rechazó la petición" si reusáramos "error". + return {"api_error": r.json()} r.raise_for_status() return r.json() diff --git a/dashboard/src/agentforge_dashboard/pages/2_▶️_Ejecutar.py b/dashboard/src/agentforge_dashboard/pages/2_▶️_Ejecutar.py index b78ecc9..45b05e1 100644 --- a/dashboard/src/agentforge_dashboard/pages/2_▶️_Ejecutar.py +++ b/dashboard/src/agentforge_dashboard/pages/2_▶️_Ejecutar.py @@ -53,7 +53,9 @@ if st.button("🚀 Invocar agente", type="primary", disabled=not user_input): st.session_state["last_execution"] = result execution = st.session_state.get("last_execution") -if execution and "error" not in execution: +if execution and "api_error" in execution: + st.error(f"Error de la API: {execution['api_error']}") +elif execution: st.divider() st.markdown(f"**trace_id:** `{execution['trace_id']}`") st.markdown(f"**Status:** `{execution['status']}`") @@ -63,10 +65,10 @@ if execution and "error" not in execution: "Esta ejecución requiere aprobación humana. " "Ve a la página **Aprobaciones** para revisar y decidir." ) + if execution.get("error"): + st.error(f"La ejecución terminó con error: `{execution['error']}`") if execution.get("final_output"): st.subheader("📦 Output final") st.json(execution["final_output"]) render_violations(execution.get("violations", [])) render_trace(execution.get("decision_path", [])) -elif execution and "error" in execution: - st.error(f"Error de la API: {execution['error']}") diff --git a/dashboard/src/agentforge_dashboard/pages/3_🤝_Aprobaciones.py b/dashboard/src/agentforge_dashboard/pages/3_🤝_Aprobaciones.py index 4698dd5..f316343 100644 --- a/dashboard/src/agentforge_dashboard/pages/3_🤝_Aprobaciones.py +++ b/dashboard/src/agentforge_dashboard/pages/3_🤝_Aprobaciones.py @@ -65,8 +65,8 @@ with col_a: execution["trace_id"], {"approved_action_ids": approved_ids, "comment": comment}, ) - if "error" in r: - st.error(r["error"]) + if "api_error" in r: + st.error(r["api_error"]) else: st.success(f"Status: {r['status']}") st.rerun() @@ -75,8 +75,8 @@ with col_r: if st.button("❌ Rechazar ejecución", disabled=not reason): with st.spinner("Aplicando rechazo..."): r = client.reject(execution["trace_id"], {"reason": reason}) - if "error" in r: - st.error(r["error"]) + if "api_error" in r: + st.error(r["api_error"]) else: st.success(f"Status: {r['status']}") st.rerun() diff --git a/tests/unit/test_dashboard_client.py b/tests/unit/test_dashboard_client.py new file mode 100644 index 0000000..f64fdd2 --- /dev/null +++ b/tests/unit/test_dashboard_client.py @@ -0,0 +1,59 @@ +"""Tests del CoreClient: cómo mapea respuestas HTTP del core a dicts. + +Importante para el dashboard: una ejecución correcta (``AgentExecution`` serializado) +incluye SIEMPRE el campo ``error`` (``str | None``). El wrapper que el cliente añade +ante un 4xx debe usar una clave distinta (``api_error``) para no colisionar con ese +campo; si usara ``error``, las páginas no podrían distinguir "el agente terminó sin +error" de "la API devolvió 404/409/422". +""" + +from __future__ import annotations + +from collections.abc import Callable + +import httpx +import pytest + +from agentforge_dashboard.client import CoreClient + + +def _client_with(handler: Callable[[httpx.Request], httpx.Response]) -> CoreClient: + client = CoreClient(base_url="http://test") + client._client.close() + client._client = httpx.Client(base_url="http://test", transport=httpx.MockTransport(handler)) + return client + + +def test_post_2xx_devuelve_el_cuerpo_verbatim_incluido_error_none() -> None: + """Un AgentExecution con ``error=None`` se devuelve tal cual, sin envolver.""" + execution = { + "trace_id": "11111111-1111-1111-1111-111111111111", + "status": "completed", + "error": None, + "violations": [], + "decision_path": [], + } + + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=execution) + + result = _client_with(handler).invoke_agent("incident_analyzer", {"input": "x"}) + assert result == execution + assert "api_error" not in result + + +@pytest.mark.parametrize("status", [404, 409, 422]) +def test_post_4xx_envuelve_el_detalle_en_api_error(status: int) -> None: + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(status, json={"detail": "boom"}) + + result = _client_with(handler).invoke_agent("incident_analyzer", {"input": "y"}) + assert result == {"api_error": {"detail": "boom"}} + + +def test_post_5xx_propaga_la_excepcion() -> None: + def handler(_: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"detail": "kaput"}) + + with pytest.raises(httpx.HTTPStatusError): + _client_with(handler).invoke_agent("incident_analyzer", {"input": "z"})