diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py index b5a8c84..8a4b4a5 100644 --- a/app/plugins/builtins/automation/plugin.py +++ b/app/plugins/builtins/automation/plugin.py @@ -193,8 +193,8 @@ class AutomationPlugin(BasePlugin): logger.exception("Failed to register agent coordinator tools") # Register MiniApps from manifest try: - from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry - registry = MiniAppRegistry() + from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry + registry = get_miniapp_registry() for miniapp in self.manifest.miniapps: registry.register( app_id=miniapp.app_id, @@ -236,8 +236,8 @@ class AutomationPlugin(BasePlugin): logger.exception("Failed to unregister agent coordinator tools") # Unregister MiniApps try: - from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry - registry = MiniAppRegistry() + from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry + registry = get_miniapp_registry() registry.unregister_plugin(self.manifest.name) logger.info("Unregistered MiniApps for plugin '%s'", self.manifest.name) except Exception: diff --git a/app/plugins/builtins/automation/routes.py b/app/plugins/builtins/automation/routes.py index 3fb5d51..907271c 100644 --- a/app/plugins/builtins/automation/routes.py +++ b/app/plugins/builtins/automation/routes.py @@ -187,8 +187,8 @@ async def list_miniapps( current_user: dict[str, Any] = Depends(get_current_user), ): """List custom MiniApps from plugin config.""" - from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry - registry = MiniAppRegistry() + from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry + registry = get_miniapp_registry() items = registry.list_apps() return {"items": items, "total": len(items)} @@ -204,8 +204,8 @@ async def create_miniapp( current_user: dict[str, Any] = Depends(get_current_user), ): """Create a custom MiniApp definition.""" - from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry - registry = MiniAppRegistry() + from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry + registry = get_miniapp_registry() registry.register( app_id=data.app_id, name=data.name, @@ -233,8 +233,8 @@ async def delete_miniapp( current_user: dict[str, Any] = Depends(get_current_user), ): """Delete a custom MiniApp definition.""" - from app.plugins.builtins.kommunikation.contracts import MiniAppRegistry - registry = MiniAppRegistry() + from app.plugins.builtins.kommunikation.contracts import get_miniapp_registry + registry = get_miniapp_registry() registry.unregister(app_id) return {"status": "ok"} diff --git a/app/plugins/builtins/kommunikation/contracts.py b/app/plugins/builtins/kommunikation/contracts.py index eb81514..c55ba33 100644 --- a/app/plugins/builtins/kommunikation/contracts.py +++ b/app/plugins/builtins/kommunikation/contracts.py @@ -17,6 +17,8 @@ from app.plugins.builtins.contracts import get_contract_registry from app.plugins.builtins.kommunikation.miniapp_registry import ( MiniAppDef, MiniAppRegistry, + get_miniapp_registry, + reset_miniapp_registry, ) from app.plugins.builtins.kommunikation.models import ( CommConversation, @@ -60,6 +62,8 @@ class KommunikationContract: # ─── mini-app registry ─── MiniAppRegistry = MiniAppRegistry MiniAppDef = MiniAppDef + get_miniapp_registry = staticmethod(get_miniapp_registry) + reset_miniapp_registry = staticmethod(reset_miniapp_registry) # ─── models (read-only for queries) ─── CommConversation = CommConversation @@ -79,6 +83,8 @@ __all__ = [ "get_participant_registry", "MiniAppRegistry", "MiniAppDef", + "get_miniapp_registry", + "reset_miniapp_registry", "parse_mentions", "get_conversation", "get_messages", diff --git a/app/plugins/builtins/kommunikation/miniapp_registry.py b/app/plugins/builtins/kommunikation/miniapp_registry.py index 67d1abe..cab9289 100644 --- a/app/plugins/builtins/kommunikation/miniapp_registry.py +++ b/app/plugins/builtins/kommunikation/miniapp_registry.py @@ -71,3 +71,22 @@ class MiniAppRegistry: def get_app(self, app_id: str) -> MiniAppDef | None: """Get a specific mini-app definition.""" return self._apps.get(app_id) + + +# ─── Singleton helpers ─── + +_registry: MiniAppRegistry | None = None + + +def get_miniapp_registry() -> MiniAppRegistry: + """Return the shared singleton MiniAppRegistry instance.""" + global _registry + if _registry is None: + _registry = MiniAppRegistry() + return _registry + + +def reset_miniapp_registry() -> None: + """Reset the singleton instance (useful for tests).""" + global _registry + _registry = None diff --git a/app/plugins/builtins/kommunikation/plugin.py b/app/plugins/builtins/kommunikation/plugin.py index 1eabb4a..b4a56ef 100644 --- a/app/plugins/builtins/kommunikation/plugin.py +++ b/app/plugins/builtins/kommunikation/plugin.py @@ -70,8 +70,8 @@ class KommunikationPlugin(BasePlugin): service_container.register("comm_websocket", ws_manager) # Register Mini-App registry as a shared service - from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry - miniapp_registry = MiniAppRegistry() + from app.plugins.builtins.kommunikation.miniapp_registry import get_miniapp_registry + miniapp_registry = get_miniapp_registry() service_container.register("comm_miniapps", miniapp_registry) # Register built-in mini-apps diff --git a/tests/test_miniapp_registry.py b/tests/test_miniapp_registry.py new file mode 100644 index 0000000..fcead13 --- /dev/null +++ b/tests/test_miniapp_registry.py @@ -0,0 +1,187 @@ +"""Tests for MiniAppRegistry singleton pattern and plugin lifecycle integration.""" + +from __future__ import annotations + +import pytest + +from app.plugins.builtins.kommunikation.miniapp_registry import ( + MiniAppRegistry, + get_miniapp_registry, + reset_miniapp_registry, +) + + +@pytest.fixture(autouse=True) +def _reset_registry(): + """Ensure a clean singleton before and after each test.""" + reset_miniapp_registry() + yield + reset_miniapp_registry() + + +class TestSingletonPattern: + def test_get_miniapp_registry_returns_same_instance(self): + """get_miniapp_registry() must return the same object every call.""" + r1 = get_miniapp_registry() + r2 = get_miniapp_registry() + assert r1 is r2 + assert isinstance(r1, MiniAppRegistry) + + def test_reset_miniapp_registry_creates_new_instance(self): + """After reset, a new instance is created.""" + r1 = get_miniapp_registry() + reset_miniapp_registry() + r2 = get_miniapp_registry() + assert r1 is not r2 + + def test_reset_miniapp_registry_sets_none(self): + """reset_miniapp_registry() should set the global to None.""" + get_miniapp_registry() # ensure singleton exists + reset_miniapp_registry() + # Access the module-level global directly + import app.plugins.builtins.kommunikation.miniapp_registry as mod + assert mod._registry is None + + +class TestRegisterAndList: + def test_register_and_list_apps(self): + """register() adds an app that appears in list_apps().""" + registry = get_miniapp_registry() + registry.register( + app_id="test_app", + name="Test App", + icon="🧪", + description="A test app", + plugin_name="test_plugin", + render_schema={"type": "object"}, + ) + apps = registry.list_apps() + assert len(apps) == 1 + assert apps[0]["app_id"] == "test_app" + assert apps[0]["name"] == "Test App" + assert apps[0]["plugin_name"] == "test_plugin" + + def test_register_overwrites_same_app_id(self): + """Registering the same app_id replaces the previous entry.""" + registry = get_miniapp_registry() + registry.register( + app_id="dup_app", + name="First", + icon="1", + description="", + plugin_name="plugin_a", + ) + registry.register( + app_id="dup_app", + name="Second", + icon="2", + description="", + plugin_name="plugin_b", + ) + apps = registry.list_apps() + assert len(apps) == 1 + assert apps[0]["name"] == "Second" + + def test_list_apps_empty_by_default(self): + """Fresh registry has no apps.""" + registry = get_miniapp_registry() + assert registry.list_apps() == [] + + +class TestUnregisterPlugin: + def test_unregister_plugin_removes_all_apps(self): + """unregister_plugin() removes all apps from that plugin.""" + registry = get_miniapp_registry() + registry.register("a1", "A1", "x", "", "plugin_x") + registry.register("a2", "A2", "x", "", "plugin_x") + registry.register("b1", "B1", "x", "", "plugin_y") + + registry.unregister_plugin("plugin_x") + + apps = registry.list_apps() + assert len(apps) == 1 + assert apps[0]["app_id"] == "b1" + + def test_unregister_plugin_noop_for_unknown(self): + """unregister_plugin() for a non-existent plugin is a no-op.""" + registry = get_miniapp_registry() + registry.register("a1", "A1", "x", "", "plugin_x") + registry.unregister_plugin("nonexistent") + assert len(registry.list_apps()) == 1 + + def test_unregister_single_app(self): + """unregister() removes a specific app by id.""" + registry = get_miniapp_registry() + registry.register("a1", "A1", "x", "", "plugin_x") + registry.register("a2", "A2", "x", "", "plugin_x") + + registry.unregister("a1") + apps = registry.list_apps() + assert len(apps) == 1 + assert apps[0]["app_id"] == "a2" + + +class TestPluginLifecycleIntegration: + """Test that plugin activation/deactivation uses the shared singleton.""" + + def test_activation_registers_in_shared_registry(self): + """When a plugin registers miniapps, they appear in the shared registry.""" + registry = get_miniapp_registry() + # Simulate what automation/plugin.py on_activate does + registry.register( + app_id="automation_agent", + name="Agent Runner", + icon="🤖", + description="Run an AI agent", + plugin_name="automation", + render_schema={"type": "object"}, + ) + + # A different caller (e.g. a route) should see the same apps + shared = get_miniapp_registry() + assert shared is registry + apps = shared.list_apps() + assert any(a["app_id"] == "automation_agent" for a in apps) + + def test_deactivation_removes_from_shared_registry(self): + """When a plugin deactivates, its miniapps are removed from the shared registry.""" + registry = get_miniapp_registry() + registry.register( + app_id="automation_agent", + name="Agent Runner", + icon="🤖", + description="Run an AI agent", + plugin_name="automation", + ) + registry.register( + app_id="komm_contact", + name="Contact Picker", + icon="👤", + description="Pick a contact", + plugin_name="kommunikation", + ) + + # Simulate on_deactivate for automation + registry.unregister_plugin("automation") + + apps = registry.list_apps() + assert len(apps) == 1 + assert apps[0]["plugin_name"] == "kommunikation" + + def test_cross_plugin_visibility(self): + """Apps registered by plugin A are visible to plugin B via the singleton.""" + # Plugin A registers + registry_a = get_miniapp_registry() + registry_a.register( + app_id="plugin_a_app", + name="A App", + icon="x", + description="", + plugin_name="plugin_a", + ) + + # Plugin B queries + registry_b = get_miniapp_registry() + assert registry_a is registry_b + apps = registry_b.list_apps() + assert any(a["app_id"] == "plugin_a_app" for a in apps)