From d0567d9b54e93635d8ce591c73a70a4858b08354 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 31 Oct 2026 20:50:14 +0000 Subject: [PATCH] fix(compat): decide the compact response status from the served release's schema #1754 stamps status on list_products and decline_proposals from 3.3.0-beta.3 by comparing version strings. That leaves three defects for a 3.2.x peer, and a fourth for the next 3.2.x release: - A typed ListProductsResponse dumps its defaulted `status: completed`. A server serving 3.2.x sent it to a closed root that forbids it, so strict response validation failed on a handler that returned the typed model. - Converting a synchronous decline_proposals response from 3.3 to 3.2 raised ADCPVersionConversionError: the 3.2 root permits `status` only as `submitted`, and the converter had no rule for the status that 3.3 requires. - Converting up from 3.2 to 3.3 did not supply the `status` 3.3 requires, so the converted list_products / decline_proposals payload failed 3.3 validation. - A 3.2.x release that composes the envelope (the backport) would still be treated as one that does not. uses_task_status_envelope(tool, version) now asks the served release's own response schema whether it composes core/protocol-envelope.json, as the TypeScript SDK does (adcontextprotocol/adcp-client#3493). The version comparison from #1754 stays as the answer for a release that ships no schema for the tool. The server drops only the typed default `completed` where the served release has none for the tool (a submitted arm keeps its status), and convert_payload supplies `completed` going up and drops it, recorded as a loss, going down. Nothing is stripped for any other tool or release. Co-Authored-By: Claude Sonnet 5.5 --- MIGRATION_v9_to_v10.md | 2 +- src/adcp/compat/versions.py | 42 ++++- src/adcp/server/mcp_tools.py | 13 +- src/adcp/validation/_response_envelope.py | 23 ++- src/adcp/validation/schema_loader.py | 55 +++++++ tests/test_response_status_gating.py | 191 ++++++++++++++++++++++ 6 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 tests/test_response_status_gating.py diff --git a/MIGRATION_v9_to_v10.md b/MIGRATION_v9_to_v10.md index ab090ec6b..21f89e33c 100644 --- a/MIGRATION_v9_to_v10.md +++ b/MIGRATION_v9_to_v10.md @@ -13,7 +13,7 @@ the SDK-relevant inventory is [docs/adcp-3.3-sdk-inventory.md](docs/adcp-3.3-sdk | AdCP 3.3.0-beta.1 adds account requests, media-buy issues and reporting subscriptions | New tasks are wired through `ADCPClient` / `ADCPMultiAgentClient`, `ADCPHandler` (each returns `not_supported()` until overridden), the MCP and A2A adapters, the MCP tool list and the CLI: `request_account` (experimental `account.requests`; it opens, matches or, with `account_id`, changes an account, guarded by `revision`), `report_media_buy_issue` and `get_media_buy_issue`. `RequestAccountRequest` / `Response`, `ReportMediaBuyIssueRequest` / `Response` and `GetMediaBuyIssueRequest` / `Response` are exported from `adcp.types`. `list_accounts` gains `include_requests`, `request_ids` and `requests_pagination`, returning `account_requests[]` and `account_requests_pagination`. Experimental principal-level `reporting_subscriptions` land on `sync_principal` / `get_principal`, with `subscription_coverage[]` on `get_reporting_status`, `configuration_tasks[]` on `media_buy.reporting_delivery`, the `ReportingDimension` enum and the `principal.changed` reason `reporting_subscription_changed`. `sync_accounts` is unchanged and stays supported. Generated class names that the generator numbers can renumber with the bundle; import the semantic names from `adcp.types` rather than a numbered class from a domain module. | | AdCP 3.3.0-beta.2 withdraws definition pins, purchase rights and installment selection | adcontextprotocol/adcp#8537 moves all three to 4.0, so their generated types leave `adcp.types.domains.*`, `adcp.types._generated` and `adcp.types.error_details`: `DefinitionPin*`, `DefinitionPinReferenceKind`, `ReferenceDefinitionChangedDetails` (and `ErrorCode.REFERENCE_DEFINITION_CHANGED`), `DriftedPin`, `RightsScope`, `InstallmentChangeSet`, `commercial_terms.definition_pins`, `purchases[].rights` / `installments`, `installment_changes` and the `definition_changed` refinement reason. `TestControllerStore.definition_pin_probe` and the `definition_pin_probe` controller scenario are removed. Delete code that builds or reads them; a seller stops listing `media_buy.purchase_rights` and `media_buy.installment_selection` in `experimental_features`. | | AdCP 3.3.0-beta.2 adds separate-account outcomes, seller offers and measurement provenance | `RequestAccountRequest.allow_separate_account` (default `false`) lets a change request resolve as `created` (a new account carrying the change) or `matched` (an account the caller already holds), and the new correctable error code `ACCOUNT_CHANGE_REQUIRES_NEW_ACCOUNT` reports a change the seller would take only on a new account. Experimental seller offers add `proposal_initiator`, `offer_reason` on `list_proposals` rows and the `proposal.offered` notification (`ProposalOfferedWebhook`); experimental measurement provenance adds `CollectionMechanism`, `counting_mechanisms` and `vendor_metric_provenance[]` (`VendorMetricProvenance`). The reporting-subscription fields settled after beta.1 (`standard_dimensions`, `unmapped_dimensions`, `inactive_intervals`, `prior_versions[].configuration`, per-offering `offering_id`, `previous_timezone`) are now generated. `principal_kind` and `expected_principal_kind` are deprecated. `NotificationConfig.product_payload_view` loses its schema default: it is `None` when unset, and a product subscription's dump no longer adds `product_payload_view: "legacy"` (absent means legacy); read `config.product_payload_view or "legacy"`. These are generated types, imported from their domain packages (for example `adcp.types.domains.core.ProposalOfferedWebhook`); no task was added. | -| AdCP 3.3.0-beta.3 composes the protocol envelope on every task response | adcontextprotocol/adcp#8547: every response root composes `core/protocol-envelope.json`, which composes `core/version-envelope.json`, so generated responses inherit `ProtocolEnvelope` alone (no separate `AdcpVersionEnvelope` base) and the local copies of envelope fields are gone. Submitted, working and input-required bodies compose `core/task-submitted.json` / `task-working.json` / `task-input-required.json`: a submitted arm still pins `status` to `submitted` and requires `task_id`. `task_id` (non-empty) and the task-level `message` (at most 2,000 characters) each have one definition (`core/task-id.json`, `core/task-message.json`); fields that refer to them stay typed `str` with those constraints, so no caller has to wrap a value. `TaskId`, `TaskMessage`, `TaskSubmitted`, `TaskWorking` and `TaskInputRequired` are generated. Consequences for handlers: `list_products` and `decline_proposals` now carry `status` (the server stamps `completed`; the 3.3 → 3.2 conversion still drops it for 3.2 callers); an absent `replayed` reads as `false` and `replayed: false` is valid; and on governance responses `status` is the task status, so a `check_governance` result names its decision in `verdict` and `report_plan_outcome` in `outcome_state`, never in `status`. `create_mcp_webhook_payload` gives a `result` without `status` the webhook's task status, and replaces a model result's defaulted envelope status with it, so a failed task's result never reads `completed`. | +| AdCP 3.3.0-beta.3 composes the protocol envelope on every task response | adcontextprotocol/adcp#8547: every response root composes `core/protocol-envelope.json`, which composes `core/version-envelope.json`, so generated responses inherit `ProtocolEnvelope` alone (no separate `AdcpVersionEnvelope` base) and the local copies of envelope fields are gone. Submitted, working and input-required bodies compose `core/task-submitted.json` / `task-working.json` / `task-input-required.json`: a submitted arm still pins `status` to `submitted` and requires `task_id`. `task_id` (non-empty) and the task-level `message` (at most 2,000 characters) each have one definition (`core/task-id.json`, `core/task-message.json`); fields that refer to them stay typed `str` with those constraints, so no caller has to wrap a value. `TaskId`, `TaskMessage`, `TaskSubmitted`, `TaskWorking` and `TaskInputRequired` are generated. Consequences for handlers: `list_products` and `decline_proposals` now carry `status` (the server stamps `completed`; the 3.3 → 3.2 conversion drops it for 3.2 callers, as a recorded loss, and the 3.2 → 3.3 conversion supplies it). Whether a release carries `status` on those two tools is read from that release's own response schema (`uses_task_status_envelope(tool, version)` asks whether it composes `core/protocol-envelope.json`), not from its version string, so a 3.2.x release that later composes the envelope needs no SDK change; under a release that does not, the typed model's defaulted `completed` is not sent (a closed 3.2 `list_products` root refuses it) and nothing is stripped for any other tool; an absent `replayed` reads as `false` and `replayed: false` is valid; and on governance responses `status` is the task status, so a `check_governance` result names its decision in `verdict` and `report_plan_outcome` in `outcome_state`, never in `status`. `create_mcp_webhook_payload` gives a `result` without `status` the webhook's task status, and replaces a model result's defaulted envelope status with it, so a failed task's result never reads `completed`. | | AdCP 3.3.0-beta.3 adds live creative updates, account-request money terms and reporting-subscription fields | Experimental `creative.live_updates` (`LiveUpdatePolicy`, `CreativeStagedUpdate`, `creative.staged_update_changed` and `CreativeStagedUpdateChangedWebhook`, error code `CREATIVE_LIVE_UPDATE_UNSUPPORTED` with `CreativeLiveUpdateUnsupportedDetails`). `request_account` outcomes carry `account` money terms on `matched` / `created`, `effective_at` on an `updated` money change, and `not_carried_over`. Reporting subscriptions gain `destination_overrides[]`, the paged readback (`prior_versions_count`, `accounts_with_exceptions_count`, `accounts_unauthorized_count`, `get_principal` `reporting_subscription_page`), `authorization.regranted_at`, `flight_history[]` and `cancellation_effective_at` on media buys, `available_from` / `withdrawn_at` on offerings and `stub` on billing obligations. `comply_test_controller` adds the `reporting_subscription_probe` scenario: override `TestControllerStore.reporting_subscription_probe` to serve it. Generated renames: `PriorVersion` is `ReportingSubscriptionPriorVersion`, `AccountsWithException` is `ReportingSubscriptionAccountExceptions`, and `AccountsUnauthorizedItem` is `ReportingSubscriptionUnauthorizedAccount`. | | The reporting-subscription surface is served from 3.3.0-beta.3 on, unprojected | The beta.1 / beta.2 field projections are gone, with the preview module that patched the fields onto the models: `project_offering`, `project_reporting_delivery`, `project_subscription_state`, `project_subscription_coverage` and `serves_settled_subscription_fields` are removed from `adcp.reporting.subscriptions`, and `ReportingSubscriptionEngine.subscription_coverage()` no longer takes `adcp_version`. One predicate, `adcp.reporting.subscriptions.serves_subscriptions(adcp_version)` (from `SUBSCRIPTIONS_MIN_ADCP_VERSION`, `3.3.0-beta.3`), gates the whole surface: capabilities, `get_reporting_status` coverage (including `status: "inactive"` with `deactivated_at`) and the principal readback. A caller served an earlier release gets none of it. The extensible `reporting-dimension` vocabulary accepts unknown values on selectors, offerings and the capability block. | | Reporting-subscription registration follows the 3.3.0-beta.3 rules | A caller may hold several pacing or analytics subscriptions per account and purpose (the lowest `subscription_id` owes a shared offering; the others record a package-level `overridden` exception), while billing stays one per account at a scope level and must use `match: exact` with `dimensions`. Membership edits (`scope.account_ids`, `exclude_account_ids`) no longer need a new `subscription_version`, and `client.reporting.subscribe` no longer bumps the version for them. `destination_overrides` must name existing destinations. The readback lists 16 prior versions and 1,000 accounts inline with `*_count` totals; page the rest with `get_principal` `reporting_subscription_page`. A partial grant refuses an unlisted reporting task with `SCOPE_INSUFFICIENT`. `AccountReady` gains `resolution`, `billing`, `billing_entity`, `payment_terms`, `effective_at` and `not_carried_over`. | diff --git a/src/adcp/compat/versions.py b/src/adcp/compat/versions.py index 4533ce699..4f6ba50a1 100644 --- a/src/adcp/compat/versions.py +++ b/src/adcp/compat/versions.py @@ -273,9 +273,7 @@ def _score(schema: dict[str, Any], value: Any, root: dict[str, Any]) -> int: else ( "string" if isinstance(value, str) - else "number" - if isinstance(value, (int, float)) - else "object" + else "number" if isinstance(value, (int, float)) else "object" ) ) ) @@ -1095,6 +1093,42 @@ def log_conversion_summary( ) +def _convert_compact_status( + tool: str, + result: dict[str, Any], + source: str, + target: str, + step: int, + losses: list[ConversionLoss], +) -> None: + """Move the task ``status`` of a compact response across the 3.2 / 3.3 line. + + ``list_products`` and a synchronous ``decline_proposals`` have no ``status`` + under 3.2.x (the closed roots predate the protocol envelope) and require it + under 3.3, which composes the envelope. Which side has it is read from each + release's own schema (:func:`uses_task_status_envelope`), so only these + tools move and a 3.2.x release that composes the envelope needs no change. + Going up supplies the completed status a synchronous result implies; going + down drops it, as a loss, rather than send a value the 3.2 root refuses. A + ``submitted`` response keeps its status, which the older contract permits. + """ + from adcp.validation._response_envelope import ( + _NATIVE_COMPACT_ENVELOPE_TOOLS, + uses_task_status_envelope, + ) + + if tool not in _NATIVE_COMPACT_ENVELOPE_TOOLS: + return + has_status = uses_task_status_envelope(tool, source) + wants_status = uses_task_status_envelope(tool, target) + if step > 0 and wants_status and not has_status: + if "status" not in result and "task_id" not in result: + result["status"] = "completed" + elif step < 0 and has_status and not wants_status and result.get("status") == "completed": + del result["status"] + losses.append(ConversionLoss("/status", source, target)) + + def _convert_payload( tool: str, payload: dict[str, Any], @@ -1167,6 +1201,8 @@ def _convert_payload( "account" if request is None or request.get("account") is not None else "public" ) result.setdefault("cache_scope", scope) + if direction != "request": + _convert_compact_status(tool, result, source, target, step, losses) if step > 0 and _carries_interim_ext(result): _hoist_interim_ext(result, target_schema, target_schema) if step < 0: diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index dd6476cd6..b6fb61954 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -162,13 +162,16 @@ def _normalize_response_envelope( # outcome discriminator. if "outcome" not in result and "products" in result: result["outcome"] = "listed" - if ( - uses_task_status_envelope(method_name, adcp_version) - and "status" not in result - and "task_id" not in result - ): + status_envelope = uses_task_status_envelope(method_name, adcp_version) + if status_envelope and "status" not in result and "task_id" not in result: if not (is_sync_media_buy_success and not _is_adcp_31_or_newer(adcp_version)): result["status"] = "completed" + elif not status_envelope and result.get("status") == "completed": + # The typed response models default ``status`` to ``completed`` (3.3 + # requires it). The served release has none for this tool, so the + # model's default must not reach a closed 3.2.x root. Only the implied + # synchronous value goes; a submitted arm keeps its status. + del result["status"] if ( method_name in {"get_products", "get_signals"} and "cache_scope" not in result diff --git a/src/adcp/validation/_response_envelope.py b/src/adcp/validation/_response_envelope.py index d98ea9c59..58c999a33 100644 --- a/src/adcp/validation/_response_envelope.py +++ b/src/adcp/validation/_response_envelope.py @@ -5,8 +5,14 @@ grafting ``completed`` onto either makes the result fail the pinned response schema. From 3.3.0-beta.3 every task response and async status payload composes ``core/protocol-envelope.json`` (adcontextprotocol/adcp#8547), whose -``status`` is required, so the exception ends. Keep the behavioral tests in -``test_compact_lifecycle_matrix.py`` aligned when adding another exception. +``status`` is required, so the exception ends. + +The served release's own response schema decides, not its version string, so a +3.2.x release that later composes the envelope needs no code change (as in the +TypeScript SDK, adcontextprotocol/adcp-client#3493). The version comparison is +only the answer for a release that ships no schema for the tool. Keep the +behavioral tests in ``test_compact_lifecycle_matrix.py`` and +``test_response_status_gating.py`` aligned when adding another exception. """ from __future__ import annotations @@ -26,7 +32,11 @@ def uses_task_status_envelope(tool_name: str, version: str | None = None) -> boo """Return whether a tool response uses the task ``status`` envelope at ``version``. ``version`` is the release the response is shaped for; ``None`` is the - SDK's pin. An unparseable version keeps the compact exception. + SDK's pin. Only the compact tools in ``_NATIVE_COMPACT_ENVELOPE_TOOLS`` ever + answer ``False``, and only for a release whose response schema for them does + not compose ``core/protocol-envelope.json``. A release with no schema for the + tool, or an unparseable version, keeps the compact exception unless the + version is at or past :data:`ENVELOPE_ON_EVERY_RESPONSE_FROM`. """ if tool_name not in _NATIVE_COMPACT_ENVELOPE_TOOLS: return True @@ -35,7 +45,14 @@ def uses_task_status_envelope(tool_name: str, version: str | None = None) -> boo is_adcp_version_at_least, resolve_adcp_version_alias, ) + from adcp.validation.schema_loader import response_composes_protocol_envelope + try: + composed = response_composes_protocol_envelope(tool_name, version=version) + except ValueError: + composed = None + if composed is not None: + return composed release = _read_packaged_version() if version is None else version try: return is_adcp_version_at_least( diff --git a/src/adcp/validation/schema_loader.py b/src/adcp/validation/schema_loader.py index d35d9593d..c1ad50f3e 100644 --- a/src/adcp/validation/schema_loader.py +++ b/src/adcp/validation/schema_loader.py @@ -275,6 +275,9 @@ def __init__(self, root: _SchemaRoot, bundle_key: str) -> None: #: ``MAJOR.MINOR`` while its documents reference the exact release, #: for example ``/schemas/3.2.1/`` inside the ``3.2`` cache. self.path_versions: tuple[str, ...] = (bundle_key,) + #: ``tool_name`` -> whether that release's sync response schema composes + #: ``core/protocol-envelope.json``. Answered from the schema document. + self.composes_envelope: dict[str, bool] = {} def _bundle_path_versions(root: _SchemaRoot, bundle_key: str) -> tuple[str, ...]: @@ -1012,6 +1015,58 @@ def get_schema( ) +def _is_protocol_envelope_arm(arm: Any) -> bool: + """Whether a root ``allOf`` arm is ``core/protocol-envelope.json``. + + Bundled schemas arrive with the reference already inlined, so the arm is + recognised by what the envelope contributes: a required ``status``. An + unresolved reference to the envelope document counts too. + """ + if not isinstance(arm, dict): + return False + ref = arm.get("$ref") + if isinstance(ref, str): + return urldefrag(ref)[0].endswith("/core/protocol-envelope.json") + required = arm.get("required") + properties = arm.get("properties") + return ( + isinstance(required, list) + and "status" in required + and isinstance(properties, dict) + and "status" in properties + ) + + +def response_composes_protocol_envelope( + tool_name: str, + *, + version: str | None = None, +) -> bool | None: + """Whether the release's own response schema for ``tool_name`` composes the envelope. + + The envelope (``core/protocol-envelope.json``) requires ``status``. A release + schema that lists it in the root ``allOf`` therefore requires ``status`` on a + response; one that does not either forbids it (a closed 3.2.x + ``list_products`` root) or does not define it for the success arm. The + release's schema is the authority, not its version string, so a maintenance + release that composes the envelope changes the answer with no code change. + Returns ``None`` when no schema for the tool ships for ``version``. + """ + state = _ensure_state(version) + if state is None: + return None + cached = state.composes_envelope.get(tool_name) + if cached is not None: + return cached + schema = get_schema(tool_name, "sync", version=version) + if schema is None: + return None + arms = schema.get("allOf") + composed = isinstance(arms, list) and any(_is_protocol_envelope_arm(arm) for arm in arms) + state.composes_envelope[tool_name] = composed + return composed + + def get_named_schema_document( relative_path: str, *, diff --git a/tests/test_response_status_gating.py b/tests/test_response_status_gating.py new file mode 100644 index 000000000..c1f0559f5 --- /dev/null +++ b/tests/test_response_status_gating.py @@ -0,0 +1,191 @@ +"""``status`` on compact responses follows the served release's own schema. + +AdCP 3.3 composes ``core/protocol-envelope.json`` into every response root, so +``status`` is REQUIRED on ``list_products`` and ``decline_proposals`` there. The +closed 3.2.x roots have none (``list_products`` forbids it; ``decline_proposals`` +permits it only on its submitted arm). The SDK reads the answer from the release's +schema instead of a version string, and it never strips a field for any other +tool or release (matches adcontextprotocol/adcp-client#3493). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from adcp.compat.versions import convert_payload +from adcp.server.mcp_tools import _normalize_response_envelope +from adcp.types.domains.core.protocol_envelope import ProtocolEnvelope +from adcp.types.domains.media_buy.decline_proposals_response import DeclineProposalsResponse1 +from adcp.types.domains.media_buy.list_products_response import ListProductsResponse1 +from adcp.validation import validate_response +from adcp.validation._response_envelope import uses_task_status_envelope +from adcp.validation.schema_loader import _is_protocol_envelope_arm, get_validator + +COMPOSED = "3.3-beta.3" +CLOSED = "3.2" + +LISTED: dict[str, Any] = { + "outcome": "listed", + "products": [], + "feed_version": "v1", + "cache_scope": "public", +} +DECLINED: dict[str, Any] = {"results": [{"proposal_id": "proposal-1", "outcome": "declined"}]} +COMPACT = [("list_products", LISTED), ("decline_proposals", DECLINED)] + + +@pytest.mark.parametrize("tool", ["list_products", "decline_proposals"]) +def test_release_schema_decides_whether_status_belongs(tool: str) -> None: + assert uses_task_status_envelope(tool, COMPOSED) is True + assert uses_task_status_envelope(tool, "3.3.0-beta.3") is True + assert uses_task_status_envelope(tool, None) is True # the SDK pin is 3.3.0-beta.3 + assert uses_task_status_envelope(tool, CLOSED) is False + assert uses_task_status_envelope(tool, "3.2.7") is False + # A release that predates the composition keeps its own contract even when + # it is a 3.3 prerelease. + assert uses_task_status_envelope(tool, "3.3-beta.2") is False + + +@pytest.mark.parametrize("tool", ["get_products", "create_media_buy", "sync_creatives"]) +@pytest.mark.parametrize("version", [COMPOSED, CLOSED, None]) +def test_every_other_tool_keeps_the_status_envelope(tool: str, version: str | None) -> None: + assert uses_task_status_envelope(tool, version) is True + + +def test_a_release_with_no_schema_falls_back_to_the_release_that_composed_the_envelope() -> None: + """No schema to consult: an unparseable version keeps the compact exception, and a + release at or past the first one that composes the envelope has it.""" + assert uses_task_status_envelope("list_products", "not-a-version") is False + assert uses_task_status_envelope("list_products", "9.9") is True + assert uses_task_status_envelope("decline_proposals", "3.2.99") is False + + +def test_a_3_2_release_that_composes_the_envelope_flips_the_answer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The release's schema decides: serve the composed schema as 3.2's and the answer follows.""" + from adcp.validation import schema_loader + + composed = schema_loader.get_schema("list_products", "sync", version=COMPOSED) + assert composed is not None + state = schema_loader._ensure_state(CLOSED) + assert state is not None + monkeypatch.setattr(state, "composes_envelope", {}) + assert uses_task_status_envelope("list_products", CLOSED) is False + + monkeypatch.setattr(state, "composes_envelope", {}) + monkeypatch.setattr(schema_loader, "get_schema", lambda *args, **kwargs: composed) + assert uses_task_status_envelope("list_products", CLOSED) is True + + +@pytest.mark.parametrize(("tool", "body"), COMPACT) +def test_composed_release_requires_status_and_closed_release_refuses_it( + tool: str, body: dict[str, Any] +) -> None: + composed = get_validator(tool, "sync", version=COMPOSED) + closed = get_validator(tool, "sync", version=CLOSED) + assert composed is not None and closed is not None + with_status = {**body, "status": "completed"} + assert composed.is_valid(with_status) + assert not composed.is_valid(body) + assert closed.is_valid(body) + assert not closed.is_valid(with_status) + + +@pytest.mark.parametrize(("tool", "body"), COMPACT) +def test_validate_response_supplies_the_status_a_composed_release_requires( + tool: str, body: dict[str, Any] +) -> None: + assert validate_response(tool, body, version=COMPOSED).valid + assert validate_response(tool, body, version=CLOSED).valid + assert not validate_response(tool, {**body, "status": "completed"}, version=CLOSED).valid + + +@pytest.mark.parametrize(("tool", "body"), COMPACT) +def test_server_emits_status_only_where_the_served_release_has_it( + tool: str, body: dict[str, Any] +) -> None: + served_3_3 = dict(body) + _normalize_response_envelope(tool, served_3_3, {}, adcp_version=COMPOSED) + assert served_3_3["status"] == "completed" + + served_3_2 = dict(body) + _normalize_response_envelope(tool, served_3_2, {}, adcp_version=CLOSED) + assert "status" not in served_3_2 + + +def test_typed_default_status_does_not_reach_a_closed_3_2_root() -> None: + dumped = ListProductsResponse1(**LISTED).model_dump(mode="json", exclude_none=True) + assert dumped["status"] == "completed" # the typed model carries the 3.3 default + _normalize_response_envelope("list_products", dumped, {}, adcp_version=CLOSED) + assert "status" not in dumped + assert validate_response("list_products", dumped, version=CLOSED).valid + + +def test_a_submitted_decline_keeps_its_status_under_both_releases() -> None: + for version in (COMPOSED, CLOSED): + submitted = {"status": "submitted", "task_id": "task-1"} + _normalize_response_envelope("decline_proposals", submitted, {}, adcp_version=version) + assert submitted == {"status": "submitted", "task_id": "task-1"} + + +def test_conversion_down_drops_the_status_a_3_2_root_refuses() -> None: + for tool, body in COMPACT: + converted = convert_payload( + tool, + {**body, "status": "completed"}, + direction="sync", + source_version=COMPOSED, + target_version=CLOSED, + ) + assert "status" not in converted.payload + assert [loss.pointer for loss in converted.losses] == ["/status"] + + +def test_conversion_up_supplies_the_status_a_3_3_root_requires() -> None: + for tool, body in COMPACT: + converted = convert_payload( + tool, body, direction="sync", source_version=CLOSED, target_version=COMPOSED + ) + assert converted.payload["status"] == "completed" + assert validate_response(tool, converted.payload, version=COMPOSED).valid + + +def test_conversion_down_keeps_a_submitted_status() -> None: + converted = convert_payload( + "decline_proposals", + {"status": "submitted", "task_id": "task-1"}, + direction="submitted", + source_version=COMPOSED, + target_version=CLOSED, + ) + assert converted.payload["status"] == "submitted" + assert converted.losses == () + + +def test_envelope_arm_is_recognised_inlined_or_by_reference() -> None: + assert _is_protocol_envelope_arm( + {"$ref": "https://adcontextprotocol.org/schemas/3.3.0-beta.3/core/protocol-envelope.json"} + ) + assert _is_protocol_envelope_arm( + {"required": ["status"], "properties": {"status": {"type": "string"}}} + ) + assert not _is_protocol_envelope_arm({"$ref": "https://example.test/core/context.json"}) + assert not _is_protocol_envelope_arm({"properties": {"status": {"type": "string"}}}) + assert not _is_protocol_envelope_arm("not a schema") + + +@pytest.mark.parametrize("model", [ListProductsResponse1, DeclineProposalsResponse1]) +def test_generated_responses_inherit_the_protocol_envelope_fields(model: type) -> None: + assert issubclass(model, ProtocolEnvelope) + for field in ( + "status", + "task_id", + "timestamp", + "adcp_version", + "adcp_major_version", + "replayed", + ): + assert field in model.model_fields, field