Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/adcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__)

Expand All @@ -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."""

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions tests/test_client_server_version.py
Original file line number Diff line number Diff line change
@@ -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"
Loading