diff --git a/workspace/tests/snapshots/adapter_base_signature.json b/workspace/tests/snapshots/adapter_base_signature.json index 9c2011bf..2a52e98f 100644 --- a/workspace/tests/snapshots/adapter_base_signature.json +++ b/workspace/tests/snapshots/adapter_base_signature.json @@ -1,5 +1,130 @@ { "class": "BaseAdapter", + "dataclasses": [ + { + "fields": [ + { + "annotation": "str", + "has_default": false, + "name": "system_prompt" + }, + { + "annotation": "list", + "has_default": false, + "name": "loaded_skills" + }, + { + "annotation": "list", + "has_default": false, + "name": "langchain_tools" + }, + { + "annotation": "bool", + "has_default": false, + "name": "is_coordinator" + }, + { + "annotation": "list", + "has_default": false, + "name": "children" + } + ], + "frozen": false, + "name": "SetupResult" + }, + { + "fields": [ + { + "annotation": "str", + "has_default": false, + "name": "model" + }, + { + "annotation": "str | None", + "has_default": true, + "name": "system_prompt" + }, + { + "annotation": "list[str]", + "has_default": true, + "name": "tools" + }, + { + "annotation": "dict[str, typing.Any]", + "has_default": true, + "name": "runtime_config" + }, + { + "annotation": "str", + "has_default": true, + "name": "config_path" + }, + { + "annotation": "str", + "has_default": true, + "name": "workspace_id" + }, + { + "annotation": "list[str]", + "has_default": true, + "name": "prompt_files" + }, + { + "annotation": "int", + "has_default": true, + "name": "a2a_port" + }, + { + "annotation": "Any", + "has_default": true, + "name": "heartbeat" + } + ], + "frozen": false, + "name": "AdapterConfig" + }, + { + "fields": [ + { + "annotation": "bool", + "has_default": true, + "name": "provides_native_heartbeat" + }, + { + "annotation": "bool", + "has_default": true, + "name": "provides_native_scheduler" + }, + { + "annotation": "bool", + "has_default": true, + "name": "provides_native_session" + }, + { + "annotation": "bool", + "has_default": true, + "name": "provides_native_status_mgmt" + }, + { + "annotation": "bool", + "has_default": true, + "name": "provides_native_retry" + }, + { + "annotation": "bool", + "has_default": true, + "name": "provides_activity_decoration" + }, + { + "annotation": "bool", + "has_default": true, + "name": "provides_channel_dispatch" + } + ], + "frozen": true, + "name": "RuntimeCapabilities" + } + ], "methods": [ { "is_abstract": false, diff --git a/workspace/tests/test_adapter_base_signature.py b/workspace/tests/test_adapter_base_signature.py index 718224c1..f1fc7d38 100644 --- a/workspace/tests/test_adapter_base_signature.py +++ b/workspace/tests/test_adapter_base_signature.py @@ -100,14 +100,75 @@ def _build_signature_snapshot() -> dict: return {"class": "BaseAdapter", "methods": methods} +def _dataclass_record(cls: type) -> dict: + """Stable JSON shape for a public dataclass exported from + adapter_base. Captures field name + type annotation + default + presence so renaming, retyping, or making-required-vs-optional + drift trips the gate. + + Note on defaults: we record presence-of-default, not the default + value. A literal default like ``False`` or ``None`` is part of the + contract (templates inherit it), but reproducing it here would + require value-shape stringifying that's brittle for non-trivial + defaults (lists, dataclasses-as-defaults). Presence is enough to + catch the dangerous transitions (required → optional and vice + versa). + """ + import dataclasses as _dc + + fields = [] + for f in _dc.fields(cls): + fields.append({ + "name": f.name, + "annotation": _annotation_repr(f.type) if not isinstance(f.type, str) else f.type, + "has_default": f.default is not _dc.MISSING or f.default_factory is not _dc.MISSING, + }) + return { + "name": cls.__name__, + "frozen": getattr(cls, "__dataclass_params__").frozen, + "fields": fields, + } + + +def _build_dataclass_snapshot() -> list[dict]: + """Snapshot the public dataclasses exported from adapter_base. + + These types form the call-and-return shape between the platform + and every adapter: + - SetupResult: returned by adapter._common_setup() + - AdapterConfig: passed into adapter setup hooks + - RuntimeCapabilities: returned by adapter.capabilities() and + consumed by platform-side dispatch routing (#117). A field + rename here silently disables every native-capability flag + every adapter currently declares. + """ + from adapter_base import AdapterConfig, RuntimeCapabilities, SetupResult + + classes = [SetupResult, AdapterConfig, RuntimeCapabilities] + return [_dataclass_record(cls) for cls in classes] + + +def _build_full_snapshot() -> dict: + """Combined snapshot — BaseAdapter methods + public dataclasses.""" + return { + **_build_signature_snapshot(), + "dataclasses": _build_dataclass_snapshot(), + } + + def test_base_adapter_signature_matches_snapshot(): """Pin BaseAdapter's public API surface against a frozen snapshot. + Covers BOTH method signatures AND public dataclass field shapes + (SetupResult, AdapterConfig, RuntimeCapabilities). Renaming a + RuntimeCapabilities field would silently disable every adapter's + capability declaration without this gate. + On failure, the test prints both the expected and actual snapshot JSON so the diff is human-readable. Updating the snapshot is the explicit ack that a template-affecting API change is intentional. """ - actual = _build_signature_snapshot() + actual = _build_full_snapshot() if not SNAPSHOT_PATH.exists(): # First-run convenience: write the snapshot if missing. A reviewer # of the introducing PR sees the new file in the diff. @@ -126,7 +187,7 @@ def test_base_adapter_signature_matches_snapshot(): expected_str = json.dumps(expected, indent=2, sort_keys=True) pytest.fail( "BaseAdapter signature drifted from snapshot.\n\n" - f"To update intentionally:\n cp <(python -c 'from tests.test_adapter_base_signature import _build_signature_snapshot; import json; print(json.dumps(_build_signature_snapshot(), indent=2, sort_keys=True))') {SNAPSHOT_PATH}\n" + f"To update intentionally:\n cp <(python -c 'from tests.test_adapter_base_signature import _build_full_snapshot; import json; print(json.dumps(_build_full_snapshot(), indent=2, sort_keys=True))') {SNAPSHOT_PATH}\n" "Or rerun with the snapshot deleted to regenerate.\n\n" f"=== EXPECTED ({SNAPSHOT_PATH.name}) ===\n{expected_str}\n\n" f"=== ACTUAL (current adapter_base.py) ===\n{actual_str}\n" @@ -164,3 +225,59 @@ def test_snapshot_has_required_methods(): "updates AND remove the entry from `required` in this test with " "a justification." ) + + +def test_snapshot_has_required_dataclass_fields(): + """Defense-in-depth for the dataclass shapes — same rationale as + test_snapshot_has_required_methods but for fields that adapters + pattern-match on. + + The most load-bearing case: RuntimeCapabilities flags drive + platform-side dispatch routing. Renaming a flag silently turns + every adapter's native-capability declaration into a no-op + (the platform fallback runs), with no AttributeError to surface + the breakage. + """ + if not SNAPSHOT_PATH.exists(): + pytest.skip(f"{SNAPSHOT_PATH.name} not generated yet") + + snapshot = json.loads(SNAPSHOT_PATH.read_text()) + dataclasses = {dc["name"]: dc for dc in snapshot.get("dataclasses", [])} + + expected = { + "RuntimeCapabilities": { + # Each flag here drives a specific platform-side consumer + # (heartbeat, cron, session, etc). Removing one without + # coordinated platform-side migration silently drops back + # to the platform fallback — see project memory + # `project_runtime_native_pluggable.md`. + "provides_native_heartbeat", + "provides_native_scheduler", + "provides_native_session", + }, + "AdapterConfig": { + "model", + "system_prompt", + }, + "SetupResult": { + "system_prompt", + "loaded_skills", + }, + } + + for cls_name, required_fields in expected.items(): + if cls_name not in dataclasses: + pytest.fail( + f"Public dataclass {cls_name} missing from snapshot — " + "either it was removed from adapter_base, OR the snapshot " + "wasn't regenerated after a refactor." + ) + actual_fields = {f["name"] for f in dataclasses[cls_name]["fields"]} + missing = required_fields - actual_fields + if missing: + pytest.fail( + f"{cls_name} is missing required fields: {sorted(missing)}.\n" + "Either restore them on adapter_base.py, OR coordinate template " + "updates AND remove the entry from `expected` in this test " + "with a justification." + )