test(adapter_base): signature snapshot — drift gate for adapter public surface (#2364 item 2)

Every workspace template (langgraph, claude-code, hermes, etc.)
subclasses BaseAdapter. Renaming, removing, or re-typing a method
on the base class silently breaks templates: the override stops
being recognized as an override; the old method-name's caller
silently invokes the default no-op; the new method-name is
unimplemented in templates that haven't migrated.

Recent #87 universal-runtime + #1957 recordResource refactor both
renamed/added methods. Without a frozen snapshot, the next rename
ships quietly and surfaces only when a template's CI catches the
AttributeError days later — long after the merge window for an
easy revert.

This snapshot pins BaseAdapter's public method surface against a
checked-in JSON file. Same-shape pattern as PR #2363's A2A
protocol-compat replay gate, applied to a Python public-API
surface instead of JSON message shapes. Both close drift classes
by snapshotting the structural surface that consumers depend on.

Two tests:

  1. test_base_adapter_signature_matches_snapshot — full
     introspection diff against tests/snapshots/adapter_base_signature.json.
     Drift = test failure with both expected + actual JSON in
     the message so the reviewer sees what changed.
  2. test_snapshot_has_required_methods — defense-in-depth: even
     if both the source AND snapshot are updated together
     (intentional API removal), this catches removal of the
     short list of methods that EVERY template depends on (name,
     display_name, description, capabilities, memory_filename).
     Removing one of these requires explicit edit to the
     `required` set with a justification.

Verified the gate fires red on a deliberate rename
(memory_filename → memory_filename_RENAMED) — failure message
shows the full snapshot diff including parameter shapes and
return annotations.

Updating the snapshot is the explicit acknowledgment that a
template-affecting API change is intentional. Reviewer of the
introducing PR sees the snapshot diff and decides whether
template repos need coordinated updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hongming Wang 2026-04-30 05:18:39 -07:00
parent 97058d5392
commit 8488a188c2
2 changed files with 477 additions and 0 deletions

View File

@ -0,0 +1,311 @@
{
"class": "BaseAdapter",
"methods": [
{
"is_abstract": false,
"is_async": false,
"name": "append_to_memory_hook",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "AdapterConfig",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "config"
},
{
"annotation": "str",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "filename"
},
{
"annotation": "str",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "content"
}
],
"return_annotation": "None"
},
{
"is_abstract": false,
"is_async": false,
"name": "capabilities",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
}
],
"return_annotation": "RuntimeCapabilities"
},
{
"is_abstract": true,
"is_async": true,
"name": "create_executor",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "AdapterConfig",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "config"
}
],
"return_annotation": "AgentExecutor"
},
{
"is_abstract": true,
"is_async": false,
"name": "description",
"parameters": [],
"return_annotation": "str"
},
{
"is_abstract": true,
"is_async": false,
"name": "display_name",
"parameters": [],
"return_annotation": "str"
},
{
"is_abstract": false,
"is_async": false,
"name": "get_config_schema",
"parameters": [],
"return_annotation": "dict"
},
{
"is_abstract": false,
"is_async": false,
"name": "idle_timeout_override",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
}
],
"return_annotation": "int | None"
},
{
"is_abstract": false,
"is_async": true,
"name": "inject_plugins",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "AdapterConfig",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "config"
},
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "plugins"
}
],
"return_annotation": "None"
},
{
"is_abstract": false,
"is_async": true,
"name": "install_plugins_via_registry",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "AdapterConfig",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "config"
},
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "plugins"
}
],
"return_annotation": "list"
},
{
"is_abstract": false,
"is_async": false,
"name": "memory_filename",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
}
],
"return_annotation": "str"
},
{
"is_abstract": true,
"is_async": false,
"name": "name",
"parameters": [],
"return_annotation": "str"
},
{
"is_abstract": false,
"is_async": false,
"name": "pre_stop_state",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
}
],
"return_annotation": "dict"
},
{
"is_abstract": false,
"is_async": false,
"name": "register_subagent_hook",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "str",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "name"
},
{
"annotation": "dict",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "spec"
}
],
"return_annotation": "None"
},
{
"is_abstract": false,
"is_async": false,
"name": "register_tool_hook",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "str",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "name"
},
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "fn"
}
],
"return_annotation": "None"
},
{
"is_abstract": false,
"is_async": false,
"name": "restore_state",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "dict",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "snapshot"
}
],
"return_annotation": "None"
},
{
"is_abstract": true,
"is_async": true,
"name": "setup",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "AdapterConfig",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "config"
}
],
"return_annotation": "None"
},
{
"is_abstract": false,
"is_async": true,
"name": "transcript_lines",
"parameters": [
{
"annotation": "",
"has_default": false,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "self"
},
{
"annotation": "int",
"has_default": true,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "since"
},
{
"annotation": "int",
"has_default": true,
"kind": "POSITIONAL_OR_KEYWORD",
"name": "limit"
}
],
"return_annotation": "dict"
}
]
}

View File

