diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 81dbf0c1..f5fc346d 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -363,10 +363,45 @@ async def generate_name(dashboard_id: str): return {"name": dashboard.name, "auto_named": True} +def _strip_orphan_session_cards(data: dict) -> None: + """Drop layout cards (and expanded ids) whose agent session no longer exists + anywhere, in memory OR on disk. The frontend mounts an AgentChat per card and + GETs its session; a card pointing at a vanished session (e.g. an empty + never-saved session) 404s on every load and flashes a dead "connect a model" + card before the client reconciles it away. The `gone()` test is the exact + condition that makes GET /sessions/{id} 404, so it removes precisely those + cards and nothing else. Filtering the RESPONSE (never the stored file) is + non-destructive: a wrong check can only hide a card for one response, not + delete it. Drafts have no backend session yet, so they're always kept.""" + from backend.apps.agents.agent_manager import agent_manager + from backend.apps.agents.manager.session.session_store import _load_session_data + layout = data.get("layout") + if not isinstance(layout, dict): + return + cards = layout.get("cards") + if not isinstance(cards, dict): + return + + def gone(sid: str) -> bool: + if sid.startswith("draft-") or sid in agent_manager.sessions: + return False + return _load_session_data(sid) is None + + orphans = [sid for sid in cards if gone(sid)] + for sid in orphans: + cards.pop(sid, None) + if orphans: + exp = layout.get("expanded_session_ids") + if isinstance(exp, list): + layout["expanded_session_ids"] = [s for s in exp if s not in orphans] + + @dashboards.router.get("/{dashboard_id}") async def get_dashboard(dashboard_id: str): dashboard = _load(dashboard_id) - return dashboard.model_dump(mode="json") + data = dashboard.model_dump(mode="json") + _strip_orphan_session_cards(data) + return data @dashboards.router.put("/{dashboard_id}") diff --git a/backend/tests/test_v2_invariants.py b/backend/tests/test_v2_invariants.py index 6ea61908..d9ba5094 100644 --- a/backend/tests/test_v2_invariants.py +++ b/backend/tests/test_v2_invariants.py @@ -594,6 +594,33 @@ async def test_mcp_gate_only_forwards_activated_servers(): assert forwarded == (set(active) & set(names)), f"mismatch for active={active}" +def test_dashboard_get_strips_only_orphan_session_cards(): + """A layout card whose session vanished (gone from memory AND disk) makes the + frontend GET /sessions/{id} 404 on every load and flash a dead card. The + dashboard GET filters those orphan cards out of the response, but must keep + live (in-memory) cards, on-disk cards, and drafts. Non-destructive: only the + response is filtered, never the stored layout.""" + from types import SimpleNamespace + from backend.apps.dashboards import dashboards as D + data = {"layout": { + "cards": { + "live": {"session_id": "live"}, # in memory + "ondisk": {"session_id": "ondisk"}, # closed but on disk + "draft-1": {"session_id": "draft-1"}, # unsent draft, no backend session yet + "ghost": {"session_id": "ghost"}, # gone from memory AND disk -> would 404 + }, + "expanded_session_ids": ["live", "ghost"], + }} + fake_mgr = SimpleNamespace(sessions={"live": object()}) + on_disk = {"ondisk": {"id": "ondisk"}} + with patch("backend.apps.agents.agent_manager.agent_manager", fake_mgr), \ + patch("backend.apps.agents.manager.session.session_store._load_session_data", + side_effect=lambda sid: on_disk.get(sid)): + D._strip_orphan_session_cards(data) + assert set(data["layout"]["cards"].keys()) == {"live", "ondisk", "draft-1"}, "only the ghost should be dropped" + assert data["layout"]["expanded_session_ids"] == ["live"], "ghost dropped from expanded too" + + def test_banned_models_not_offered(): """Claude Fable (banned) and Gemini 3.1 Pro (no working lane: AG can't serve it, AI Studio key 429s pro-preview) were pulled from the picker. Guard so a