fix(agent): full-catalog dashboard search and working LLM retry
- search_dashboards: call /api/dashboards with page_context=other and page_size=100 so the profile 'My Dashboards Only' filter can no longer hide the whole catalog; parse available_total/effective_profile_filter and report hidden-by-filter instead of a false 'no dashboards' answer - prefetch_dashboards: same full-catalog context; fix dead code where data=resp.json() sat after return '' inside the error branch, making every 200 response raise NameError and the prefetch always return '' - llm-status: ?force=1 bypasses the 30s health cache so the 'Retry now' button performs a fresh probe instead of re-reading the stale status; frontend keeps a single retry interval (previously stacked intervals decayed the countdown faster than 1/s and fired duplicate probes) - tests: agent tool/prefetch, backend route bypass + force param, frontend retry/force coverage
This commit is contained in:
@@ -206,30 +206,34 @@ async def prefetch_dashboards(env_id: str) -> str:
|
||||
client = get_shared_http_client(timeout=10)
|
||||
resp = await client.get(
|
||||
f"{FASTAPI_URL}/api/dashboards",
|
||||
params={"q": "", "env_id": env_id or ""},
|
||||
# Full-catalog context: page_context=other disables the "My Dashboards Only"
|
||||
# profile filter, so prefetch reflects the whole environment instead of the
|
||||
# user's filtered view (which would report "No dashboards found." for every
|
||||
# dashboard without matching owner metadata). page_size=100 avoids truncation.
|
||||
params={"q": "", "env_id": env_id or "", "page_context": "other", "page_size": 100},
|
||||
headers=_dual_auth_headers(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return ""
|
||||
data = resp.json()
|
||||
dashboards = data.get("dashboards", [])
|
||||
if not dashboards:
|
||||
return "No dashboards found."
|
||||
limit = _PREFETCH_LIMIT
|
||||
total = len(dashboards)
|
||||
lines = []
|
||||
for db in dashboards[:limit]:
|
||||
title = db.get("title", "Untitled")
|
||||
dashboard_id = db.get("id") or db.get("dashboard_id")
|
||||
modified = (db.get("last_modified", "") or "")[:10]
|
||||
if modified:
|
||||
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
|
||||
else:
|
||||
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
|
||||
suffix = ""
|
||||
if total > limit:
|
||||
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
|
||||
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
|
||||
data = resp.json()
|
||||
dashboards = data.get("dashboards", [])
|
||||
if not dashboards:
|
||||
return "No dashboards found."
|
||||
limit = _PREFETCH_LIMIT
|
||||
total = data.get("total") or len(dashboards)
|
||||
lines = []
|
||||
for db in dashboards[:limit]:
|
||||
title = db.get("title", "Untitled")
|
||||
dashboard_id = db.get("id") or db.get("dashboard_id")
|
||||
modified = (db.get("last_modified", "") or "")[:10]
|
||||
if modified:
|
||||
lines.append(f"- {title} (id: {dashboard_id or 'n/a'}, modified: {modified})")
|
||||
else:
|
||||
lines.append(f"- {title} (id: {dashboard_id or 'n/a'})")
|
||||
suffix = ""
|
||||
if total > limit:
|
||||
suffix = f"\n... {total - limit} more dashboards omitted. Ask for a narrower search if needed."
|
||||
return f"Available dashboards in environment '{env_id or 'default'}' ({total} total):\n" + "\n".join(lines) + suffix
|
||||
except Exception as e:
|
||||
logger.explore("Prefetch dashboards failed", payload={"env_id": env_id}, error=str(e), extra={"src": "AgentChat.Persistence.PrefetchDashboards"})
|
||||
return ""
|
||||
|
||||
@@ -367,13 +367,26 @@ class SearchDashboardsInput(BaseModel):
|
||||
# @BRIEF Search and list dashboards by name, with optional environment filter.
|
||||
# @PRE User authenticated via dual-identity JWT.
|
||||
# @POST Returns formatted dashboard list string.
|
||||
# @POST Response surfaces effective profile filter metadata so hidden dashboards are never reported as absent.
|
||||
# @RATIONALE Agent search must see the full environment catalog: without page_context=other the backend
|
||||
# applies the UI "My Dashboards Only" profile filter, which hides every dashboard whose owners/modified_by
|
||||
# do not match the bound Superset user — including all dashboards lacking owner metadata — and the tool
|
||||
# previously discarded available_total/effective_profile_filter, producing a false "no dashboards" answer.
|
||||
# @SIDE_EFFECT HTTP GET to FastAPI /api/dashboards.
|
||||
@tool(args_schema=SearchDashboardsInput)
|
||||
async def search_dashboards(query: str, env_id: str | None = None) -> str:
|
||||
"""Search and list dashboards by name, with optional environment filter."""
|
||||
logger.reason("Search dashboards", payload={"query": query, "env_id": env_id},
|
||||
extra={"src": "AgentChat.Tools.SearchDashboards"})
|
||||
params = {"q": query, "env_id": env_id or ""}
|
||||
# Full-catalog search: page_context=other disables the profile-default filter,
|
||||
# page_size=100 avoids truncation for environments with more dashboards than
|
||||
# the default page size (10).
|
||||
params = {
|
||||
"q": query,
|
||||
"env_id": env_id or "",
|
||||
"page_context": "other",
|
||||
"page_size": 100,
|
||||
}
|
||||
resp = await _get("/api/dashboards", params=params)
|
||||
if resp.status_code != 200:
|
||||
logger.explore("Dashboard search failed",
|
||||
@@ -386,14 +399,28 @@ async def search_dashboards(query: str, env_id: str | None = None) -> str:
|
||||
data = resp.json()
|
||||
dashboards = data.get("dashboards", [])
|
||||
total = data.get("total", 0)
|
||||
available_total = data.get("available_total")
|
||||
profile_filter = data.get("effective_profile_filter") or {}
|
||||
filter_applied = bool(profile_filter.get("applied"))
|
||||
env_label = env_id or "default"
|
||||
|
||||
if total == 0:
|
||||
if isinstance(available_total, int) and available_total > 0:
|
||||
logger.reflect("Dashboards hidden by profile filter",
|
||||
payload={"query": query, "total": 0, "available_total": available_total, "filter_applied": filter_applied},
|
||||
extra={"src": "AgentChat.Tools.SearchDashboards"})
|
||||
return (
|
||||
f"Found {available_total} dashboard(s) in environment '{env_label}', but all are hidden "
|
||||
f"by the profile filter 'My Dashboards Only' (bound username: "
|
||||
f"{profile_filter.get('username') or 'unknown'}). No dashboards are listed; "
|
||||
f"use override_show_all=true or page_context=other to see the full catalog."
|
||||
)
|
||||
logger.reflect("No dashboards found",
|
||||
payload={"query": query, "total": 0},
|
||||
extra={"src": "AgentChat.Tools.SearchDashboards"})
|
||||
return f"No dashboards found matching '{query}' in environment '{env_id or 'default'}'."
|
||||
return f"No dashboards found matching '{query}' in environment '{env_label}'."
|
||||
|
||||
lines = [f"Found {total} dashboard(s) in environment '{env_id or 'default'}':"]
|
||||
lines = [f"Found {total} dashboard(s) in environment '{env_label}':"]
|
||||
for db in dashboards:
|
||||
title = db.get("title", "Untitled")
|
||||
owners = ", ".join(db.get("owners", [])) or "N/A"
|
||||
@@ -401,6 +428,15 @@ async def search_dashboards(query: str, env_id: str | None = None) -> str:
|
||||
lines.append(f" - {title}")
|
||||
lines.append(f" Owners: {owners}")
|
||||
lines.append(f" Last modified: {modified}")
|
||||
if filter_applied:
|
||||
lines.append(
|
||||
f"(Note: profile filter 'My Dashboards Only' active — showing only dashboards "
|
||||
f"matching {profile_filter.get('username') or 'bound user'})"
|
||||
)
|
||||
if len(dashboards) < total:
|
||||
lines.append(
|
||||
f"(Note: showing first {len(dashboards)} of {total} dashboards — refine the query or paginate for more)"
|
||||
)
|
||||
logger.reflect("Dashboards listed",
|
||||
payload={"query": query, "total": total},
|
||||
extra={"src": "AgentChat.Tools.SearchDashboards"})
|
||||
|
||||
@@ -228,6 +228,99 @@ async def test_search_dashboards_correct_url():
|
||||
args, kwargs = call_args
|
||||
url = args[0] if args else kwargs.get("url", "")
|
||||
assert "api/dashboards" in url
|
||||
# Agent searches the full catalog: profile-default filter must be disabled
|
||||
# and the page size raised above the default (10) to avoid truncation.
|
||||
params = kwargs.get("params", {})
|
||||
assert params.get("page_context") == "other"
|
||||
assert params.get("page_size") == 100
|
||||
assert params.get("q") == "dashboard-name"
|
||||
assert params.get("env_id") == "prod"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_dashboards_surfaces_profile_filter_hidden():
|
||||
"""total=0 with available_total>0 must report hidden dashboards, not absence."""
|
||||
from ss_tools.agent.context import set_service_jwt, set_user_jwt
|
||||
from ss_tools.agent.tools import search_dashboards
|
||||
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
payload = {
|
||||
"dashboards": [],
|
||||
"total": 0,
|
||||
"available_total": 11,
|
||||
"effective_profile_filter": {
|
||||
"applied": True,
|
||||
"username": "admin",
|
||||
"match_logic": "owners_or_modified_by",
|
||||
},
|
||||
}
|
||||
mock_resp = Mock(status_code=200, text="")
|
||||
mock_resp.json.return_value = payload
|
||||
|
||||
_, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
result = await search_dashboards.ainvoke({"query": "", "env_id": "ss-dev"})
|
||||
|
||||
assert "11" in result
|
||||
assert "hidden" in result
|
||||
assert "My Dashboards Only" in result
|
||||
assert "admin" in result
|
||||
assert "no dashboards found" not in result.lower() or "hidden" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_dashboards_truncation_note():
|
||||
"""When the API returns fewer items than total, the tool must say so."""
|
||||
from ss_tools.agent.context import set_service_jwt, set_user_jwt
|
||||
from ss_tools.agent.tools import search_dashboards
|
||||
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
payload = {
|
||||
"dashboards": [
|
||||
{"title": f"Dashboard {i}", "owners": ["admin"], "last_modified": "2026-01-01T00:00:00"} for i in range(10)
|
||||
],
|
||||
"total": 15,
|
||||
"available_total": 15,
|
||||
"effective_profile_filter": {"applied": False, "username": None},
|
||||
}
|
||||
mock_resp = Mock(status_code=200, text="")
|
||||
mock_resp.json.return_value = payload
|
||||
|
||||
_, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
result = await search_dashboards.ainvoke({"query": "", "env_id": "prod"})
|
||||
|
||||
assert "Found 15 dashboard(s)" in result
|
||||
assert "showing first 10 of 15" in result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_dashboards_empty_env_still_reports_absent():
|
||||
"""A genuinely empty environment must still produce the 'no dashboards' message."""
|
||||
from ss_tools.agent.context import set_service_jwt, set_user_jwt
|
||||
from ss_tools.agent.tools import search_dashboards
|
||||
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
payload = {
|
||||
"dashboards": [],
|
||||
"total": 0,
|
||||
"available_total": 0,
|
||||
"effective_profile_filter": {"applied": False, "username": None},
|
||||
}
|
||||
mock_resp = Mock(status_code=200, text="")
|
||||
mock_resp.json.return_value = payload
|
||||
|
||||
_, patcher = _mock_http_client(get_return=mock_resp)
|
||||
with patcher:
|
||||
result = await search_dashboards.ainvoke({"query": "x", "env_id": "prod"})
|
||||
|
||||
assert "No dashboards found matching 'x'" in result
|
||||
|
||||
|
||||
# #endregion TestAgentChat.Tools.ToolContracts
|
||||
|
||||
99
agent/tests/test_agent/test_persistence_prefetch.py
Normal file
99
agent/tests/test_agent/test_persistence_prefetch.py
Normal file
@@ -0,0 +1,99 @@
|
||||
# #region Test.Agent.Persistence.PrefetchDashboards [C:3] [TYPE Module] [SEMANTICS test,agent,persistence,prefetch,dashboards]
|
||||
# @BRIEF Tests for prefetch_dashboards — full-catalog context injection for the LLM.
|
||||
# @RELATION BINDS_TO -> [AgentChat.Persistence]
|
||||
# @TEST_EDGE: http_error -> non-200 returns empty string
|
||||
# @TEST_EDGE: empty_catalog -> "No dashboards found." text
|
||||
# @TEST_EDGE: full_catalog -> lists dashboards and requests page_context=other (profile filter bypass)
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
sys.path.append(str(Path(__file__).resolve().parent.parent.parent / "src"))
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("FASTAPI_URL", "http://test-backend:8000")
|
||||
os.environ.setdefault("SERVICE_JWT", "test-service-jwt")
|
||||
os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing")
|
||||
|
||||
|
||||
def _mock_response(status_code=200, json_data=None):
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data or {}
|
||||
return resp
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prefetch_dashboards_uses_full_catalog_context():
|
||||
"""prefetch_dashboards must request page_context=other + page_size=100 so the
|
||||
"My Dashboards Only" profile filter cannot hide the whole catalog."""
|
||||
from ss_tools.agent.context import set_service_jwt, set_user_jwt
|
||||
from ss_tools.agent._persistence import prefetch_dashboards
|
||||
|
||||
set_user_jwt("jwt")
|
||||
set_service_jwt("svc-jwt")
|
||||
|
||||
payload = {
|
||||
"dashboards": [
|
||||
{"id": i, "title": f"Dash {i}", "slug": f"dash-{i}", "last_modified": "2026-01-01T00:00:00"}
|
||||
for i in range(1, 12)
|
||||
],
|
||||
"total": 11,
|
||||
"available_total": 11,
|
||||
"effective_profile_filter": {"applied": False, "username": None},
|
||||
}
|
||||
mock_resp = _mock_response(200, payload)
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get = AsyncMock(return_value=mock_resp)
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
result = await prefetch_dashboards("ss-dev")
|
||||
|
||||
call_kwargs = mock_client.get.call_args
|
||||
assert call_kwargs is not None
|
||||
_, kwargs = call_kwargs
|
||||
params = kwargs.get("params", {})
|
||||
assert params.get("page_context") == "other"
|
||||
assert params.get("page_size") == 100
|
||||
|
||||
# The previous dead-code bug made every 200 response raise NameError and return "".
|
||||
assert result != ""
|
||||
assert "11 total" in result
|
||||
assert "Dash 1" in result
|
||||
assert "Dash 11" in result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prefetch_dashboards_http_error_returns_empty():
|
||||
"""Non-200 response must degrade to an empty string, not raise."""
|
||||
from ss_tools.agent._persistence import prefetch_dashboards
|
||||
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get = AsyncMock(return_value=_mock_response(500, {}))
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
result = await prefetch_dashboards("ss-dev")
|
||||
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_prefetch_dashboards_empty_catalog():
|
||||
"""A genuinely empty catalog returns the explicit no-dashboards marker."""
|
||||
from ss_tools.agent._persistence import prefetch_dashboards
|
||||
|
||||
payload = {
|
||||
"dashboards": [],
|
||||
"total": 0,
|
||||
"available_total": 0,
|
||||
"effective_profile_filter": {"applied": False, "username": None},
|
||||
}
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get = AsyncMock(return_value=_mock_response(200, payload))
|
||||
with patch("ss_tools.agent._persistence.get_shared_http_client", return_value=mock_client):
|
||||
result = await prefetch_dashboards("ss-dev")
|
||||
|
||||
assert result == "No dashboards found."
|
||||
# #endregion Test.Agent.Persistence.PrefetchDashboards
|
||||
@@ -3,7 +3,7 @@
|
||||
# @BRIEF Agent LLM provider health status endpoint — used by frontend for provider availability indicator.
|
||||
# @RATIONALE Frontend performs health check at mount and auto-retries every 30s if provider unavailable.
|
||||
# @RELATION DEPENDS_ON -> [ss_tools.shared._llm_health]
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
router = APIRouter(prefix="/api/agent", tags=["agent-status"])
|
||||
|
||||
@@ -13,15 +13,17 @@ router = APIRouter(prefix="/api/agent", tags=["agent-status"])
|
||||
# @BRIEF Return cached LLM provider health status (or trigger probe if cache expired).
|
||||
# @POST Returns {"status": "ok"|"unavailable"|"timeout"|"auth_error",
|
||||
# "last_error": str, "retry_after_s": int}
|
||||
# @POST force=true bypasses the 30s status cache for an immediate probe (frontend
|
||||
# "Retry now" button must get a fresh check, not the stale cached status).
|
||||
# @RATIONALE Uses ss_tools.shared._llm_health instead of src.agent._llm_health
|
||||
# because the agent code has been moved to a separate project. The shared
|
||||
# module uses only openai+httpx (no gradio, no langchain), so it works in
|
||||
# both backend and agent containers.
|
||||
@router.get("/llm-status")
|
||||
async def get_llm_status():
|
||||
async def get_llm_status(force: bool = Query(False)):
|
||||
"""Get cached LLM provider health status. Probes provider if cache expired."""
|
||||
from ss_tools.shared._llm_health import _check_llm_provider_health, _llm_status
|
||||
status = await _check_llm_provider_health()
|
||||
status = await _check_llm_provider_health(force=force)
|
||||
return {
|
||||
"status": status,
|
||||
"last_error": _llm_status.get("last_error", ""),
|
||||
|
||||
@@ -107,4 +107,32 @@ class TestGetLlmStatus:
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_check.assert_called_once()
|
||||
|
||||
def test_force_param_bypasses_cache(self):
|
||||
"""?force=true must reach _check_llm_provider_health(force=True) so the
|
||||
"Retry now" button gets a fresh probe instead of the 30s cached status."""
|
||||
mock_check = AsyncMock(return_value="unavailable")
|
||||
with (
|
||||
patch("ss_tools.shared._llm_health._check_llm_provider_health", mock_check),
|
||||
patch("ss_tools.shared._llm_health._llm_status", {"status": "unavailable", "last_error": "Provider unreachable"}),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/agent/llm-status?force=true")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "unavailable"
|
||||
mock_check.assert_called_once_with(force=True)
|
||||
|
||||
def test_force_false_default_passes_no_force(self):
|
||||
"""Without the force param the health check is called with force=False (cached)."""
|
||||
mock_check = AsyncMock(return_value="ok")
|
||||
with (
|
||||
patch("ss_tools.shared._llm_health._check_llm_provider_health", mock_check),
|
||||
patch("ss_tools.shared._llm_health._llm_status", {"status": "ok", "last_error": ""}),
|
||||
):
|
||||
client = _make_client()
|
||||
resp = client.get("/api/agent/llm-status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_check.assert_called_once_with(force=False)
|
||||
# #endregion Test.Api.AgentStatusRoutes
|
||||
|
||||
@@ -407,6 +407,62 @@ class TestGetDashboards:
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
|
||||
def test_get_dashboards_page_context_other_bypasses_profile_filter(self, base_mocks):
|
||||
"""page_context=other must bypass the profile-default filter (agent full-catalog search)."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "other"}], "modified_by": None},
|
||||
{"id": 2, "title": "Other Dash", "slug": "other", "owners": [], "modified_by": None},
|
||||
]
|
||||
|
||||
mocks = dict(base_mocks)
|
||||
profile_svc = MagicMock()
|
||||
profile_svc.get_dashboard_filter_binding.return_value = {
|
||||
"superset_username": "testuser",
|
||||
"superset_username_normalized": "testuser",
|
||||
"show_only_my_dashboards": True,
|
||||
"show_only_slug_dashboards": False,
|
||||
}
|
||||
profile_svc.matches_dashboard_actor.return_value = False
|
||||
with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
|
||||
MockPS.__module__ = "unittest.mock"
|
||||
client = _make_client(mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&page_context=other&apply_profile_default=true")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
# No profile filter applied on non-main page context: both dashboards visible.
|
||||
assert data["total"] == 2
|
||||
assert data["effective_profile_filter"]["applied"] is False
|
||||
profile_svc.matches_dashboard_actor.assert_not_called()
|
||||
|
||||
def test_get_dashboards_override_show_all_bypasses_profile_filter(self, base_mocks):
|
||||
"""override_show_all=true must bypass the profile-default filter (agent fallback)."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
|
||||
mock_rs.get_dashboards_with_status.return_value = [
|
||||
{"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "other"}], "modified_by": None},
|
||||
]
|
||||
|
||||
mocks = dict(base_mocks)
|
||||
profile_svc = MagicMock()
|
||||
profile_svc.get_dashboard_filter_binding.return_value = {
|
||||
"superset_username": "testuser",
|
||||
"superset_username_normalized": "testuser",
|
||||
"show_only_my_dashboards": True,
|
||||
"show_only_slug_dashboards": False,
|
||||
}
|
||||
profile_svc.matches_dashboard_actor.return_value = False
|
||||
with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
|
||||
MockPS.__module__ = "unittest.mock"
|
||||
client = _make_client(mocks)
|
||||
resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&override_show_all=true")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["effective_profile_filter"]["applied"] is False
|
||||
profile_svc.matches_dashboard_actor.assert_not_called()
|
||||
|
||||
def test_get_dashboards_profile_error_fallback(self, base_mocks):
|
||||
"""Profile service error falls back gracefully."""
|
||||
mock_rs = base_mocks["resource_service"]
|
||||
|
||||
@@ -957,7 +957,7 @@
|
||||
status={model.llmEffectiveStatus}
|
||||
message={model.llmBannerMessage}
|
||||
retryCountdown={model.llmRetryCountdown}
|
||||
onRetry={() => model.checkLlmStatus()}
|
||||
onRetry={() => model.checkLlmStatus(true)}
|
||||
onDismiss={() => model.llmBannerDismissed = true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,10 @@ export class AgentChatModel {
|
||||
llmBannerDismissed: boolean = $state(false);
|
||||
llmRetryCountdown: number = $state(0);
|
||||
llmBannerMessage: string = $state("");
|
||||
/** Single auto-retry interval — restarted on every check so countdown ticks 1/s
|
||||
* and retry probes never stack (multiple overlapping intervals previously made
|
||||
* the countdown decay faster than real time and "Retry now" appear dead). */
|
||||
private _llmRetryTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Effective LLM health for UI. `llmStatus` is a process-global, cached probe
|
||||
@@ -1158,10 +1162,12 @@ export class AgentChatModel {
|
||||
|
||||
// ── LLM provider health ─────────────────────────────────────────
|
||||
|
||||
/** Check LLM provider connectivity via backend endpoint. */
|
||||
async checkLlmStatus(): Promise<void> {
|
||||
/** Check LLM provider connectivity via backend endpoint.
|
||||
* force=true bypasses the backend 30s status cache so the "Retry now" button
|
||||
* performs a fresh probe instead of re-reading the stale cached status. */
|
||||
async checkLlmStatus(force = false): Promise<void> {
|
||||
try {
|
||||
const data = await fetchApi<Record<string, unknown>>("/agent/llm-status", { suppressToast: true });
|
||||
const data = await fetchApi<Record<string, unknown>>(`/agent/llm-status${force ? "?force=1" : ""}`, { suppressToast: true });
|
||||
const status = typeof data.status === "string" ? data.status : "unknown";
|
||||
this.llmStatus = status;
|
||||
if (status !== "ok" && !this.llmBannerDismissed) {
|
||||
@@ -1171,6 +1177,7 @@ export class AgentChatModel {
|
||||
this.llmBannerDismissed = false;
|
||||
this.llmRetryCountdown = 0;
|
||||
this.llmBannerMessage = "";
|
||||
if (this._llmRetryTimer) { clearInterval(this._llmRetryTimer); this._llmRetryTimer = null; }
|
||||
}
|
||||
} catch {
|
||||
this.llmStatus = "unknown";
|
||||
@@ -1178,11 +1185,14 @@ export class AgentChatModel {
|
||||
}
|
||||
|
||||
private _startRetryCountdown(seconds: number): void {
|
||||
// Clear any previous interval first — stacking intervals made the countdown
|
||||
// decay faster than 1/s and fired duplicate auto-retry probes.
|
||||
if (this._llmRetryTimer) { clearInterval(this._llmRetryTimer); this._llmRetryTimer = null; }
|
||||
this.llmRetryCountdown = seconds;
|
||||
const interval = setInterval(() => {
|
||||
this._llmRetryTimer = setInterval(() => {
|
||||
this.llmRetryCountdown--;
|
||||
if (this.llmRetryCountdown <= 0) {
|
||||
clearInterval(interval);
|
||||
if (this._llmRetryTimer) { clearInterval(this._llmRetryTimer); this._llmRetryTimer = null; }
|
||||
this.checkLlmStatus();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
@@ -1231,6 +1231,36 @@ describe("AgentChatModel — LLM Status", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("_startRetryCountdown clears previous interval so countdown never stacks", async () => {
|
||||
vi.useFakeTimers();
|
||||
const checkSpy = vi.spyOn(model, "checkLlmStatus").mockResolvedValue();
|
||||
// Two rapid starts (e.g. auto-retry + "Retry now" click) must not double the
|
||||
// decrement rate — previously overlapping intervals decayed 2/s and fired
|
||||
// duplicate probes, making the banner look stuck.
|
||||
model["_startRetryCountdown"](5);
|
||||
model["_startRetryCountdown"](5);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(model.llmRetryCountdown).toBe(4);
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(model.llmRetryCountdown).toBe(0);
|
||||
expect(checkSpy).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("checkLlmStatus(force=true) requests a fresh probe via force=1", async () => {
|
||||
vi.mocked(fetchApi).mockResolvedValue({ status: "unavailable", retry_after_s: 30 });
|
||||
await model.checkLlmStatus(true);
|
||||
expect(fetchApi).toHaveBeenCalledWith("/agent/llm-status?force=1", { suppressToast: true });
|
||||
expect(model.llmStatus).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("checkLlmStatus() default uses cached endpoint without force", async () => {
|
||||
vi.mocked(fetchApi).mockResolvedValue({ status: "ok" });
|
||||
await model.checkLlmStatus();
|
||||
expect(fetchApi).toHaveBeenCalledWith("/agent/llm-status", { suppressToast: true });
|
||||
expect(model.llmStatus).toBe("ok");
|
||||
});
|
||||
|
||||
it("checkLlmStatus handles fetch failure", async () => {
|
||||
vi.mocked(fetchApi).mockRejectedValue(new Error("Network fail"));
|
||||
await model.checkLlmStatus();
|
||||
|
||||
@@ -42,10 +42,13 @@ _LLM_LAST_ERROR_TS_KEY = "last_llm_error_ts"
|
||||
|
||||
|
||||
# #region AgentChat.LlmHealth.Check [C:2] [TYPE Function] [SEMANTICS agent-chat,llm,health,check]
|
||||
async def _check_llm_provider_health() -> str:
|
||||
"""Check LLM provider connectivity. Cached for _LLM_CHECK_CACHE_TTL seconds."""
|
||||
async def _check_llm_provider_health(force: bool = False) -> str:
|
||||
"""Check LLM provider connectivity. Cached for _LLM_CHECK_CACHE_TTL seconds
|
||||
unless force=True bypasses the cache for an immediate probe (used by the
|
||||
frontend "Retry now" button — without force a click inside the TTL window
|
||||
returned the stale cached status and appeared to do nothing)."""
|
||||
now = time.time()
|
||||
if now - _llm_status["last_check_ts"] < _LLM_CHECK_CACHE_TTL:
|
||||
if not force and now - _llm_status["last_check_ts"] < _LLM_CHECK_CACHE_TTL:
|
||||
return _llm_status["status"]
|
||||
|
||||
# Fetch LLM config from backend's own API (same as agent container does)
|
||||
|
||||
Reference in New Issue
Block a user