molecule-ai-workspace-runtime/tests/test_adapter_loader.py
Hongming Wang 4aa0d9f110 fix(adapter-loader): fall back to any BaseAdapter subclass
ADAPTER_MODULE resolution required the imported module to export a
class literally named `Adapter`. The claude-code, langgraph, and
openclaw adapter-template repos (3 of 4 currently in production) don't
ship that alias — they export ClaudeCodeAdapter / LangGraphAdapter /
OpenClawAdapter directly. Only hermes has the `Adapter = HermesAdapter`
shim at the bottom of adapter.py.

Consequence in prod: every fresh claude-code / langgraph / openclaw
workspace crashed at runtime startup with
"module 'adapter' has no attribute 'Adapter'", even with a2a-sdk
correctly pinned <1.0. Provisioning looked successful from CP's side
(EC2 ran) but the agent never registered because the process never
reached A2A bootstrap.

Fix: if `Adapter` is absent from the imported module, scan the module
for any attribute that is a proper BaseAdapter subclass (excluding
BaseAdapter itself — regression guard in tests). The explicit alias
remains the preferred contract; this is purely additive tolerance.

Bump to 0.1.4 and publish to PyPI via the existing v* tag trigger.

6 new tests cover: explicit alias, subclass-fallback, non-adapter-noise
ignored, empty module → error, missing module → error, re-exported
BaseAdapter → not selected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:59:12 -07:00

109 lines
3.6 KiB
Python

"""Tests for get_adapter() ADAPTER_MODULE resolution.
The claude-code, langgraph, and openclaw template adapters in prod on
2026-04-20 did NOT add the `Adapter = XxxAdapter` alias that the loader
originally required. The runtime now falls back to scanning the module
for any BaseAdapter subclass when the alias is missing. These tests pin
that behaviour so a future "cleanup" doesn't re-introduce the footgun.
"""
import sys
import types
import pytest
from molecule_runtime.adapters import get_adapter
from molecule_runtime.adapters.base import BaseAdapter
class _FakeAdapterCC(BaseAdapter):
"""Shaped like the real ClaudeCodeAdapter (class name != 'Adapter')."""
@staticmethod
def name() -> str:
return "claude-code"
@staticmethod
def display_name() -> str:
return "Claude Code"
@staticmethod
def description() -> str:
return "Test adapter"
@staticmethod
def get_config_schema() -> dict:
return {}
async def setup(self, config):
return None
async def build_agent_executor(self, config):
return None
def _install_module(monkeypatch, name, **attrs):
"""Stage a fake module in sys.modules so importlib.import_module finds it."""
mod = types.ModuleType(name)
for k, v in attrs.items():
setattr(mod, k, v)
monkeypatch.setitem(sys.modules, name, mod)
def test_loader_picks_up_explicit_alias(monkeypatch):
_install_module(monkeypatch, "adapter_with_alias", Adapter=_FakeAdapterCC)
monkeypatch.setenv("ADAPTER_MODULE", "adapter_with_alias")
cls = get_adapter("claude-code")
assert cls is _FakeAdapterCC
def test_loader_falls_back_to_any_baseadapter_subclass(monkeypatch):
# Only the canonical-name export is missing. The ClaudeCodeAdapter
# class alone should still resolve — that's the whole bug fix.
_install_module(
monkeypatch, "adapter_without_alias", ClaudeCodeAdapter=_FakeAdapterCC
)
monkeypatch.setenv("ADAPTER_MODULE", "adapter_without_alias")
cls = get_adapter("claude-code")
assert cls is _FakeAdapterCC
def test_loader_ignores_non_adapter_attrs(monkeypatch):
# Module has noise (constants, functions, unrelated classes); the
# loader must skip past them and still find the real adapter.
class NotAnAdapter: # intentionally NOT a BaseAdapter
pass
_install_module(
monkeypatch,
"adapter_with_noise",
SOME_CONST=42,
helper=lambda: None,
Junk=NotAnAdapter,
MyClaudeAdapter=_FakeAdapterCC,
)
monkeypatch.setenv("ADAPTER_MODULE", "adapter_with_noise")
cls = get_adapter("claude-code")
assert cls is _FakeAdapterCC
def test_loader_errors_when_module_has_no_subclass(monkeypatch):
_install_module(monkeypatch, "adapter_empty", SOMETHING=42)
monkeypatch.setenv("ADAPTER_MODULE", "adapter_empty")
with pytest.raises(KeyError, match="no BaseAdapter subclass"):
get_adapter("claude-code")
def test_loader_errors_when_module_missing(monkeypatch):
monkeypatch.setenv("ADAPTER_MODULE", "definitely_not_installed_xyz")
with pytest.raises(KeyError, match="could not be imported"):
get_adapter("claude-code")
def test_loader_does_not_return_baseadapter_itself(monkeypatch):
# Don't accidentally return BaseAdapter when it's re-exported by the
# module — regression guard for the fallback scan.
_install_module(monkeypatch, "adapter_reexports_base", BaseAdapter=BaseAdapter)
monkeypatch.setenv("ADAPTER_MODULE", "adapter_reexports_base")
with pytest.raises(KeyError, match="no BaseAdapter subclass"):
get_adapter("claude-code")