diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index af9c7c65..74c8c3c2 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -4639,17 +4639,20 @@ class AgentManager: def get_all_sessions(self, dashboard_id: str | None = None) -> list[AgentSession]: if not dashboard_id: return list(self.sessions.values()) - # Memory first, then promote any on-disk sessions for this dashboard - # that aren't loaded yet. Imported sessions (and ones not resumed since - # a restart) live on disk but not in memory, so without the disk pass - # their cards render blank, the frontend's AgentCard returns null when - # a card's session is missing from the agents slice. Promoting into - # self.sessions bounds the disk read to once per session per run, like - # resume_session. Mirrors get_browser_agent_children's memory+disk walk. + # Memory first, then promote on-disk sessions for this dashboard, but + # ONLY ones the dashboard's layout still has a card for. A session keeps + # its dashboard_id when its card is deleted, so promoting by tag alone + # resurrected deleted chats on every reopen; the layout's cards are the + # real source of truth for what's on the board. Imported sessions ARE in + # the layout, so they still surface, and this bounds the disk read to + # once per session per run, like resume_session. result = [s for s in self.sessions.values() if s.dashboard_id == dashboard_id] seen = {s.id for s in result} + card_ids = self._dashboard_card_ids(dashboard_id) for sid, data in _load_all_session_data(): - if sid in seen or data.get("dashboard_id") != dashboard_id: + if sid in seen or sid not in card_ids: + continue + if data.get("dashboard_id") != dashboard_id: continue try: sess = AgentSession(**data) @@ -4661,6 +4664,18 @@ class AgentManager: result.append(sess) return result + def _dashboard_card_ids(self, dashboard_id: str) -> set[str]: + """Session ids the dashboard's layout currently has agent cards for. + Read straight off disk (no dashboards-module import, avoids a cycle).""" + try: + import os + import backend.config.paths as _paths + from backend.config.json_store import read_json_or_none + d = read_json_or_none(os.path.join(_paths.DASHBOARDS_DIR, f"{dashboard_id}.json")) or {} + return set((d.get("layout", {}).get("cards") or {}).keys()) + except Exception: + return set() + def get_session(self, session_id: str) -> Optional[AgentSession]: return self.sessions.get(session_id) diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index fe23d60a..0678bda6 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -323,6 +323,39 @@ def test_dashboard_export_import_carries_agent_cards_and_transcript(tmp_path, mo assert sum(len(s.messages) for s in found) == 2, "and with their transcripts" +def test_get_all_sessions_does_not_resurrect_deleted_cards(tmp_path, monkeypatch): + # Deleting a card removes it from the layout but the session keeps its + # dashboard_id on disk. get_all_sessions must surface only sessions the + # layout still has a card for, or deleted chats come back on every reopen. + from backend.apps.agents import agent_manager as am + import backend.config.paths as paths + sdir = tmp_path / "sessions" + ddir = tmp_path / "dashboards" + sdir.mkdir() + ddir.mkdir() + monkeypatch.setattr(am, "SESSIONS_DIR", str(sdir)) + monkeypatch.setattr(paths, "DASHBOARDS_DIR", str(ddir)) + monkeypatch.setattr(am.agent_manager, "sessions", {}) + + did = "d1" + + def sess(sid): + return { + "id": sid, "name": sid, "status": "completed", "model": "sonnet", + "mode": "agent", "messages": [], "branches": {}, "active_branch_id": "main", + "dashboard_id": did, + } + + (sdir / "kept.json").write_text(json.dumps(sess("kept"))) + (sdir / "deleted.json").write_text(json.dumps(sess("deleted"))) # still tagged, card gone + # The layout has a card only for "kept" (the user deleted "deleted"'s card). + (ddir / f"{did}.json").write_text(json.dumps({"id": did, "layout": {"cards": {"kept": {"session_id": "kept"}}}})) + + ids = {s.id for s in am.agent_manager.get_all_sessions(dashboard_id=did)} + assert "kept" in ids, "a session the layout still has a card for must surface" + assert "deleted" not in ids, "a session whose card was deleted must NOT resurrect" + + def test_dashboard_serialize_rewrites_refs_to_bundle_ids(): from backend.apps.swarm.entities.dashboards import DashboardExportable from backend.apps.swarm.models import EntityType