Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 25 additions & 33 deletions amplifier_app_cli/lib/bundle_loader/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down Expand Up @@ -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

Expand Down
146 changes: 146 additions & 0 deletions tests/lib/bundle_loader/test_discovery.py
Original file line number Diff line number Diff line change
@@ -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"