From 49597ef3ac7167237fd583ff3566674ec8856feb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 31 Jul 2026 03:15:10 -0400 Subject: [PATCH] fix(client): preserve canonical format options --- src/adcp/client.py | 18 +++++++ tests/test_client.py | 119 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/src/adcp/client.py b/src/adcp/client.py index 40626ab5..3fd4f821 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -1506,6 +1506,24 @@ def _canonicalize_get_products_result( ) -> TaskResult[GetProductsResponse]: """Parse the wire shape, project products, and enforce the primary boundary.""" + # Canonical responses must be parsed before the legacy compatibility + # model. The generated legacy ProductFormatDeclaration intentionally + # lacks canonical ``format_kind`` and ``params`` fields, so parsing a + # canonical-only response through it first irreversibly discards the + # declaration before ``project_legacy_product`` can inspect it. + canonical_result: TaskResult[GetProductsResponse] = self.adapter._parse_response( + raw_result, GetProductsResponse + ) + if not raw_result.success or raw_result.data is None: + return canonical_result + if canonical_result.success and canonical_result.data is not None: + direct_products = list(canonical_result.data.products or []) + self._remember_canonical_product_routes(direct_products) + metadata = dict(canonical_result.metadata or {}) + metadata["projection"] = {"diagnostics": []} + canonical_result.metadata = metadata + return canonical_result + legacy_result: TaskResult[Any] = self.adapter._parse_response( raw_result, LegacyGetProductsResponse ) diff --git a/tests/test_client.py b/tests/test_client.py index e9fe0b91..06c076ff 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,5 +1,7 @@ """Tests for ADCPClient.""" +from typing import Any + import pytest from adcp import ADCPClient, ADCPMultiAgentClient @@ -7,6 +9,40 @@ from tests.conftest import validate_union +def _get_products_product( + *, + format_options: list[dict[str, Any]] | None = None, + format_ids: list[dict[str, str]] | None = None, +) -> dict[str, Any]: + product: dict[str, Any] = { + "product_id": "p1", + "name": "Product 1", + "description": "A test product", + "publisher_properties": [{"selection_type": "all", "publisher_domain": "pub.example.com"}], + "delivery_type": "non_guaranteed", + "pricing_options": [ + { + "pricing_model": "cpm", + "pricing_option_id": "po1", + "currency": "USD", + } + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 0, + "timezone": "UTC", + "supports_webhooks": False, + "available_metrics": ["impressions"], + "date_range_support": "date_range", + }, + } + if format_options is not None: + product["format_options"] = format_options + if format_ids is not None: + product["format_ids"] = format_ids + return product + + def test_agent_config_creation(): """Test creating agent configuration.""" config = AgentConfig( @@ -165,7 +201,7 @@ async def test_get_products(): """Test get_products method with mock adapter.""" from unittest.mock import patch - from adcp.types import GetProductsRequest, GetProductsResponse, LegacyGetProductsResponse + from adcp.types import GetProductsRequest, GetProductsResponse from adcp.types.core import TaskResult, TaskStatus config = AgentConfig( @@ -203,13 +239,92 @@ async def test_get_products(): # Verify adapter method was called mock_get.assert_called_once_with({"brief": "test campaign", "buying_mode": "brief"}) # Verify parsing was called with correct type - mock_parse.assert_called_once_with(mock_raw_result, LegacyGetProductsResponse) + mock_parse.assert_called_once_with(mock_raw_result, GetProductsResponse) # Verify final result assert result.success is True assert result.status == TaskStatus.COMPLETED assert isinstance(result.data, GetProductsResponse) +def test_get_products_preserves_canonical_format_options(): + """Canonical declarations must not pass through the lossy legacy model.""" + from adcp.types import GetProductsResponse + from adcp.types.core import TaskResult, TaskStatus + + config = AgentConfig( + id="test_agent", + agent_uri="https://test.example.com", + protocol=Protocol.A2A, + ) + client = ADCPClient(config) + raw_result = TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data={ + "products": [ + _get_products_product( + format_options=[ + { + "format_option_id": "p1-display", + "format_kind": "image", + "params": {"width": 300, "height": 250}, + } + ] + ) + ] + }, + ) + + result = client._canonicalize_get_products_result(raw_result) + + assert result.success is True + assert isinstance(result.data, GetProductsResponse) + assert result.data.products is not None + assert len(result.data.products) == 1 + declaration = result.data.products[0].format_options[0] + assert declaration.format_kind.value == "image" + assert declaration.params == {"width": 300, "height": 250} + assert result.metadata == {"projection": {"diagnostics": []}} + + +def test_get_products_still_projects_legacy_format_ids(): + """Canonical-first parsing must retain the legacy compatibility fallback.""" + from adcp.types import GetProductsResponse + from adcp.types.core import TaskResult, TaskStatus + + client = ADCPClient( + AgentConfig( + id="test_agent", + agent_uri="https://test.example.com", + protocol=Protocol.A2A, + ) + ) + raw_result = TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data={ + "products": [ + _get_products_product( + format_ids=[ + { + "agent_url": "https://seller.example", + "id": "display_300x250_image", + } + ] + ) + ] + }, + ) + + result = client._canonicalize_get_products_result(raw_result) + + assert result.success is True + assert isinstance(result.data, GetProductsResponse) + assert result.data.products is not None + assert len(result.data.products) == 1 + assert result.data.products[0].format_options[0].format_kind.value == "image" + + @pytest.mark.asyncio async def test_get_products_wholesale_versions_sent_and_parsed(): """Wholesale product enumeration sends and parses beta 3 version tokens."""