diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c2895da --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install package + test deps + run: | + pip install -e . + pip install pytest + + - name: Run import smoke tests + # Critical: these tests run in an environment with NO top-level + # `adapters/` package on sys.path. They catch the regression that + # broke every modular workspace template repo before the absolute- + # import fix. Do not weaken — the failure mode (silent fallthrough + # in get_adapter → "Unknown runtime") is hard to debug at runtime. + run: pytest tests/ -v diff --git a/molecule_runtime/a2a_executor.py b/molecule_runtime/a2a_executor.py index ebe4008..444a84a 100644 --- a/molecule_runtime/a2a_executor.py +++ b/molecule_runtime/a2a_executor.py @@ -41,7 +41,7 @@ from a2a.server.events import EventQueue from a2a.server.tasks import TaskUpdater from a2a.types import Part, TextPart from a2a.utils import new_agent_text_message -from adapters.shared_runtime import ( +from molecule_runtime.adapters.shared_runtime import ( extract_history as _extract_history, extract_message_text, brief_task, diff --git a/molecule_runtime/builtin_tools/temporal_workflow.py b/molecule_runtime/builtin_tools/temporal_workflow.py index bb5c049..6b1c400 100644 --- a/molecule_runtime/builtin_tools/temporal_workflow.py +++ b/molecule_runtime/builtin_tools/temporal_workflow.py @@ -414,7 +414,7 @@ class TemporalWorkflowWrapper: # Build serialisable AgentTaskInput try: - from adapters.shared_runtime import ( + from molecule_runtime.adapters.shared_runtime import ( extract_history as _extract_history, extract_message_text, ) diff --git a/molecule_runtime/coordinator.py b/molecule_runtime/coordinator.py index 99e9adb..5c4a3d1 100644 --- a/molecule_runtime/coordinator.py +++ b/molecule_runtime/coordinator.py @@ -17,7 +17,7 @@ import os import httpx from langchain_core.tools import tool -from adapters.shared_runtime import build_peer_section +from molecule_runtime.adapters.shared_runtime import build_peer_section from policies.routing import build_team_routing_payload logger = logging.getLogger(__name__) diff --git a/molecule_runtime/main.py b/molecule_runtime/main.py index 4ff54e7..dbc8c61 100644 --- a/molecule_runtime/main.py +++ b/molecule_runtime/main.py @@ -24,7 +24,7 @@ from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore from a2a.types import AgentCard, AgentCapabilities, AgentSkill -from adapters import get_adapter, AdapterConfig +from molecule_runtime.adapters import get_adapter, AdapterConfig from config import load_config from heartbeat import HeartbeatLoop from preflight import run_preflight, render_preflight_report diff --git a/molecule_runtime/prompt.py b/molecule_runtime/prompt.py index a9876d4..f488420 100644 --- a/molecule_runtime/prompt.py +++ b/molecule_runtime/prompt.py @@ -3,7 +3,7 @@ from pathlib import Path from skill_loader.loader import LoadedSkill -from adapters.shared_runtime import build_peer_section +from molecule_runtime.adapters.shared_runtime import build_peer_section DEFAULT_MEMORY_SNAPSHOT_FILES = ("MEMORY.md", "USER.md") diff --git a/tests/test_imports.py b/tests/test_imports.py new file mode 100644 index 0000000..2541d80 --- /dev/null +++ b/tests/test_imports.py @@ -0,0 +1,73 @@ +"""Smoke tests for module imports. + +Catches the class of bug fixed by the absolute-import switch +(monorepo legacy `from adapters import …` worked when something on +sys.path had an `adapters/` package; modular template repos that ship +only `/app/adapter.py` broke). Any future regression to relative-style +top-level imports gets caught here before publish. + +These tests run in a clean Python environment with NO `adapters/` +package on sys.path — exactly the deployment shape consumers see. +""" + +import importlib +import sys + +import pytest + + +# Every module that previously had `from adapters import …` or +# `from adapters.… import …`. Importing any of them must succeed +# without an `adapters/` package on sys.path. +MODULES = [ + "molecule_runtime", + "molecule_runtime.adapters", + "molecule_runtime.adapters.base", + "molecule_runtime.main", + "molecule_runtime.a2a_executor", + "molecule_runtime.coordinator", + "molecule_runtime.prompt", + "molecule_runtime.builtin_tools.temporal_workflow", +] + + +@pytest.mark.parametrize("module_name", MODULES) +def test_module_imports_without_top_level_adapters_pkg(module_name): + # Sanity: no top-level `adapters` package shadowing molecule_runtime.adapters. + assert "adapters" not in sys.modules or sys.modules["adapters"].__name__ != "adapters", ( + "test environment must not have a top-level `adapters` package — " + "this test catches the regression of importing `from adapters import …` " + "instead of `from molecule_runtime.adapters import …`" + ) + mod = importlib.import_module(module_name) + assert mod is not None + # Re-import via importlib should be idempotent. + mod2 = importlib.import_module(module_name) + assert mod is mod2 + + +def test_get_adapter_resolves_via_absolute_path(): + from molecule_runtime.adapters import get_adapter + assert callable(get_adapter) + + +def test_no_top_level_adapters_imports_remain(): + """Grep guard: keep the import-style invariant explicit so a future + drive-by change doesn't silently reintroduce `from adapters import`.""" + import os + pkg_dir = os.path.dirname(importlib.import_module("molecule_runtime").__file__) + offenders = [] + for dirpath, _, files in os.walk(pkg_dir): + for fname in files: + if not fname.endswith(".py"): + continue + path = os.path.join(dirpath, fname) + with open(path, "r", encoding="utf-8") as fh: + for lineno, line in enumerate(fh, start=1): + stripped = line.lstrip() + if stripped.startswith("from adapters") or stripped.startswith("import adapters"): + offenders.append(f"{path}:{lineno}: {line.rstrip()}") + assert not offenders, ( + "Top-level `adapters` imports found — must be `from molecule_runtime.adapters …`:\n " + + "\n ".join(offenders) + )