diff --git a/amplifier_app_cli/lib/bundle_loader/discovery.py b/amplifier_app_cli/lib/bundle_loader/discovery.py index 2081f4f..12ccdba 100644 --- a/amplifier_app_cli/lib/bundle_loader/discovery.py +++ b/amplifier_app_cli/lib/bundle_loader/discovery.py @@ -490,7 +490,6 @@ def get_bundle_categories(self) -> dict[str, list[dict[str, str]]]: Dict with categories: well_known, user_added, dependencies, nested_bundles Each category contains list of {name, uri, ...} dicts. """ - import json categories: dict[str, list[dict[str, str]]] = { "well_known": [], @@ -523,40 +522,33 @@ def get_bundle_categories(self) -> dict[str, list[dict[str, str]]]: } ) - # Read persisted registry for dependencies and nested bundles - registry_path = Path.home() / ".amplifier" / "registry.json" - if registry_path.exists(): - try: - with open(registry_path, encoding="utf-8") as f: - data = json.load(f) + # Read from in-memory registry for dependencies and nested bundles. + # This ensures URIs reflect current overrides (e.g., local path overrides + # from settings.local.yaml) rather than stale persisted data. + well_known_names = set(WELL_KNOWN_BUNDLES.keys()) + user_added_names = set(added_bundles.keys()) + + all_states = self._registry.get_state() + if isinstance(all_states, dict): + for name, state in all_states.items(): + # Skip if already categorized + if name in well_known_names or name in user_added_names: + continue - well_known_names = set(WELL_KNOWN_BUNDLES.keys()) - user_added_names = set(added_bundles.keys()) - - for name, bundle_data in data.get("bundles", {}).items(): - # Skip if already categorized - if name in well_known_names or name in user_added_names: - continue - - entry = { - "name": name, - "uri": bundle_data.get("uri", ""), - } - - if not bundle_data.get("is_root", True): - # Nested bundle (behavior, provider, etc.) - entry["root"] = bundle_data.get("root_name", "") - categories["nested_bundles"].append(entry) - elif not bundle_data.get("explicitly_requested", False): - # Dependency (loaded transitively) - included_by = bundle_data.get("included_by", []) - entry["included_by"] = ( - ", ".join(included_by) if included_by else "" - ) - categories["dependencies"].append(entry) + entry = { + "name": name, + "uri": state.uri, + } - except Exception as e: - logger.debug(f"Could not read persisted registry: {e}") + if not state.is_root: + # Nested bundle (behavior, provider, etc.) + entry["root"] = state.root_name or "" + categories["nested_bundles"].append(entry) + elif not state.explicitly_requested: + # Dependency (loaded transitively) + included_by = state.included_by or [] + entry["included_by"] = ", ".join(included_by) if included_by else "" + categories["dependencies"].append(entry) return categories diff --git a/tests/lib/bundle_loader/test_discovery.py b/tests/lib/bundle_loader/test_discovery.py new file mode 100644 index 0000000..42f0b33 --- /dev/null +++ b/tests/lib/bundle_loader/test_discovery.py @@ -0,0 +1,146 @@ +"""Tests for AppBundleDiscovery. + +Regression tests for https://github.com/microsoft-amplifier/amplifier-support/issues/62 +""" + +import json +from datetime import datetime +from unittest.mock import patch + + +from amplifier_foundation.registry import BundleRegistry, BundleState + + +class TestGetBundleCategoriesNestedURIs: + """Verify get_bundle_categories reads nested bundle URIs from in-memory registry.""" + + @patch( + "amplifier_app_cli.lib.bundle_loader.discovery.AppBundleDiscovery._load_user_registry" + ) + @patch( + "amplifier_app_cli.lib.bundle_loader.discovery.AppBundleDiscovery._register_well_known_bundles" + ) + def test_nested_bundles_reflect_in_memory_state( + self, mock_well_known, mock_user_registry, tmp_path + ): + """Nested bundle URIs come from in-memory registry, not disk.""" + from amplifier_app_cli.lib.bundle_loader.discovery import AppBundleDiscovery + + registry = BundleRegistry(home=tmp_path / "home") + discovery = AppBundleDiscovery(search_paths=[], registry=registry) + + # Inject a nested bundle entry into the in-memory registry + registry._registry["behavior-sessions"] = BundleState( + uri="file:///local/path/behaviors/sessions.yaml", + name="behavior-sessions", + is_root=False, + root_name="foundation", + loaded_at=datetime.now(), + ) + + with patch("amplifier_app_cli.lib.settings.AppSettings") as mock_settings_cls: + mock_settings_cls.return_value.get_added_bundles.return_value = {} + categories = discovery.get_bundle_categories() + + nested = categories["nested_bundles"] + nested_names = [b["name"] for b in nested] + assert "behavior-sessions" in nested_names + + entry = next(b for b in nested if b["name"] == "behavior-sessions") + assert entry["uri"] == "file:///local/path/behaviors/sessions.yaml" + assert entry["root"] == "foundation" + + @patch( + "amplifier_app_cli.lib.bundle_loader.discovery.AppBundleDiscovery._load_user_registry" + ) + @patch( + "amplifier_app_cli.lib.bundle_loader.discovery.AppBundleDiscovery._register_well_known_bundles" + ) + def test_stale_persisted_uri_not_used_for_nested_bundles( + self, mock_well_known, mock_user_registry, tmp_path + ): + """In-memory URI takes precedence over stale persisted data on disk.""" + home = tmp_path / "home" + + # Write stale registry.json with old git+ URI + home.mkdir(parents=True) + stale_data = { + "version": 1, + "bundles": { + "behavior-agents": { + "uri": "git+https://github.com/example/old@main#subdirectory=behaviors/agents", + "name": "behavior-agents", + "version": "1.0.0", + "loaded_at": None, + "checked_at": None, + "local_path": None, + "is_root": False, + "root_name": "foundation", + "explicitly_requested": False, + "app_bundle": False, + } + }, + } + (home / "registry.json").write_text(json.dumps(stale_data, indent=2)) + + from amplifier_app_cli.lib.bundle_loader.discovery import AppBundleDiscovery + + # Registry loads persisted state, then we update in-memory + registry = BundleRegistry(home=home) + discovery = AppBundleDiscovery(search_paths=[], registry=registry) + + # Simulate runtime override: update the URI in-memory + registry._registry[ + "behavior-agents" + ].uri = "file:///local/override/behaviors/agents.yaml" + + with patch( + "amplifier_app_cli.lib.settings.AppSettings" + ) as mock_settings_cls: + mock_settings_cls.return_value.get_added_bundles.return_value = {} + categories = discovery.get_bundle_categories() + + nested = categories["nested_bundles"] + entry = next(b for b in nested if b["name"] == "behavior-agents") + + # Must reflect the in-memory URI, not the stale git+ from disk + assert entry["uri"] == "file:///local/override/behaviors/agents.yaml" + assert "git+" not in entry["uri"] + + @patch( + "amplifier_app_cli.lib.bundle_loader.discovery.AppBundleDiscovery._load_user_registry" + ) + @patch( + "amplifier_app_cli.lib.bundle_loader.discovery.AppBundleDiscovery._register_well_known_bundles" + ) + def test_dependencies_reflect_in_memory_state( + self, mock_well_known, mock_user_registry, tmp_path + ): + """Dependency URIs also come from in-memory registry.""" + from amplifier_app_cli.lib.bundle_loader.discovery import AppBundleDiscovery + + registry = BundleRegistry(home=tmp_path / "home") + discovery = AppBundleDiscovery(search_paths=[], registry=registry) + + # Inject a dependency entry (is_root=True, not explicitly requested) + registry._registry["lsp-python"] = BundleState( + uri="git+https://github.com/microsoft/amplifier-bundle-lsp-python@main", + name="lsp-python", + is_root=True, + explicitly_requested=False, + included_by=["foundation"], + loaded_at=datetime.now(), + ) + + with patch( + "amplifier_app_cli.lib.settings.AppSettings" + ) as mock_settings_cls: + mock_settings_cls.return_value.get_added_bundles.return_value = {} + categories = discovery.get_bundle_categories() + + deps = categories["dependencies"] + dep_names = [b["name"] for b in deps] + assert "lsp-python" in dep_names + + entry = next(b for b in deps if b["name"] == "lsp-python") + assert entry["included_by"] == "foundation"