diff --git a/src/adcp/client.py b/src/adcp/client.py index 73f149408..caf7b187b 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -23,6 +23,7 @@ from adcp._version import resolve_adcp_version from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver, looks_like_v3_capabilities +from adcp.compat.legacy import LEGACY_ADAPTER_VERSIONS from adcp.exceptions import ADCPError, ADCPWebhookSignatureError from adcp.protocols.a2a import A2AAdapter from adcp.protocols.base import ProtocolAdapter @@ -295,6 +296,7 @@ from adcp.types.generated_poc.tmp.identity_match_response import IdentityMatchResponse from adcp.utils.operation_id import create_operation_id from adcp.validation.client_hooks import ValidationHookConfig +from adcp.validation.version import resolve_bundle_key logger = logging.getLogger(__name__) @@ -320,6 +322,40 @@ class Checkpoint(TypedDict): active_task_id: str | None +def _resolve_server_version(pin: str | None) -> str | None: + """Validate the optional ``server_version`` constructor arg. + + Returns the normalized bundle-key (``"3.0.7"`` → ``"3.0"``, + ``"2.5"`` → ``"2.5"``) so :meth:`ADCPClient.get_server_version` + reports a stable shape. ``None`` passes through. + + Pins to a version in :data:`adcp.compat.legacy.LEGACY_ADAPTER_VERSIONS` + emit a :class:`DeprecationWarning` because the SDK acknowledges + the seller's wire shape but doesn't yet translate outbound + requests down to it (Stage 7-full). + + Garbage input raises :class:`ValueError` — same contract as + :func:`adcp.validation.version.resolve_bundle_key`. + """ + if pin is None: + return None + normalized = resolve_bundle_key(pin) + if normalized in LEGACY_ADAPTER_VERSIONS: + import warnings as _warnings + + _warnings.warn( + f"server_version={pin!r} pins this client to a legacy AdCP " + f"wire shape. The SDK records the pin but does NOT yet " + f"translate outbound requests — your seller will receive v3 " + f"requests this client constructs. Wait for Stage 7-full " + f"(inverse adapters) before relying on this in production, " + f"or upgrade the seller to a current major.", + DeprecationWarning, + stacklevel=3, + ) + return normalized + + class ADCPClient: """Client for interacting with a single AdCP agent.""" @@ -338,6 +374,7 @@ def __init__( validation: ValidationHookConfig | None = None, force_a2a_version: str | None = None, adcp_version: str | None = None, + server_version: str | None = None, ): """ Initialize ADCP client for a single agent. @@ -456,8 +493,31 @@ def __init__( are present. Stop populating ``adcp_major_version`` on request models once your seller advertises 3.1 in ``supported_versions``. + server_version: AdCP wire shape the *seller* speaks. Most + adopters leave this ``None`` — the SDK assumes a v3 + seller and the seller's + ``/.well-known/agent-card.json`` is the canonical + source of truth once a probe-and-cache path lands. + + Pin explicitly when: + + * You're talking to a known-legacy seller (e.g. + ``server_version="2.5"``). The SDK emits a + :class:`DeprecationWarning` at construction — + outbound translation is **not** yet wired (Stage 7 + full will add it), so a legacy pin today is a signal + the SDK acknowledges but cannot act on. Adopters + whose sellers still speak pre-3.0 should either + upgrade the seller or wait for the inverse-translator + release. + * You want telemetry to attribute outbound traffic to + a specific server-side version regardless of what the + seller advertises. + + Retrieve the current value via :meth:`get_server_version`. """ self._adcp_version: str = resolve_adcp_version(adcp_version) + self._server_version: str | None = _resolve_server_version(server_version) self.agent_config = agent_config self.webhook_url_template = webhook_url_template self.webhook_secret = webhook_secret @@ -546,6 +606,22 @@ def get_adcp_version(self) -> str: """ return self._adcp_version + def get_server_version(self) -> str | None: + """Return the seller's AdCP wire-shape version, or ``None``. + + ``None`` means the SDK is assuming a current-major seller + (the default). Returns a release-precision string + (``"3.0"``, ``"3.1"``, ``"2.5"``) when the adopter pinned + via the ``server_version`` constructor arg or — once the + agent-card probe lands — when the SDK detected the seller's + version from its agent-card. + + See ``__init__``'s ``server_version`` parameter for what + legacy pins mean today (signal only; outbound translation + ships in Stage 7-full). + """ + return self._server_version + @property def context_id(self) -> str | None: """Current A2A conversation context_id. diff --git a/tests/test_client_server_version.py b/tests/test_client_server_version.py new file mode 100644 index 000000000..63026ef4d --- /dev/null +++ b/tests/test_client_server_version.py @@ -0,0 +1,63 @@ +"""Tests for ``ADCPClient.server_version`` (Stage 7-lite). + +Stage 7-lite ships the API surface for adopters to declare which +AdCP wire shape their seller speaks. Today the pin is plumbing-only — +the SDK records it and warns when adopters declare a legacy pin +(since outbound translation isn't wired yet). Stage 7-full will use +this signal to drive request rewriting. +""" + +from __future__ import annotations + +import warnings + +import pytest + +from adcp.client import _resolve_server_version + + +def test_resolve_server_version_none_passes_through() -> None: + """``None`` (default) records no pin — most adopters land here.""" + assert _resolve_server_version(None) is None + + +def test_resolve_server_version_current_major_pin() -> None: + """A current-major pin is accepted silently — adopters using the + knob for telemetry attribution don't need a warning.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") # any warning would fail + assert _resolve_server_version("3.0") == "3.0" + assert _resolve_server_version("3.0.7") == "3.0" + assert _resolve_server_version("3.1.0-beta.1") == "3.1.0-beta.1" + + +def test_resolve_server_version_legacy_emits_deprecation_warning() -> None: + """Legacy pins are acknowledged but adopters need to know that + outbound translation isn't yet wired.""" + with pytest.warns(DeprecationWarning, match="legacy AdCP wire shape"): + result = _resolve_server_version("2.5") + assert result == "2.5" + + +def test_resolve_server_version_legacy_warning_mentions_stage_7_full() -> None: + """Warning message should point adopters at the upgrade path so + they know what to wait for.""" + with pytest.warns(DeprecationWarning) as record: + _resolve_server_version("2.5") + assert any("Stage 7-full" in str(w.message) for w in record) + + +def test_resolve_server_version_rejects_garbage() -> None: + """Same contract as ``resolve_bundle_key`` — adopters get a loud + ValueError on typos rather than silent acceptance.""" + with pytest.raises(ValueError): + _resolve_server_version("latest") + with pytest.raises(ValueError): + _resolve_server_version("v3.0") + + +def test_resolve_server_version_normalizes_patch_to_bundle_key() -> None: + """Patch-precision pins collapse to bundle-key precision, matching + the loader's expectation.""" + assert _resolve_server_version("3.0.0") == "3.0" + assert _resolve_server_version("3.0.99") == "3.0"