diff --git a/integration_tests/_contract_support.py b/integration_tests/_contract_support.py index e4058ee2da..cdc4c8e9a8 100644 --- a/integration_tests/_contract_support.py +++ b/integration_tests/_contract_support.py @@ -869,6 +869,61 @@ def _merge_public_class_contracts( return result +def _validate_voice_public_class_contract_policy( + release_policy: SubmoduleExportPolicy, + agents_module: Any | None, +) -> None: + voice_class_exports: list[tuple[Mapping[str, str], type[Any]]] = [] + for entry in release_policy.canonical_imports: + if entry["module"] != "agents.voice": + continue + canonical_module = _import_contract_module(entry["canonical_module"], agents_module) + value = getattr(canonical_module, entry["canonical_name"], None) + if isinstance(value, type): + voice_class_exports.append((entry, value)) + + abstract_bases = { + class_value for _, class_value in voice_class_exports if inspect.isabstract(class_value) + } + policy_by_identity = { + (entry["module"], entry["class_name"]): entry + for entry in release_policy.public_class_contracts + } + missing_entries: list[dict[str, object]] = [] + for canonical_import, class_value in voice_class_exports: + is_abstract = inspect.isabstract(class_value) + if not is_abstract and not any( + issubclass(class_value, abstract_base) for abstract_base in abstract_bases + ): + continue + + identity = ( + canonical_import["canonical_module"], + canonical_import["canonical_name"], + ) + policy_entry = policy_by_identity.get(identity) + has_explicit_state = policy_entry is not None and ( + (is_abstract and ("abstract" in policy_entry or "abstract_members" in policy_entry)) + or (not is_abstract and policy_entry.get("abstract") is False) + ) + if not has_explicit_state: + missing_entries.append( + { + "abstract": is_abstract, + "class_name": canonical_import["canonical_name"], + "module": canonical_import["canonical_module"], + } + ) + + if missing_entries: + raise ValueError( + "Cannot promote the public Voice API without explicit public_class_contracts " + "coverage for its abstract bases and concrete implementations. Add or correct " + f"these policy entries: {missing_entries!r}. Required classes are derived from " + "canonical agents.voice imports and their public abstract-base relationships." + ) + + def _public_property_identity(entry: Mapping[str, Any]) -> tuple[str, str, str]: if "class_name" in entry: return ("class_name", cast(str, entry["module"]), cast(str, entry["class_name"])) @@ -1493,6 +1548,9 @@ def build_released_api_contract( ) -> dict[str, Any]: """Build the next rolling release contract from the current public surface.""" agents = agents_module or importlib.import_module("agents") + if release_policy is not None: + _validate_voice_public_class_contract_policy(release_policy, agents_module) + compatibility_errors = validate_released_api_contract(contract, agents_module=agents) if compatibility_errors: details = "\n".join(f"- {error}" for error in compatibility_errors) diff --git a/tests/fixtures/released_api_contract_policy.json b/tests/fixtures/released_api_contract_policy.json index 8053fe0c97..813ae4785b 100644 --- a/tests/fixtures/released_api_contract_policy.json +++ b/tests/fixtures/released_api_contract_policy.json @@ -606,6 +606,11 @@ "class_name": "VoiceModelProvider", "module": "agents.voice.model" }, + { + "abstract": false, + "class_name": "OpenAIVoiceModelProvider", + "module": "agents.voice.models.openai_model_provider" + }, { "abstract": false, "class_name": "OpenAISTTModel", @@ -627,6 +632,11 @@ ], "class_name": "VoiceWorkflowBase", "module": "agents.voice.workflow" + }, + { + "abstract": false, + "class_name": "SingleAgentVoiceWorkflow", + "module": "agents.voice.workflow" } ], "public_properties": [ diff --git a/tests/test_released_api_contract.py b/tests/test_released_api_contract.py index 8d3657c7b6..1cabc39756 100644 --- a/tests/test_released_api_contract.py +++ b/tests/test_released_api_contract.py @@ -7,7 +7,7 @@ import subprocess import sys from collections.abc import AsyncIterator, Callable, Iterator -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, replace from enum import Enum from importlib.metadata import version from inspect import Parameter, Signature @@ -2592,6 +2592,121 @@ class PublicState(TypedDict, total=False): assert updated["callables"]["agents.submodule.NewPublic"] == _callable_contract(NewPublic) +def test_release_contract_promotion_rejects_missing_voice_concrete_state_policy() -> None: + contract = load_api_contract(CONTRACT) + policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + omitted_classes = {"OpenAIVoiceModelProvider", "SingleAgentVoiceWorkflow"} + incomplete_policy = replace( + policy, + public_class_contracts=tuple( + entry + for entry in policy.public_class_contracts + if entry["class_name"] not in omitted_classes + ), + ) + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline=contract["baseline"], + baseline_commit=contract["baseline_commit"], + release_policy=incomplete_policy, + ) + + message = str(exc_info.value) + assert "Cannot promote the public Voice API" in message + assert "OpenAIVoiceModelProvider" in message + assert "SingleAgentVoiceWorkflow" in message + assert "abstract': False" in message + assert "canonical agents.voice imports" in message + + +def test_release_contract_promotion_rejects_new_public_voice_implementation_without_state_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class PublicVoiceBase(abc.ABC): + @abc.abstractmethod + def run(self) -> None: + pass + + class NewPublicVoiceImplementation(PublicVoiceBase): + def run(self) -> None: + pass + + class UnrelatedPublicClass: + pass + + agents_module = SimpleNamespace(__all__=[]) + modules = { + "agents.voice.base": SimpleNamespace(PublicVoiceBase=PublicVoiceBase), + "agents.voice.implementation": SimpleNamespace( + NewPublicVoiceImplementation=NewPublicVoiceImplementation, + UnrelatedPublicClass=UnrelatedPublicClass, + ), + } + monkeypatch.setattr( + contract_support, + "_import_contract_module", + lambda module_name, _agents_module: modules[module_name], + ) + contract: dict[str, Any] = { + "baseline": "v0.22.0", + "baseline_commit": "a" * 40, + "required_top_level_exports": [], + "public_modules": ["agents"], + "canonical_imports": [], + "public_class_contracts": [], + "public_properties": [], + "public_type_aliases": [], + "public_typed_dicts": [], + "callables": {}, + } + canonical_imports = ( + { + "canonical_module": "agents.voice.base", + "canonical_name": "PublicVoiceBase", + "module": "agents.voice", + "name": "PublicVoiceBase", + }, + { + "canonical_module": "agents.voice.implementation", + "canonical_name": "NewPublicVoiceImplementation", + "module": "agents.voice", + "name": "NewPublicVoiceImplementation", + }, + { + "canonical_module": "agents.voice.implementation", + "canonical_name": "UnrelatedPublicClass", + "module": "agents.voice", + "name": "UnrelatedPublicClass", + }, + ) + release_policy = _release_policy( + {}, + canonical_imports=canonical_imports, + public_class_contracts=( + { + "abstract_members": ["run"], + "class_name": "PublicVoiceBase", + "module": "agents.voice.base", + }, + ), + ) + + with pytest.raises(ValueError) as exc_info: + build_released_api_contract( + contract, + baseline="v0.22.1", + baseline_commit="b" * 40, + agents_module=agents_module, + release_policy=release_policy, + ) + + message = str(exc_info.value) + assert "NewPublicVoiceImplementation" in message + assert "UnrelatedPublicClass" not in message + + def test_typed_dict_only_promotion_updates_baseline_commit( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -3529,6 +3644,7 @@ def test_repository_release_policy_declares_public_optional_modules() -> None: def test_repository_release_policy_declares_public_state_surfaces() -> None: policy = load_submodule_export_policy(CONTRACT.with_name("released_api_contract_policy.json")) + contract_support._validate_voice_public_class_contract_policy(policy, None) expected_modules = { "agents.realtime.testing", "agents.testing", @@ -3637,6 +3753,11 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None: "module": "agents.voice.model", "abstract_members": ["get_stt_model", "get_tts_model"], }, + { + "abstract": False, + "class_name": "OpenAIVoiceModelProvider", + "module": "agents.voice.models.openai_model_provider", + }, { "abstract": False, "class_name": "OpenAISTTModel", @@ -3657,6 +3778,11 @@ def test_repository_release_policy_declares_public_state_surfaces() -> None: "module": "agents.voice.workflow", "abstract_members": ["run"], }, + { + "abstract": False, + "class_name": "SingleAgentVoiceWorkflow", + "module": "agents.voice.workflow", + }, ) assert policy.public_type_aliases == ( {