diff --git a/test/unit/adapters/test_adapter_lifecycle.py b/test/unit/adapters/test_adapter_lifecycle.py new file mode 100644 index 00000000..e2d2bb83 --- /dev/null +++ b/test/unit/adapters/test_adapter_lifecycle.py @@ -0,0 +1,204 @@ +"""Lifecycle tests for the concrete `FrameworkAdapter` subclasses. + +Each adapter delegates `register_hooks` / `unregister_hooks` to a framework +`*Patch` object's `apply()` / `revert()`. These tests substitute a fake Patch +so the adapter's own delegation, idempotent teardown, and metadata accessors +are exercised without importing the real frameworks (which are not installed). +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agent_assembly.adapters.crewai.adapter import CrewAIAdapter +from agent_assembly.adapters.google_adk.adapter import GoogleADKAdapter +from agent_assembly.adapters.langchain.adapter import LangChainAdapter +from agent_assembly.adapters.langgraph.adapter import LangGraphAdapter +from agent_assembly.adapters.mcp.adapter import MCPAdapter +from agent_assembly.adapters.openai_agents.adapter import OpenAIAgentsAdapter +from agent_assembly.adapters.pydantic_ai.adapter import PydanticAIAdapter + + +class _FakePatch: + """Stands in for a framework Patch; records apply/revert.""" + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + self.applied = 0 + self.reverted = 0 + + def apply(self) -> None: + self.applied += 1 + + def revert(self) -> None: + self.reverted += 1 + + +def test_crewai_adapter_metadata() -> None: + adapter = CrewAIAdapter() + assert adapter.get_framework_name() == "crewai" + assert adapter.get_supported_versions() == [">=0.1.0"] + + +def test_crewai_adapter_register_then_unregister(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("agent_assembly.adapters.crewai.adapter.CrewAIPatch", _FakePatch) + adapter = CrewAIAdapter() + + adapter.register_hooks(MagicMock()) + patch_obj = adapter._patch + assert isinstance(patch_obj, _FakePatch) + assert patch_obj.applied == 1 + + adapter.unregister_hooks() + assert patch_obj.reverted == 1 + assert adapter._patch is None + + +def test_crewai_unregister_is_noop_when_never_registered() -> None: + adapter = CrewAIAdapter() + # No exception when no patch is installed. + adapter.unregister_hooks() + assert adapter._patch is None + + +def test_langchain_adapter_metadata_and_process_agent_id() -> None: + adapter = LangChainAdapter(process_agent_id="proc-1") + assert adapter.get_framework_name() == "langchain" + assert adapter.get_supported_versions() == [">=0.1.0"] + assert adapter.process_agent_id == "proc-1" + + adapter.process_agent_id = "proc-2" + assert adapter.process_agent_id == "proc-2" + + +def test_langchain_adapter_register_unregister_and_callback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("agent_assembly.adapters.langchain.adapter.LangChainPatch", _FakePatch) + monkeypatch.setattr( + "agent_assembly.adapters.langchain.adapter.get_active_callback_handler", + lambda: "the-handler", + ) + adapter = LangChainAdapter() + + adapter.register_hooks(MagicMock()) + assert isinstance(adapter._patch, _FakePatch) + assert adapter.get_callback_handler() == "the-handler" + + adapter.unregister_hooks() + assert adapter._patch is None + adapter.unregister_hooks() # idempotent + + +def test_langgraph_adapter_metadata_and_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("agent_assembly.adapters.langgraph.adapter.LangGraphPatch", _FakePatch) + adapter = LangGraphAdapter() + assert adapter.get_framework_name() == "langgraph" + assert adapter.get_supported_versions() == [">=0.1.0"] + assert adapter.process_agent_id is None + + adapter.process_agent_id = "lg-1" + adapter.register_hooks(MagicMock()) + assert isinstance(adapter._patch, _FakePatch) + + adapter.unregister_hooks() + assert adapter._patch is None + + +def test_openai_agents_adapter_metadata_and_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("agent_assembly.adapters.openai_agents.adapter.OpenAIAgentsPatch", _FakePatch) + adapter = OpenAIAgentsAdapter(process_agent_id="oa-1") + assert adapter.get_framework_name() == "openai" + assert adapter.get_supported_versions() == [">=1.0.0"] + assert adapter.process_agent_id == "oa-1" + adapter.process_agent_id = "oa-2" + assert adapter.process_agent_id == "oa-2" + + adapter.register_hooks(MagicMock()) + assert isinstance(adapter._patch, _FakePatch) + adapter.unregister_hooks() + assert adapter._patch is None + + +def test_openai_agents_is_available_false_when_module_absent() -> None: + # openai.agents is not installed in the test env. + assert OpenAIAgentsAdapter().is_available() is False + + +def test_openai_agents_is_available_false_when_find_spec_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def boom(_name: str) -> Any: + raise ValueError("namespace package without __spec__") + + monkeypatch.setattr( + "agent_assembly.adapters.openai_agents.adapter.importlib.util.find_spec", + boom, + ) + assert OpenAIAgentsAdapter().is_available() is False + + +def test_google_adk_adapter_metadata_and_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("agent_assembly.adapters.google_adk.adapter.GoogleADKPatch", _FakePatch) + adapter = GoogleADKAdapter() + assert adapter.get_framework_name() == "google_adk" + assert adapter.get_supported_versions() == [">=1.0.0,<2.0"] + assert adapter.process_agent_id is None + adapter.process_agent_id = "g-1" + assert adapter.process_agent_id == "g-1" + + adapter.register_hooks(MagicMock()) + assert isinstance(adapter._patch, _FakePatch) + adapter.unregister_hooks() + assert adapter._patch is None + + +def test_google_adk_is_available_false_when_module_absent() -> None: + # google.adk is not installed; find_spec on the missing parent namespace + # must be caught and reported as unavailable. + assert GoogleADKAdapter().is_available() is False + + +def test_google_adk_is_available_false_when_find_spec_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def boom(_name: str) -> Any: + raise ModuleNotFoundError("google parent namespace absent") + + monkeypatch.setattr( + "agent_assembly.adapters.google_adk.adapter.importlib.util.find_spec", + boom, + ) + assert GoogleADKAdapter().is_available() is False + + +def test_mcp_adapter_metadata_and_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("agent_assembly.adapters.mcp.adapter.MCPClientPatch", _FakePatch) + adapter = MCPAdapter(process_agent_id="mcp-1") + assert adapter.get_framework_name() == "mcp" + assert adapter.get_supported_versions() == [">=1.0.0"] + assert adapter.process_agent_id == "mcp-1" + adapter.process_agent_id = "mcp-2" + assert adapter.process_agent_id == "mcp-2" + + adapter.register_hooks(MagicMock()) + assert isinstance(adapter._patch, _FakePatch) + adapter.unregister_hooks() + assert adapter._patch is None + + +def test_pydantic_ai_adapter_metadata_and_lifecycle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("agent_assembly.adapters.pydantic_ai.adapter.PydanticAIPatch", _FakePatch) + adapter = PydanticAIAdapter() + assert adapter.get_framework_name() == "pydantic_ai" + assert adapter.get_supported_versions() == [">=0.1.0"] + assert adapter.process_agent_id is None + adapter.process_agent_id = "pa-1" + assert adapter.process_agent_id == "pa-1" + + adapter.register_hooks(MagicMock()) + assert isinstance(adapter._patch, _FakePatch) + adapter.unregister_hooks() + assert adapter._patch is None diff --git a/test/unit/adapters/test_base.py b/test/unit/adapters/test_base.py index 7db10895..fc2342f5 100644 --- a/test/unit/adapters/test_base.py +++ b/test/unit/adapters/test_base.py @@ -107,6 +107,75 @@ def test_register_raises_validation_error_for_invalid_contract() -> None: InvalidRegistrationAdapter().register(object()) +class EmptyVersionsAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "math" + + def get_supported_versions(self) -> list[str]: + return [] + + def register_hooks(self, _interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_validate_registration_rejects_empty_supported_versions() -> None: + with pytest.raises(AdapterValidationError, match="supported versions must not be empty"): + EmptyVersionsAdapter().validate_registration() + + +class BlankVersionRangeAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "math" + + def get_supported_versions(self) -> list[str]: + return [" "] + + def register_hooks(self, _interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_validate_registration_rejects_blank_version_range() -> None: + with pytest.raises(AdapterValidationError, match="version ranges must be non-empty"): + BlankVersionRangeAdapter().validate_registration() + + +class AgentIdAwareAdapter(FrameworkAdapter): + def __init__(self) -> None: + self.process_agent_id: str | None = None + + def get_framework_name(self) -> str: + return "math" + + def get_supported_versions(self) -> list[str]: + return [">=0.1.0"] + + def register_hooks(self, _interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_set_process_agent_id_sets_attribute_when_supported() -> None: + adapter = AgentIdAwareAdapter() + adapter.set_process_agent_id("proc-7") + assert adapter.process_agent_id == "proc-7" + + +def test_set_process_agent_id_is_noop_when_attribute_absent() -> None: + # NonVersionedFrameworkAdapter has no process_agent_id attribute; the + # base no-op must not raise. + adapter = NonVersionedFrameworkAdapter() + adapter.set_process_agent_id("ignored") + assert not hasattr(adapter, "process_agent_id") + + class ValidRegistrationAdapter(FrameworkAdapter): def __init__(self) -> None: self.hooks_registered = False diff --git a/test/unit/adapters/test_registry_edge_cases.py b/test/unit/adapters/test_registry_edge_cases.py new file mode 100644 index 00000000..fd85cb2b --- /dev/null +++ b/test/unit/adapters/test_registry_edge_cases.py @@ -0,0 +1,241 @@ +"""Edge-case and error-path tests for `AdapterRegistry`. + +Complements `test_registry.py` (happy-path discovery/activation) by covering the +branches that only fire on malformed entry points, replacement of an active +adapter, error-state reporting via `list_active`, and an adapter that raises +during activation. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from agent_assembly.adapters import ( + AdapterRegistry, + FrameworkAdapter, + GovernanceInterceptor, +) + + +class _EmptyEntryPoints(list[object]): + def select(self, *, group: str) -> list[object]: + del group + return [] + + +class _DummyAdapter(FrameworkAdapter): + def __init__(self, framework_name: str, *, version: str | None = "1.0.0") -> None: + self._framework_name = framework_name + self._version = version + self.unregister_calls = 0 + + def get_framework_name(self) -> str: + return self._framework_name + + def get_supported_versions(self) -> list[str]: + return [">=0.1.0"] + + def get_active_version(self) -> str | None: + return self._version + + def register_hooks(self, _interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + self.unregister_calls += 1 + + +def _no_entry_points(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + "agent_assembly.adapters.registry.metadata.entry_points", + lambda: _EmptyEntryPoints(), + ) + + +def test_register_replacing_active_adapter_drops_stale_active_entry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + first = _DummyAdapter("dup_framework") + registry._registered = {"dup_framework": first} + _no_entry_points(monkeypatch) + monkeypatch.setattr( + "agent_assembly.adapters.base.importlib.import_module", + lambda _module_name: SimpleNamespace(__version__="1.0.0"), + ) + + registry.auto_detect() + assert registry._active["dup_framework"] is first + + # Registering a *different* instance under the same name evicts the active one. + replacement = _DummyAdapter("dup_framework") + registry.register(replacement) + + assert "dup_framework" not in registry._active + assert registry._registered["dup_framework"] is replacement + + +def test_unregister_calls_unregister_hooks_on_active_adapter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + adapter = _DummyAdapter("teardown_framework") + registry._registered = {"teardown_framework": adapter} + _no_entry_points(monkeypatch) + monkeypatch.setattr( + "agent_assembly.adapters.base.importlib.import_module", + lambda _module_name: SimpleNamespace(__version__="1.0.0"), + ) + + registry.auto_detect() + registry.unregister("teardown_framework") + + assert adapter.unregister_calls == 1 + + +def test_list_active_coerces_non_int_hook_count_to_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + adapter = _DummyAdapter("hooky_framework") + # A non-int hook count must be coerced to 0 in the AdapterInfo. + adapter._hooks_registered_count = "not-an-int" # type: ignore[attr-defined] + registry._active = {"hooky_framework": adapter} + + infos = registry.list_active() + + assert len(infos) == 1 + assert infos[0].hooks_registered == 0 + assert infos[0].status == "active" + + +def test_list_active_includes_error_only_adapters(monkeypatch: pytest.MonkeyPatch) -> None: + registry = AdapterRegistry() + registry._active = {} + registry._errors = {"broken_framework": "load failed"} + + infos = registry.list_active() + + assert len(infos) == 1 + assert infos[0].name == "broken_framework" + assert infos[0].status == "error" + assert infos[0].hooks_registered == 0 + + +def test_get_available_adapters_by_priority_orders_and_filters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + # langchain has priority 0 (first), mcp has 99 (last); an unknown framework + # falls into the default priority slot between them. + langchain = _DummyAdapter("langchain") + mcp = _DummyAdapter("mcp") + unavailable = _DummyAdapter("offline_framework") + registry._registered = { + "mcp": mcp, + "offline_framework": unavailable, + "langchain": langchain, + } + _no_entry_points(monkeypatch) + + def fake_import_module(module_name: str) -> Any: + if module_name in ("langchain", "mcp"): + return SimpleNamespace(__version__="1.0.0") + raise ImportError + + monkeypatch.setattr("agent_assembly.adapters.base.importlib.import_module", fake_import_module) + + available = registry.get_available_adapters_by_priority() + + names = [a.get_framework_name() for a in available] + assert names == ["langchain", "mcp"] + assert "offline_framework" not in names + + +class _FakeEntryPoint: + def __init__(self, name: str, loaded: object) -> None: + self.name = name + self._loaded = loaded + + def load(self) -> object: + return self._loaded + + +def _patch_entry_points(monkeypatch: pytest.MonkeyPatch, eps: list[_FakeEntryPoint]) -> None: + class _FakeEntryPoints(list[_FakeEntryPoint]): + def select(self, *, group: str) -> list[_FakeEntryPoint]: + assert group == "agent_assembly.adapters" + return list(self) + + monkeypatch.setattr( + "agent_assembly.adapters.registry.metadata.entry_points", + lambda: _FakeEntryPoints(eps), + ) + + +def test_discover_records_error_when_entry_point_is_not_a_class( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + _patch_entry_points(monkeypatch, [_FakeEntryPoint("not-a-class", object())]) + + discovered = registry._discover_entry_point_adapters() + + assert discovered == [] + assert registry._errors["not-a-class"] == "Entry point did not load a class." + + +def test_discover_records_error_when_class_is_not_a_framework_adapter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class NotAnAdapter: ... + + registry = AdapterRegistry() + _patch_entry_points(monkeypatch, [_FakeEntryPoint("wrong-class", NotAnAdapter)]) + + discovered = registry._discover_entry_point_adapters() + + assert discovered == [] + assert registry._errors["wrong-class"] == "Entry point class is not a FrameworkAdapter." + + +class _RaisingAdapter(FrameworkAdapter): + def get_framework_name(self) -> str: + return "raising_framework" + + def get_supported_versions(self) -> list[str]: + return [">=0.1.0"] + + def register(self, _interceptor: GovernanceInterceptor) -> None: + raise RuntimeError("hook wiring failed") + + def register_hooks(self, _interceptor: GovernanceInterceptor) -> None: + return None + + def unregister_hooks(self) -> None: + return None + + +def test_auto_detect_records_error_when_adapter_register_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + registry = AdapterRegistry() + adapter = _RaisingAdapter() + registry._registered = {"raising_framework": adapter} + _no_entry_points(monkeypatch) + + def fake_import_module(module_name: str) -> Any: + if module_name == "raising_framework": + return SimpleNamespace(__version__="1.0.0") + raise ImportError + + monkeypatch.setattr("agent_assembly.adapters.base.importlib.import_module", fake_import_module) + + activated = registry.auto_detect() + + assert activated == [] + assert registry._errors["raising_framework"] == "hook wiring failed" + assert "raising_framework" not in registry._active diff --git a/test/unit/client/test_emitter.py b/test/unit/client/test_emitter.py new file mode 100644 index 00000000..c79ac1cd --- /dev/null +++ b/test/unit/client/test_emitter.py @@ -0,0 +1,97 @@ +"""Unit tests for the fire-and-forget `EdgeEmitter` helper. + +`EdgeEmitter.emit` schedules `GatewayClient.report_edge` on a daemon thread so +the caller is never blocked and a gateway failure never propagates. The tests +join the spawned thread (via a stub `GatewayClient`) to make the assertions +deterministic rather than racing the daemon. +""" + +from __future__ import annotations + +import threading +from typing import Any +from unittest.mock import MagicMock + +from agent_assembly.client.emitter import EdgeEmitter + + +class _RecordingClient: + """Stub GatewayClient that records report_edge calls and signals an Event.""" + + def __init__(self, *, raise_exc: Exception | None = None) -> None: + self.calls: list[tuple[str, str, str, dict[str, Any] | None]] = [] + self.done = threading.Event() + self._raise_exc = raise_exc + + def report_edge( + self, + source_agent_id: str, + target_agent_id: str, + edge_type: str, + metadata: dict[str, Any] | None = None, + ) -> dict[str, Any]: + self.calls.append((source_agent_id, target_agent_id, edge_type, metadata)) + try: + if self._raise_exc is not None: + raise self._raise_exc + return {"edge_id": "e-1"} + finally: + self.done.set() + + +def test_emit_reports_edge_with_all_arguments() -> None: + client = _RecordingClient() + emitter = EdgeEmitter(client) # type: ignore[arg-type] + + emitter.emit("src", "dst", "delegates_to", {"weight": 3}) + + assert client.done.wait(timeout=2.0) + assert client.calls == [("src", "dst", "delegates_to", {"weight": 3})] + + +def test_emit_passes_none_metadata_through() -> None: + client = _RecordingClient() + emitter = EdgeEmitter(client) # type: ignore[arg-type] + + emitter.emit("src", "dst", "messages") + + assert client.done.wait(timeout=2.0) + assert client.calls[0] == ("src", "dst", "messages", None) + + +def test_emit_swallows_report_edge_exception() -> None: + barrier = threading.Event() + + class _RaisingClient(_RecordingClient): + def report_edge(self, *_args: Any, **_kwargs: Any) -> dict[str, Any]: + try: + raise RuntimeError("gateway down") + finally: + barrier.set() + + client = _RaisingClient() + emitter = EdgeEmitter(client) # type: ignore[arg-type] + + # No exception should surface to the caller even though report_edge raises; + # the daemon thread catches and logs it. The barrier confirms the failing + # call actually ran (the except branch is exercised) without a leaked error. + emitter.emit("src", "dst", "messages") + + assert barrier.wait(timeout=2.0) + + +def test_emit_uses_a_daemon_thread(monkeypatch: Any) -> None: + captured: dict[str, Any] = {} + real_thread = threading.Thread + + def fake_thread(*args: Any, **kwargs: Any) -> threading.Thread: + captured["daemon"] = kwargs.get("daemon") + return real_thread(*args, **kwargs) + + monkeypatch.setattr("agent_assembly.client.emitter.threading.Thread", fake_thread) + client = MagicMock() + emitter = EdgeEmitter(client) + + emitter.emit("src", "dst", "messages") + + assert captured["daemon"] is True diff --git a/test/unit/client/test_gateway_endpoints.py b/test/unit/client/test_gateway_endpoints.py new file mode 100644 index 00000000..9902162e --- /dev/null +++ b/test/unit/client/test_gateway_endpoints.py @@ -0,0 +1,115 @@ +"""Unit tests for the remaining `GatewayClient` HTTP endpoints. + +Covers `check_policy_compliance`, `report_edge`, the API-key auth header, and +the `register_agent` failure branch — the success/error paths not already +exercised by the topology and dispatch_tool suites. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from agent_assembly.client.gateway import GatewayClient +from agent_assembly.exceptions import GatewayError + + +def _ok(json_body: dict[str, Any]) -> MagicMock: + resp = MagicMock() + resp.json.return_value = json_body + resp.raise_for_status = MagicMock() + return resp + + +def _raising(exc: Exception) -> MagicMock: + resp = MagicMock() + resp.raise_for_status = MagicMock(side_effect=exc) + return resp + + +def _patch_post(client: GatewayClient, mock_post: MagicMock) -> Any: + return patch.object( + type(client), + "client", + new_callable=lambda: property(lambda _self: MagicMock(post=mock_post)), + ) + + +def test_http_client_sets_bearer_auth_header_when_api_key_present() -> None: + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="sekret") + try: + assert client.client.headers["Authorization"] == "Bearer sekret" + finally: + client.close() + + +def test_http_client_omits_auth_header_when_no_api_key() -> None: + client = GatewayClient(gateway_url="http://gw.test", agent_id="a") + try: + assert "Authorization" not in client.client.headers + finally: + client.close() + + +@pytest.mark.asyncio +async def test_register_agent_raises_gateway_error_on_http_error() -> None: + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="k") + mock_post = MagicMock(return_value=_raising(httpx.ConnectError("refused"))) + with _patch_post(client, mock_post), pytest.raises(GatewayError, match="Failed to register agent"): + await client.register_agent() + + +@pytest.mark.asyncio +async def test_check_policy_compliance_returns_decision_on_success() -> None: + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="k") + mock_post = MagicMock(return_value=_ok({"allowed": True, "reason": "ok"})) + with _patch_post(client, mock_post): + result = await client.check_policy_compliance("send_email") + + assert result == {"allowed": True, "reason": "ok"} + _, kwargs = mock_post.call_args + assert kwargs["json"] == {"action": "send_email"} + + +@pytest.mark.asyncio +async def test_check_policy_compliance_raises_gateway_error_on_http_error() -> None: + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="k") + mock_post = MagicMock(return_value=_raising(httpx.ReadTimeout("slow"))) + with _patch_post(client, mock_post), pytest.raises(GatewayError, match="Failed to check policy compliance"): + await client.check_policy_compliance("send_email") + + +def test_report_edge_serializes_metadata_and_returns_edge_id() -> None: + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="k") + mock_post = MagicMock(return_value=_ok({"edge_id": "e-9"})) + with _patch_post(client, mock_post): + result = client.report_edge("src", "dst", "delegates_to", {"weight": 2}) + + assert result == {"edge_id": "e-9"} + _, kwargs = mock_post.call_args + body = kwargs["json"] + assert body["source_agent_id"] == "src" + assert body["target_agent_id"] == "dst" + assert body["edge_type"] == "delegates_to" + # metadata is JSON-encoded into metadata_json. + assert body["metadata_json"] == '{"weight": 2}' + + +def test_report_edge_omits_metadata_json_when_metadata_none() -> None: + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="k") + mock_post = MagicMock(return_value=_ok({"edge_id": "e-1"})) + with _patch_post(client, mock_post): + client.report_edge("src", "dst", "messages") + + _, kwargs = mock_post.call_args + assert "metadata_json" not in kwargs["json"] + + +def test_report_edge_raises_gateway_error_on_http_error() -> None: + client = GatewayClient(gateway_url="http://gw.test", agent_id="a", api_key="k") + mock_post = MagicMock(return_value=_raising(httpx.ConnectError("down"))) + with _patch_post(client, mock_post), pytest.raises(GatewayError, match="Failed to report edge"): + client.report_edge("src", "dst", "messages") diff --git a/test/unit/core/test_core_lazy_exports.py b/test/unit/core/test_core_lazy_exports.py new file mode 100644 index 00000000..ca697cbd --- /dev/null +++ b/test/unit/core/test_core_lazy_exports.py @@ -0,0 +1,35 @@ +"""Unit tests for the lazy `__getattr__` re-exports on `agent_assembly.core`. + +`agent_assembly.core` resolves its public symbols lazily (PEP 562) so importing +the package does not eagerly pull in `core.assembly`'s dependency surface. These +tests exercise each lazy branch and the unknown-attribute guard. +""" + +from __future__ import annotations + +import pytest + +import agent_assembly.core as core + + +def test_lazy_getattr_resolves_init_assembly_and_assembly_context() -> None: + from agent_assembly.core.assembly import AssemblyContext, init_assembly + + assert core.init_assembly is init_assembly + assert core.AssemblyContext is AssemblyContext + + +def test_lazy_getattr_resolves_lineage_registry() -> None: + from agent_assembly.core.lineage import LineageRegistry + + assert core.LineageRegistry is LineageRegistry + + +def test_lazy_getattr_raises_attribute_error_for_unknown_symbol() -> None: + missing_name = "does_not_exist" + with pytest.raises(AttributeError, match="has no attribute 'does_not_exist'"): + getattr(core, missing_name) # exercises the __getattr__ unknown-symbol guard + + +def test_all_lists_the_public_lazy_exports() -> None: + assert set(core.__all__) == {"init_assembly", "AssemblyContext", "LineageRegistry"} diff --git a/test/unit/test_models_agent.py b/test/unit/test_models_agent.py new file mode 100644 index 00000000..535dd8eb --- /dev/null +++ b/test/unit/test_models_agent.py @@ -0,0 +1,101 @@ +"""Unit tests for the `agent_assembly.models.agent` Pydantic models. + +Covers the public data models re-exported from `agent_assembly.models`: +`AgentConfig`, `AgentState`, and `PolicyEvaluation`. +""" + +from __future__ import annotations + +from datetime import datetime + +import pytest +from pydantic import ValidationError + +from agent_assembly.models import AgentConfig, AgentState, PolicyEvaluation + + +def test_models_package_reexports_public_symbols() -> None: + import agent_assembly.models as models + + assert set(models.__all__) == {"AgentConfig", "AgentState", "PolicyEvaluation"} + + +def test_agent_config_defaults_applied_for_optional_fields() -> None: + config = AgentConfig(agent_id="a-1", name="Researcher") + + assert config.agent_id == "a-1" + assert config.name == "Researcher" + assert config.description is None + assert config.version == "0.1.0" + assert isinstance(config.created_at, datetime) + assert isinstance(config.updated_at, datetime) + + +def test_agent_config_accepts_explicit_overrides() -> None: + stamp = datetime(2026, 1, 2, 3, 4, 5) + config = AgentConfig( + agent_id="a-2", + name="Planner", + description="plans things", + version="2.5.0", + created_at=stamp, + updated_at=stamp, + ) + + assert config.description == "plans things" + assert config.version == "2.5.0" + assert config.created_at == stamp + + +def test_agent_config_requires_agent_id_and_name() -> None: + with pytest.raises(ValidationError): + AgentConfig(name="missing-id") # type: ignore[call-arg] + + +def test_agent_state_defaults_to_idle_with_empty_metadata() -> None: + state = AgentState(agent_id="a-1") + + assert state.status == "idle" + assert state.last_activity is None + assert state.metadata == {} + + +def test_agent_state_carries_metadata_and_activity() -> None: + stamp = datetime(2026, 6, 18, 12, 0, 0) + state = AgentState( + agent_id="a-1", + status="running", + last_activity=stamp, + metadata={"region": "us-east"}, + ) + + assert state.status == "running" + assert state.last_activity == stamp + assert state.metadata["region"] == "us-east" + + +def test_policy_evaluation_allowed_decision() -> None: + evaluation = PolicyEvaluation(action="send_email", allowed=True) + + assert evaluation.action == "send_email" + assert evaluation.allowed is True + assert evaluation.reason is None + assert evaluation.policy_id is None + + +def test_policy_evaluation_denied_decision_records_reason_and_policy() -> None: + evaluation = PolicyEvaluation( + action="delete_db", + allowed=False, + reason="destructive action blocked", + policy_id="pol-7", + ) + + assert evaluation.allowed is False + assert evaluation.reason == "destructive action blocked" + assert evaluation.policy_id == "pol-7" + + +def test_policy_evaluation_requires_action_and_allowed() -> None: + with pytest.raises(ValidationError): + PolicyEvaluation(action="x") # type: ignore[call-arg] diff --git a/test/unit/test_runtime.py b/test/unit/test_runtime.py index 3e0c8759..6062e6da 100644 --- a/test/unit/test_runtime.py +++ b/test/unit/test_runtime.py @@ -87,3 +87,72 @@ def test_init_assembly_idempotent_when_already_running(monkeypatch: pytest.Monke find_spy.assert_not_called() start_spy.assert_not_called() + + +def test_is_running_true_when_listener_accepts(monkeypatch: pytest.MonkeyPatch) -> None: + """A successful connect on host:port reports the sidecar as up.""" + + class _FakeConn: + def __enter__(self) -> _FakeConn: + return self + + def __exit__(self, *_args: object) -> None: + return None + + monkeypatch.setattr( + runtime.socket, + "create_connection", + lambda _address, timeout: _FakeConn(), # noqa: ARG005 — signature-matching stub + ) + + assert runtime.is_running(port=7878) is True + + +def test_is_running_false_on_socket_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Any OSError (refused / timeout / unreachable) means no sidecar.""" + + def refuse(address: object, timeout: object) -> object: + raise ConnectionRefusedError("nothing listening") + + monkeypatch.setattr(runtime.socket, "create_connection", refuse) + + assert runtime.is_running(port=7878) is False + + +def test_start_runtime_spawns_detached_serve_process( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """start_runtime opens the log file in the chosen dir and spawns the + detached ``aasm serve --port`` subprocess with stdout/stderr redirected.""" + captured: dict[str, object] = {} + + def fake_popen(cmd: list[str], **kwargs: object) -> str: + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return "popen-handle" + + monkeypatch.setattr(runtime.subprocess, "Popen", fake_popen) + + handle = runtime.start_runtime(Path("/bin/aasm"), port=9001, log_dir=tmp_path) + + assert handle == "popen-handle" + assert captured["cmd"] == ["/bin/aasm", "serve", "--port", "9001"] + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["start_new_session"] is True + # The runtime log file is created in the supplied log_dir. + assert (tmp_path / runtime.RUNTIME_LOG_FILENAME).exists() + + +def test_init_assembly_spawns_when_not_running(monkeypatch: pytest.MonkeyPatch) -> None: + """When the sidecar is down and a binary exists, init_assembly resolves the + binary and starts the runtime exactly once.""" + binary = Path("/usr/local/bin/aasm") + start_spy = MagicMock() + monkeypatch.setattr(runtime, "is_running", lambda *_a, **_kw: False) + monkeypatch.setattr(runtime, "find_aasm_binary", lambda: binary) + monkeypatch.setattr(runtime, "start_runtime", start_spy) + + runtime.init_assembly(agent_id="ignored-at-this-layer", port=7878) + + start_spy.assert_called_once_with(binary, port=7878) diff --git a/test/unit/test_types_audit_event.py b/test/unit/test_types_audit_event.py index 23fd7eb9..1c9f457c 100644 --- a/test/unit/test_types_audit_event.py +++ b/test/unit/test_types_audit_event.py @@ -96,3 +96,25 @@ def test_top_level_imports_resolve_to_types_module() -> None: assert AuditEvent is TypesAuditEvent assert CallStackNode is TypesCallStackNode + + +def test_to_wire_bytes_raises_helpful_import_error_without_native_core() -> None: + """In pure-Python mode the native `_core` extension is absent, so the + encode path raises ImportError with a maturin/reinstall hint.""" + event = AuditEvent(event_id="e", agent_id="a", action_type="llm_call", decision="allow") + with pytest.raises(ImportError) as exc_info: + event.to_wire_bytes() + + message = str(exc_info.value) + assert "to_wire_bytes()" in message + assert "maturin develop" in message + + +def test_from_wire_bytes_raises_helpful_import_error_without_native_core() -> None: + """The decode path raises the same kind of guidance when `_core` is absent.""" + with pytest.raises(ImportError) as exc_info: + AuditEvent.from_wire_bytes(b"\x00\x01") + + message = str(exc_info.value) + assert "from_wire_bytes()" in message + assert "native" in message