@ -0,0 +1,166 @@
"""BaseAdapter public-API signature snapshot — drift gate (#2364 item 2).
Every workspace template subclasses ``BaseAdapter``. Renaming, removing,
or re-typing a method on the base class silently breaks templates that
override it the override stops being recognized as an override, the
old method-name's caller silently invokes the default no-op, etc.
Recent #87 universal-runtime work + #1957 recordResource refactor both
renamed/added methods; without a frozen snapshot, the next rename ships
quietly and only surfaces when a template's CI catches the AttributeError
days later.
This test pins the public surface. It walks ``BaseAdapter`` with
``inspect`` and compares the result against a checked-in JSON snapshot.
Any drift = test failure.
When the failure is intentional:
1. Make the API change in ``adapter_base.py``.
2. Run the test once to see the diff in the failure message.
3. Update ``tests/snapshots/adapter_base_signature.json`` to match
the new shape that update IS the explicit acknowledgment that
templates need follow-up. Reviewer of the PR sees the snapshot
diff in their review and decides whether template repos need
coordinated updates.
Same-shape pattern as PR #2363's A2A protocol-compat replay gate
(workspace-server/internal/handlers/testdata/a2a_corpus). Both close
drift classes by snapshotting the structural surface that templates
or callers depend on.
"""
import inspect
import json
import sys
from pathlib import Path
import pytest
# Resolve workspace/ as the import root so adapter_base imports clean.
WORKSPACE_DIR = Path(__file__).parent.parent
if str(WORKSPACE_DIR) not in sys.path:
sys.path.insert(0, str(WORKSPACE_DIR))
SNAPSHOT_PATH = Path(__file__).parent / "snapshots" / "adapter_base_signature.json"
def _annotation_repr(annotation: object) -> str:
"""Stable string form of a type annotation. ``inspect`` returns the
runtime objects which don't compare cleanly — repr is the boring
correct answer for snapshotting."""
if annotation is inspect.Parameter.empty:
return ""
if isinstance(annotation, type):
return annotation.__name__
# types.UnionType / typing.Union / forward refs — repr captures all
return str(annotation)
def _parameter_record(p: inspect.Parameter) -> dict:
return {
"name": p.name,
"kind": p.kind.name,
"annotation": _annotation_repr(p.annotation),
"has_default": p.default is not inspect.Parameter.empty,
}
def _signature_record(name: str, fn: object) -> dict:
sig = inspect.signature(fn)
return {
"name": name,
"is_async": inspect.iscoroutinefunction(fn),
"is_abstract": getattr(fn, "__isabstractmethod__", False),
"parameters": [_parameter_record(p) for p in sig.parameters.values()],
"return_annotation": _annotation_repr(sig.return_annotation),
}
def _build_signature_snapshot() -> dict:
"""Walk BaseAdapter and produce a stable JSON-serializable snapshot."""
from adapter_base import BaseAdapter # imported lazy so test discovery is fast
methods: list[dict] = []
for attr_name in sorted(vars(BaseAdapter)):
if attr_name.startswith("_"):
continue
attr = vars(BaseAdapter)[attr_name]
# Only callables — skip data attributes (none today, but
# forward-defensive). staticmethod / classmethod are unwrapped
# via __func__; abstractmethod wraps the underlying fn.
if isinstance(attr, staticmethod):
fn = attr.__func__
elif isinstance(attr, classmethod):
fn = attr.__func__
elif callable(attr):
fn = attr
else:
continue
methods.append(_signature_record(attr_name, fn))
return {"class": "BaseAdapter", "methods": methods}
def test_base_adapter_signature_matches_snapshot():
"""Pin BaseAdapter's public API surface against a frozen snapshot.
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()
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.
SNAPSHOT_PATH.parent.mkdir(parents=True, exist_ok=True)
SNAPSHOT_PATH.write_text(json.dumps(actual, indent=2, sort_keys=True) + "\n")
pytest.skip(
f"snapshot did not exist; wrote {SNAPSHOT_PATH.name}"
"re-run the test to verify it now passes"
)
expected = json.loads(SNAPSHOT_PATH.read_text())
if actual != expected:
# Pretty-print both for the failure message so reviewer sees what
# changed without rerunning anything.
actual_str = json.dumps(actual, indent=2, sort_keys=True)
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"
"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"
)
def test_snapshot_has_required_methods():
"""Defense-in-depth: the snapshot must include the methods every
template overrides. If a future refactor accidentally drops one of
these from BaseAdapter (e.g., moves it to a mixin), the equality
test above passes if the snapshot file is also updated but THIS
test catches the structural regression.
Add a method to ``required`` ONLY when removing it would break a
deployed template. The list is intentionally short.
"""
if not SNAPSHOT_PATH.exists():
pytest.skip(f"{SNAPSHOT_PATH.name} not generated yet")
snapshot = json.loads(SNAPSHOT_PATH.read_text())
method_names = {m["name"] for m in snapshot["methods"]}
required = {
"name", # runtime identifier — every template MUST implement
"display_name", # UI-facing label
"description", # short description
"capabilities", # native vs platform-fallback declaration (#117)
"memory_filename", # plugin-pipeline hook
}
missing = required - method_names
if missing:
pytest.fail(
f"BaseAdapter snapshot is missing required methods: {sorted(missing)}.\n"
"Either restore them on adapter_base.py, OR coordinate template "
"updates AND remove the entry from `required` in this test with "
"a justification."
)