forked from molecule-ai/molecule-core
test(adapter_base): extend signature snapshot to public dataclasses (#2364 item 2 followup)
Follows up #2378. The BaseAdapter snapshot covers method signatures but `adapter_base.py` also exports three public dataclasses that form the call/return contract between the platform and every adapter: - SetupResult — returned by adapter._common_setup() - AdapterConfig — passed into adapter setup hooks - RuntimeCapabilities — returned by adapter.capabilities(); drives platform-side dispatch routing (#117) Renaming a RuntimeCapabilities flag silently disables every adapter's capability declaration (the platform fallback runs) without an AttributeError to surface the breakage. That's exactly the drift class the snapshot pattern is meant to catch. Changes: - _build_dataclass_snapshot walks SetupResult, AdapterConfig, RuntimeCapabilities via dataclasses.fields(), capturing field name + type annotation + has_default per field, plus the @dataclass(frozen=...) flag. - _build_full_snapshot composes method + dataclass records into one stable JSON snapshot. - test_snapshot_has_required_dataclass_fields — defense-in-depth test parallel to test_snapshot_has_required_methods. Catches field removal even when both source AND snapshot are updated together. Required field set is intentionally short (the flags that drive platform dispatch + the adapter-level config knobs). Verified: deliberately renaming `provides_native_heartbeat` → `provides_native_heartbeat_RENAMED` trips test_base_adapter_signature_matches_snapshot with a full diff in the failure message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a7ddfbc3b5
commit
12e39c7311
@ -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,
|
||||
|
||||
@ -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."
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user