From 39910042302708b4e824a6ba308b44633ff0789d Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Wed, 6 May 2026 17:20:58 +0800 Subject: [PATCH 1/5] test: isolate make_settings from ambient env sources (#474) --- tests/support/helpers.py | 17 ++++++++++++++++- tests/support/test_helpers.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/support/test_helpers.py diff --git a/tests/support/helpers.py b/tests/support/helpers.py index af4abf7..04c9bcd 100644 --- a/tests/support/helpers.py +++ b/tests/support/helpers.py @@ -9,6 +9,7 @@ from a2a.server.agent_execution import RequestContext from a2a.server.context import ServerCallContext from a2a.types import Message, Part, Role, SendMessageConfiguration, SendMessageRequest +from pydantic_settings import PydanticBaseSettingsSource from opencode_a2a.config import Settings from opencode_a2a.contracts.extensions import ( @@ -20,6 +21,20 @@ from opencode_a2a.server.context_helpers import normalize_server_call_context +class _IsolatedTestSettings(Settings): + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[Settings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + del settings_cls, env_settings, dotenv_settings, file_secret_settings + return (init_settings,) + + def _build_test_static_auth_credentials(**overrides: Any) -> tuple[dict[str, Any], ...]: explicit_credentials = overrides.pop("a2a_static_auth_credentials", None) has_bearer_override = "test_bearer_token" in overrides @@ -74,7 +89,7 @@ def make_settings(**overrides: Any) -> Settings: } base.update(overrides) base["a2a_static_auth_credentials"] = _build_test_static_auth_credentials(**base) - return Settings(**base) + return _IsolatedTestSettings(**base) def make_basic_auth_header(username: str, password: str) -> dict[str, str]: diff --git a/tests/support/test_helpers.py b/tests/support/test_helpers.py new file mode 100644 index 0000000..7eae416 --- /dev/null +++ b/tests/support/test_helpers.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from unittest import mock + +from tests.support.helpers import make_settings + + +def test_make_settings_ignores_environment_and_dotenv_sources(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text( + "\n".join( + [ + "OPENCODE_BASE_URL=http://dotenv-upstream.test", + "A2A_PUBLIC_URL=http://dotenv-public.test", + "A2A_HOST=dotenv-host", + ] + ), + encoding="utf-8", + ) + + with mock.patch.dict( + "os.environ", + { + "OPENCODE_BASE_URL": "http://env-upstream.test", + "A2A_PUBLIC_URL": "http://env-public.test", + "A2A_HOST": "env-host", + }, + clear=False, + ): + settings = make_settings() + + assert settings.opencode_base_url == "http://127.0.0.1:4096" + assert settings.a2a_public_url == "http://127.0.0.1:8000" + assert settings.a2a_host == "127.0.0.1" From 5e4745154958bef1db7a125e0b9a3a30c25b09a7 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Wed, 6 May 2026 17:29:07 +0800 Subject: [PATCH 2/5] test: extract isolated settings helper under tests support (#474) --- tests/config/test_settings.py | 30 +++++++++++++ tests/support/helpers.py | 75 +------------------------------- tests/support/settings.py | 82 +++++++++++++++++++++++++++++++++++ tests/support/test_helpers.py | 34 --------------- 4 files changed, 113 insertions(+), 108 deletions(-) create mode 100644 tests/support/settings.py delete mode 100644 tests/support/test_helpers.py diff --git a/tests/config/test_settings.py b/tests/config/test_settings.py index 8d11e53..c34f233 100644 --- a/tests/config/test_settings.py +++ b/tests/config/test_settings.py @@ -11,6 +11,7 @@ A2A_PROTOCOL_VERSION, A2A_SUPPORTED_PROTOCOL_VERSIONS, ) +from tests.support.settings import make_settings def test_settings_missing_required(): @@ -93,6 +94,35 @@ def test_settings_valid(): assert A2A_SUPPORTED_PROTOCOL_VERSIONS == ("1.0",) +def test_make_settings_ignores_ambient_environment_sources(tmp_path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / ".env").write_text( + "\n".join( + [ + "OPENCODE_BASE_URL=http://dotenv-upstream.test", + "A2A_PUBLIC_URL=http://dotenv-public.test", + "A2A_HOST=dotenv-host", + ] + ), + encoding="utf-8", + ) + + with mock.patch.dict( + os.environ, + { + "OPENCODE_BASE_URL": "http://env-upstream.test", + "A2A_PUBLIC_URL": "http://env-public.test", + "A2A_HOST": "env-host", + }, + clear=False, + ): + settings = make_settings() + + assert settings.opencode_base_url == "http://127.0.0.1:4096" + assert settings.a2a_public_url == "http://127.0.0.1:8000" + assert settings.a2a_host == "127.0.0.1" + + def test_settings_allow_explicit_memory_backend() -> None: env = { "A2A_STATIC_AUTH_CREDENTIALS": json.dumps( diff --git a/tests/support/helpers.py b/tests/support/helpers.py index 04c9bcd..01f2c2a 100644 --- a/tests/support/helpers.py +++ b/tests/support/helpers.py @@ -1,7 +1,5 @@ from __future__ import annotations -import tempfile -import uuid from base64 import b64encode from typing import Any from unittest.mock import MagicMock, PropertyMock @@ -9,7 +7,6 @@ from a2a.server.agent_execution import RequestContext from a2a.server.context import ServerCallContext from a2a.types import Message, Part, Role, SendMessageConfiguration, SendMessageRequest -from pydantic_settings import PydanticBaseSettingsSource from opencode_a2a.config import Settings from opencode_a2a.contracts.extensions import ( @@ -19,77 +16,7 @@ ) from opencode_a2a.opencode_upstream_client import OpencodeMessage, OpencodeMessagePage from opencode_a2a.server.context_helpers import normalize_server_call_context - - -class _IsolatedTestSettings(Settings): - @classmethod - def settings_customise_sources( - cls, - settings_cls: type[Settings], - init_settings: PydanticBaseSettingsSource, - env_settings: PydanticBaseSettingsSource, - dotenv_settings: PydanticBaseSettingsSource, - file_secret_settings: PydanticBaseSettingsSource, - ) -> tuple[PydanticBaseSettingsSource, ...]: - del settings_cls, env_settings, dotenv_settings, file_secret_settings - return (init_settings,) - - -def _build_test_static_auth_credentials(**overrides: Any) -> tuple[dict[str, Any], ...]: - explicit_credentials = overrides.pop("a2a_static_auth_credentials", None) - has_bearer_override = "test_bearer_token" in overrides - has_basic_username_override = "test_basic_username" in overrides - has_basic_password_override = "test_basic_password" in overrides # pragma: allowlist secret - bearer_token = overrides.pop("test_bearer_token", "test-token") - basic_username = overrides.pop("test_basic_username", None) - basic_password = overrides.pop("test_basic_password", None) # pragma: allowlist secret - - if explicit_credentials is not None: - if ( - (has_bearer_override and bearer_token is not None) - or (has_basic_username_override and basic_username is not None) - or (has_basic_password_override and basic_password is not None) - ): - raise ValueError( - "Test settings helper does not combine a2a_static_auth_credentials " - "with shorthand auth overrides." - ) - return tuple(explicit_credentials) - - credentials: list[dict[str, Any]] = [] - if bearer_token is not None: - credentials.append( - { - "scheme": "bearer", - "token": bearer_token, - "principal": "automation", - } - ) - if basic_username is not None or basic_password is not None: - if not basic_username or not basic_password: - raise ValueError( - "Test settings helper requires both basic username and password overrides." - ) - credentials.append( - { - "scheme": "basic", - "username": basic_username, - "password": basic_password, - } - ) - return tuple(credentials) - - -def make_settings(**overrides: Any) -> Settings: - base: dict[str, Any] = { - "opencode_base_url": "http://127.0.0.1:4096", - "a2a_task_store_database_url": ( - f"sqlite+aiosqlite:///{tempfile.gettempdir()}/opencode-a2a-test-{uuid.uuid4().hex}.db" - ), - } - base.update(overrides) - base["a2a_static_auth_credentials"] = _build_test_static_auth_credentials(**base) - return _IsolatedTestSettings(**base) +from tests.support.settings import make_settings def make_basic_auth_header(username: str, password: str) -> dict[str, str]: diff --git a/tests/support/settings.py b/tests/support/settings.py new file mode 100644 index 0000000..3760bfd --- /dev/null +++ b/tests/support/settings.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import tempfile +import uuid +from typing import Any + +from pydantic_settings import PydanticBaseSettingsSource + +from opencode_a2a.config import Settings + + +class _IsolatedTestSettings(Settings): + """Test-only settings variant that ignores ambient env and .env sources.""" + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[Settings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + del settings_cls, env_settings, dotenv_settings, file_secret_settings + return (init_settings,) + + +def _build_test_static_auth_credentials(**overrides: Any) -> tuple[dict[str, Any], ...]: + explicit_credentials = overrides.pop("a2a_static_auth_credentials", None) + has_bearer_override = "test_bearer_token" in overrides + has_basic_username_override = "test_basic_username" in overrides + has_basic_password_override = "test_basic_password" in overrides # pragma: allowlist secret + bearer_token = overrides.pop("test_bearer_token", "test-token") + basic_username = overrides.pop("test_basic_username", None) + basic_password = overrides.pop("test_basic_password", None) # pragma: allowlist secret + + if explicit_credentials is not None: + if ( + (has_bearer_override and bearer_token is not None) + or (has_basic_username_override and basic_username is not None) + or (has_basic_password_override and basic_password is not None) + ): + raise ValueError( + "Test settings helper does not combine a2a_static_auth_credentials " + "with shorthand auth overrides." + ) + return tuple(explicit_credentials) + + credentials: list[dict[str, Any]] = [] + if bearer_token is not None: + credentials.append( + { + "scheme": "bearer", + "token": bearer_token, + "principal": "automation", + } + ) + if basic_username is not None or basic_password is not None: + if not basic_username or not basic_password: + raise ValueError( + "Test settings helper requires both basic username and password overrides." + ) + credentials.append( + { + "scheme": "basic", + "username": basic_username, + "password": basic_password, + } + ) + return tuple(credentials) + + +def make_settings(**overrides: Any) -> Settings: + base: dict[str, Any] = { + "opencode_base_url": "http://127.0.0.1:4096", + "a2a_task_store_database_url": ( + f"sqlite+aiosqlite:///{tempfile.gettempdir()}/opencode-a2a-test-{uuid.uuid4().hex}.db" + ), + } + base.update(overrides) + base["a2a_static_auth_credentials"] = _build_test_static_auth_credentials(**base) + return _IsolatedTestSettings(**base) diff --git a/tests/support/test_helpers.py b/tests/support/test_helpers.py deleted file mode 100644 index 7eae416..0000000 --- a/tests/support/test_helpers.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from unittest import mock - -from tests.support.helpers import make_settings - - -def test_make_settings_ignores_environment_and_dotenv_sources(tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / ".env").write_text( - "\n".join( - [ - "OPENCODE_BASE_URL=http://dotenv-upstream.test", - "A2A_PUBLIC_URL=http://dotenv-public.test", - "A2A_HOST=dotenv-host", - ] - ), - encoding="utf-8", - ) - - with mock.patch.dict( - "os.environ", - { - "OPENCODE_BASE_URL": "http://env-upstream.test", - "A2A_PUBLIC_URL": "http://env-public.test", - "A2A_HOST": "env-host", - }, - clear=False, - ): - settings = make_settings() - - assert settings.opencode_base_url == "http://127.0.0.1:4096" - assert settings.a2a_public_url == "http://127.0.0.1:8000" - assert settings.a2a_host == "127.0.0.1" From 13bd6d5b942efbfa6e8f5a03847bc7b99be95f9d Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Wed, 6 May 2026 17:35:20 +0800 Subject: [PATCH 3/5] test: drop obsolete make_settings helper re-export (#474) --- tests/contracts/test_extension_contract_consistency.py | 2 +- tests/execution/test_directory_validation.py | 3 ++- tests/execution/test_metrics.py | 3 ++- tests/execution/test_multipart_input.py | 3 ++- tests/execution/test_session_ownership.py | 2 +- tests/execution/test_streaming_output_contract_logging.py | 2 +- tests/jsonrpc/test_application_dispatch.py | 3 ++- tests/jsonrpc/test_dispatch_registry.py | 3 ++- tests/jsonrpc/test_jsonrpc_unsupported_method.py | 2 +- tests/jsonrpc/test_opencode_session_extension_commands.py | 3 ++- .../jsonrpc/test_opencode_session_extension_interrupts.py | 2 +- .../jsonrpc/test_opencode_session_extension_lifecycle.py | 2 +- .../test_opencode_session_extension_prompt_async.py | 2 +- tests/jsonrpc/test_opencode_session_extension_queries.py | 2 +- .../jsonrpc/test_opencode_workspace_control_extension.py | 3 ++- tests/profile/test_profile_runtime.py | 2 +- tests/server/test_agent_card.py | 2 +- tests/server/test_app_behaviors.py | 2 +- tests/server/test_auth.py | 2 +- tests/server/test_database_app_persistence.py | 2 +- tests/server/test_state_store.py | 2 +- tests/server/test_task_store_factory.py | 3 ++- tests/server/test_transport_contract.py | 2 +- tests/support/helpers.py | 8 +++++--- tests/support/streaming_output.py | 2 +- tests/upstream/test_opencode_upstream_client_params.py | 2 +- 26 files changed, 38 insertions(+), 28 deletions(-) diff --git a/tests/contracts/test_extension_contract_consistency.py b/tests/contracts/test_extension_contract_consistency.py index 00ad562..f1d6dd3 100644 --- a/tests/contracts/test_extension_contract_consistency.py +++ b/tests/contracts/test_extension_contract_consistency.py @@ -47,8 +47,8 @@ from tests.support.helpers import ( DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient, ) -from tests.support.helpers import make_settings from tests.support.session_extensions import _extension_headers +from tests.support.settings import make_settings @pytest.mark.parametrize( diff --git a/tests/execution/test_directory_validation.py b/tests/execution/test_directory_validation.py index bcafc50..e6af9f9 100644 --- a/tests/execution/test_directory_validation.py +++ b/tests/execution/test_directory_validation.py @@ -7,7 +7,8 @@ from opencode_a2a.execution.executor import OpencodeAgentExecutor from opencode_a2a.opencode_upstream_client import OpencodeUpstreamClient -from tests.support.helpers import make_request_context_mock, make_settings +from tests.support.helpers import make_request_context_mock +from tests.support.settings import make_settings @pytest.fixture diff --git a/tests/execution/test_metrics.py b/tests/execution/test_metrics.py index ee015ff..458c9f2 100644 --- a/tests/execution/test_metrics.py +++ b/tests/execution/test_metrics.py @@ -20,7 +20,8 @@ from opencode_a2a.execution.executor import OpencodeAgentExecutor, _StreamOutputState from opencode_a2a.server.application import OpencodeRequestHandler -from tests.support.helpers import DummyEventQueue, make_settings +from tests.support.helpers import DummyEventQueue +from tests.support.settings import make_settings def _make_message_send_params() -> SendMessageRequest: diff --git a/tests/execution/test_multipart_input.py b/tests/execution/test_multipart_input.py index 2b38e3d..c7a5db4 100644 --- a/tests/execution/test_multipart_input.py +++ b/tests/execution/test_multipart_input.py @@ -4,7 +4,8 @@ from opencode_a2a.a2a_utils import make_data_part from opencode_a2a.execution.executor import OpencodeAgentExecutor from opencode_a2a.opencode_upstream_client import OpencodeMessage -from tests.support.helpers import DummyEventQueue, make_request_context_with_parts, make_settings +from tests.support.helpers import DummyEventQueue, make_request_context_with_parts +from tests.support.settings import make_settings class RecordingMultipartClient: diff --git a/tests/execution/test_session_ownership.py b/tests/execution/test_session_ownership.py index b585ec1..236fa24 100644 --- a/tests/execution/test_session_ownership.py +++ b/tests/execution/test_session_ownership.py @@ -19,8 +19,8 @@ from tests.support.helpers import ( configure_mock_client_runtime, make_request_context_mock, - make_settings, ) +from tests.support.settings import make_settings @pytest.fixture diff --git a/tests/execution/test_streaming_output_contract_logging.py b/tests/execution/test_streaming_output_contract_logging.py index 3f18351..33a7638 100644 --- a/tests/execution/test_streaming_output_contract_logging.py +++ b/tests/execution/test_streaming_output_contract_logging.py @@ -8,8 +8,8 @@ from tests.support.helpers import ( DummyEventQueue, make_request_context, - make_settings, ) +from tests.support.settings import make_settings from tests.support.streaming_output import ( DummyStreamingClient, _artifact_stream_meta, diff --git a/tests/jsonrpc/test_application_dispatch.py b/tests/jsonrpc/test_application_dispatch.py index 26602d0..6d6b34f 100644 --- a/tests/jsonrpc/test_application_dispatch.py +++ b/tests/jsonrpc/test_application_dispatch.py @@ -14,8 +14,9 @@ from opencode_a2a.contracts.extensions import SESSION_MANAGEMENT_EXTENSION_URI from opencode_a2a.jsonrpc.models import JSONRPCRequest from tests.support.async_iterators import iter_async -from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient, make_settings +from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient from tests.support.session_extensions import _BASE_SETTINGS, _jsonrpc_app +from tests.support.settings import make_settings def _build_dispatcher(monkeypatch: pytest.MonkeyPatch): diff --git a/tests/jsonrpc/test_dispatch_registry.py b/tests/jsonrpc/test_dispatch_registry.py index baa4a23..2213350 100644 --- a/tests/jsonrpc/test_dispatch_registry.py +++ b/tests/jsonrpc/test_dispatch_registry.py @@ -6,9 +6,10 @@ from opencode_a2a.a2a_protocol import CORE_JSONRPC_METHODS from opencode_a2a.contracts.extensions import SESSION_MANAGEMENT_EXTENSION_URI from opencode_a2a.jsonrpc.application import OpencodeSessionManagementJSONRPCApplication -from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient, make_settings +from tests.support.helpers import DummySessionQueryOpencodeUpstreamClient from tests.support.jsonrpc_error_assertions import assert_v1_error_reason, error_context_detail from tests.support.session_extensions import _BASE_SETTINGS, _extension_headers, _jsonrpc_app +from tests.support.settings import make_settings @pytest.mark.asyncio diff --git a/tests/jsonrpc/test_jsonrpc_unsupported_method.py b/tests/jsonrpc/test_jsonrpc_unsupported_method.py index 8827182..b99d5c9 100644 --- a/tests/jsonrpc/test_jsonrpc_unsupported_method.py +++ b/tests/jsonrpc/test_jsonrpc_unsupported_method.py @@ -3,7 +3,7 @@ from opencode_a2a.protocol_versions import A2A_PROTOCOL_VERSION from opencode_a2a.server.application import create_app -from tests.support.helpers import make_settings +from tests.support.settings import make_settings @pytest.mark.asyncio diff --git a/tests/jsonrpc/test_opencode_session_extension_commands.py b/tests/jsonrpc/test_opencode_session_extension_commands.py index 57c1f68..6c6a646 100644 --- a/tests/jsonrpc/test_opencode_session_extension_commands.py +++ b/tests/jsonrpc/test_opencode_session_extension_commands.py @@ -4,7 +4,7 @@ from tests.support.helpers import ( DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient, ) -from tests.support.helpers import make_basic_auth_header, make_settings +from tests.support.helpers import make_basic_auth_header from tests.support.jsonrpc_error_assertions import ( assert_v1_error_context, assert_v1_error_metadata_contains, @@ -16,6 +16,7 @@ _jsonrpc_app, _session_meta, ) +from tests.support.settings import make_settings @pytest.mark.asyncio diff --git a/tests/jsonrpc/test_opencode_session_extension_interrupts.py b/tests/jsonrpc/test_opencode_session_extension_interrupts.py index 3818758..afda4e9 100644 --- a/tests/jsonrpc/test_opencode_session_extension_interrupts.py +++ b/tests/jsonrpc/test_opencode_session_extension_interrupts.py @@ -6,12 +6,12 @@ from tests.support.helpers import ( DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient, ) -from tests.support.helpers import make_settings from tests.support.jsonrpc_error_assertions import ( assert_v1_error_reason, error_context_detail, ) from tests.support.session_extensions import _BASE_SETTINGS, _extension_headers +from tests.support.settings import make_settings @pytest.mark.asyncio diff --git a/tests/jsonrpc/test_opencode_session_extension_lifecycle.py b/tests/jsonrpc/test_opencode_session_extension_lifecycle.py index db18c20..df04466 100644 --- a/tests/jsonrpc/test_opencode_session_extension_lifecycle.py +++ b/tests/jsonrpc/test_opencode_session_extension_lifecycle.py @@ -6,7 +6,6 @@ from tests.support.helpers import ( DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient, ) -from tests.support.helpers import make_settings from tests.support.jsonrpc_error_assertions import assert_v1_error_reason from tests.support.session_extensions import ( _BASE_SETTINGS, @@ -14,6 +13,7 @@ _jsonrpc_app, _session_meta, ) +from tests.support.settings import make_settings def _identity_for_token(token: str) -> str: diff --git a/tests/jsonrpc/test_opencode_session_extension_prompt_async.py b/tests/jsonrpc/test_opencode_session_extension_prompt_async.py index 6ef10c0..e7dfe01 100644 --- a/tests/jsonrpc/test_opencode_session_extension_prompt_async.py +++ b/tests/jsonrpc/test_opencode_session_extension_prompt_async.py @@ -10,13 +10,13 @@ from tests.support.helpers import ( DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient, ) -from tests.support.helpers import make_settings from tests.support.jsonrpc_error_assertions import ( assert_v1_error_metadata_contains, assert_v1_error_reason, error_context_detail, ) from tests.support.session_extensions import _BASE_SETTINGS, _extension_headers, _jsonrpc_app +from tests.support.settings import make_settings @pytest.mark.asyncio diff --git a/tests/jsonrpc/test_opencode_session_extension_queries.py b/tests/jsonrpc/test_opencode_session_extension_queries.py index f04a59d..dbf7b1e 100644 --- a/tests/jsonrpc/test_opencode_session_extension_queries.py +++ b/tests/jsonrpc/test_opencode_session_extension_queries.py @@ -12,12 +12,12 @@ from tests.support.helpers import ( DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient, ) -from tests.support.helpers import make_settings from tests.support.jsonrpc_error_assertions import ( assert_v1_error_reason, error_context_detail, ) from tests.support.session_extensions import _BASE_SETTINGS, _extension_headers, _session_meta +from tests.support.settings import make_settings def _identity_for_token(token: str) -> str: diff --git a/tests/jsonrpc/test_opencode_workspace_control_extension.py b/tests/jsonrpc/test_opencode_workspace_control_extension.py index 572b1c9..9e3c3ea 100644 --- a/tests/jsonrpc/test_opencode_workspace_control_extension.py +++ b/tests/jsonrpc/test_opencode_workspace_control_extension.py @@ -4,12 +4,13 @@ from tests.support.helpers import ( DummySessionQueryOpencodeUpstreamClient as DummyOpencodeUpstreamClient, ) -from tests.support.helpers import make_basic_auth_header, make_settings +from tests.support.helpers import make_basic_auth_header from tests.support.jsonrpc_error_assertions import ( assert_v1_error_metadata_contains, assert_v1_error_reason, ) from tests.support.session_extensions import _BASE_SETTINGS, _extension_headers +from tests.support.settings import make_settings @pytest.mark.asyncio diff --git a/tests/profile/test_profile_runtime.py b/tests/profile/test_profile_runtime.py index 40fbaa5..0af0258 100644 --- a/tests/profile/test_profile_runtime.py +++ b/tests/profile/test_profile_runtime.py @@ -1,6 +1,6 @@ from opencode_a2a.profile.runtime import build_runtime_profile from opencode_a2a.protocol_versions import A2A_PROTOCOL_VERSION -from tests.support.helpers import make_settings +from tests.support.settings import make_settings def test_profile_runtime_splits_deployment_runtime_features_and_health_payload() -> None: diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index c50b6e5..e840ff4 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -24,7 +24,7 @@ from opencode_a2a.server.agent_card import ( build_agent_card, ) -from tests.support.helpers import make_settings +from tests.support.settings import make_settings def _security_requirements(card) -> list[dict[str, dict[str, list[str]]]]: diff --git a/tests/server/test_app_behaviors.py b/tests/server/test_app_behaviors.py index 1f09066..fef98bd 100644 --- a/tests/server/test_app_behaviors.py +++ b/tests/server/test_app_behaviors.py @@ -80,8 +80,8 @@ from tests.support.helpers import ( DummyChatOpencodeUpstreamClient, make_basic_auth_header, - make_settings, ) +from tests.support.settings import make_settings def _agent_card() -> AgentCard: diff --git a/tests/server/test_auth.py b/tests/server/test_auth.py index ccff057..513dcc1 100644 --- a/tests/server/test_auth.py +++ b/tests/server/test_auth.py @@ -12,7 +12,7 @@ build_static_auth_credentials, request_has_capability, ) -from tests.support.helpers import make_settings +from tests.support.settings import make_settings def _request_with_principal(principal: AuthenticatedPrincipal | None) -> Request: diff --git a/tests/server/test_database_app_persistence.py b/tests/server/test_database_app_persistence.py index 8e48fcc..6c4ba73 100644 --- a/tests/server/test_database_app_persistence.py +++ b/tests/server/test_database_app_persistence.py @@ -7,8 +7,8 @@ from a2a.types import Task, TaskState, TaskStatus from opencode_a2a.opencode_upstream_client import OpencodeMessage -from tests.support.helpers import make_settings from tests.support.session_extensions import _extension_headers +from tests.support.settings import make_settings def _task(task_id: str, *, context_id: str = "ctx-1") -> Task: diff --git a/tests/server/test_state_store.py b/tests/server/test_state_store.py index a62a047..4071abf 100644 --- a/tests/server/test_state_store.py +++ b/tests/server/test_state_store.py @@ -21,7 +21,7 @@ build_session_state_repository, initialize_state_repository, ) -from tests.support.helpers import make_settings +from tests.support.settings import make_settings async def _read_state_store_schema_version(engine) -> int | None: # noqa: ANN001 diff --git a/tests/server/test_task_store_factory.py b/tests/server/test_task_store_factory.py index dd17132..df7bce4 100644 --- a/tests/server/test_task_store_factory.py +++ b/tests/server/test_task_store_factory.py @@ -35,7 +35,8 @@ _task_model_to_core, _task_row_values, ) -from tests.support.helpers import make_request_context_mock, make_settings +from tests.support.helpers import make_request_context_mock +from tests.support.settings import make_settings def _task(task_id: str, *, context_id: str = "ctx-1") -> Task: diff --git a/tests/server/test_transport_contract.py b/tests/server/test_transport_contract.py index 7e6e562..ef972b8 100644 --- a/tests/server/test_transport_contract.py +++ b/tests/server/test_transport_contract.py @@ -45,8 +45,8 @@ DummyChatOpencodeUpstreamClient, DummySessionQueryOpencodeUpstreamClient, make_basic_auth_header, - make_settings, ) +from tests.support.settings import make_settings def _task_for_listing( diff --git a/tests/support/helpers.py b/tests/support/helpers.py index 01f2c2a..e33f832 100644 --- a/tests/support/helpers.py +++ b/tests/support/helpers.py @@ -16,7 +16,7 @@ ) from opencode_a2a.opencode_upstream_client import OpencodeMessage, OpencodeMessagePage from opencode_a2a.server.context_helpers import normalize_server_call_context -from tests.support.settings import make_settings +from tests.support import settings as test_settings def make_basic_auth_header(username: str, password: str) -> dict[str, str]: @@ -97,7 +97,7 @@ def configure_mock_client_runtime( if settings_overrides: overrides.update(settings_overrides) type(client).directory = PropertyMock(return_value=directory) - type(client).settings = PropertyMock(return_value=make_settings(**overrides)) + type(client).settings = PropertyMock(return_value=test_settings.make_settings(**overrides)) def make_request_context( @@ -175,7 +175,9 @@ def __init__( self.created_workspace_ids: list[str | None] = [] self.stream_timeout = None self.directory = None - self.settings = settings or make_settings(opencode_base_url="http://localhost") + self.settings = settings or test_settings.make_settings( + opencode_base_url="http://localhost" + ) async def close(self) -> None: return None diff --git a/tests/support/streaming_output.py b/tests/support/streaming_output.py index c6df8ad..75cd8b5 100644 --- a/tests/support/streaming_output.py +++ b/tests/support/streaming_output.py @@ -9,8 +9,8 @@ from opencode_a2a.opencode_upstream_client import OpencodeMessage from tests.support.helpers import ( DummyEventQueue, - make_settings, ) +from tests.support.settings import make_settings class DummyStreamingClient: diff --git a/tests/upstream/test_opencode_upstream_client_params.py b/tests/upstream/test_opencode_upstream_client_params.py index db3aa81..bd214c7 100644 --- a/tests/upstream/test_opencode_upstream_client_params.py +++ b/tests/upstream/test_opencode_upstream_client_params.py @@ -13,7 +13,7 @@ UpstreamContractError, ) from opencode_a2a.trace_context import TraceContext, bind_trace_context -from tests.support.helpers import make_settings +from tests.support.settings import make_settings class _DummyResponse: From 1ee9198e742e2860b0245e929778cb8ac6783cf0 Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Wed, 6 May 2026 18:05:08 +0800 Subject: [PATCH 4/5] refactor: narrow local compatibility surfaces (#476) --- docs/compatibility.md | 3 +- docs/guide.md | 3 + src/opencode_a2a/client/config.py | 6 +- .../contracts/extensions/catalog.py | 7 -- src/opencode_a2a/jsonrpc/params.py | 67 ++++++------------- tests/client/test_client_config.py | 6 ++ tests/jsonrpc/test_dispatch_registry.py | 17 +++-- tests/jsonrpc/test_jsonrpc_params.py | 41 ++++-------- ...test_opencode_session_extension_queries.py | 8 ++- tests/server/test_agent_card.py | 7 -- 10 files changed, 64 insertions(+), 101 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index 914647e..d71bb35 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -8,7 +8,7 @@ This document defines the compatibility promises `opencode-a2a` currently uphold - A2A SDK line: `1.x.y` - Supported A2A protocol line: `1.0` -The repository currently pins one concrete SDK release in `pyproject.toml` within that v1 line. Upgrade the SDK deliberately rather than relying on floating dependency resolution. +The repository currently pins one concrete SDK release in `pyproject.toml` within that v1 line. Upgrade the SDK deliberately rather than relying on floating dependency resolution. The SDK-owned core JSON-RPC method set follows that pinned release and is locked by repository tests so SDK upgrades trigger an explicit compatibility review. ## Contract Honesty @@ -53,6 +53,7 @@ This repository still ships as an alpha project. Within that alpha line, these d - authenticated extended card and OpenAPI wire-contract metadata Changes to those surfaces should be treated as compatibility-sensitive and should include corresponding test updates. +- provider-private session query params use one top-level request shape; the repository does not promise parallel nested `query.*` aliases for the same filters. Service-level behavior layered on top of those core methods should also be declared explicitly when interoperability depends on it. Current examples: diff --git a/docs/guide.md b/docs/guide.md index b0563aa..28b0483 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -286,6 +286,7 @@ Current behavior: - `all_jsonrpc_methods` is the runtime truth for the current deployment. - The current SDK-owned core JSON-RPC surface includes `GetExtendedAgentCard` and `tasks/pushNotificationConfig/*`. - The current SDK-owned REST surface also includes `GET /v1/tasks` and the task push notification config routes. +- The SDK-owned core JSON-RPC method set follows the pinned `a2a-sdk` release and is locked by repository tests; review that surface deliberately when upgrading the SDK. - Push notification config routes/methods are currently exposed only because they are part of the SDK-owned core surface. This runtime does not configure a push config store or push sender, so push notification operations remain unsupported. REST routes currently return HTTP `501`, while JSON-RPC methods surface SDK-owned unsupported error envelopes. When `A2A_ENABLE_SESSION_SHELL=false`, `opencode.sessions.shell` is omitted from `all_jsonrpc_methods` and exposed only through `extensions.conditionally_available_methods`. @@ -621,11 +622,13 @@ Detailed contract discovery for this provider-private surface is intentionally a - Session list filters: - optional `directory`, `roots`, `start`, `search`, `limit` - optional `metadata.opencode.workspace.id` + - nested `query` objects are not supported; pass filters at the top level only - `directory` is normalized through the same workspace-boundary rules used by other OpenCode directory overrides before reaching upstream - when `metadata.opencode.workspace.id` is present, the adapter routes by workspace and ignores `directory` - Session message history filters: - optional `limit`, `before` - optional `metadata.opencode.workspace.id` + - nested `query` objects are not supported; pass filters at the top level only - `before` is an opaque cursor for loading older messages and is only supported on `opencode.sessions.messages.list` - Mutation methods: - `opencode.sessions.fork` diff --git a/src/opencode_a2a/client/config.py b/src/opencode_a2a/client/config.py index 891d519..6f58fc5 100644 --- a/src/opencode_a2a/client/config.py +++ b/src/opencode_a2a/client/config.py @@ -72,15 +72,15 @@ def _coerce_optional_str(name: str, value: Any) -> str | None: def _normalize_transport(value: str) -> str: normalized = value.strip().lower() - if normalized in {"jsonrpc", "json-rpc", "json_rpc"}: + if normalized in {"jsonrpc", "json-rpc"}: return "JSONRPC" - if normalized in {"http+json", "http_json", "http-json", "httpjson", "http+json+"}: + if normalized in {"http+json", "http-json"}: return "HTTP+JSON" if normalized in {"grpc"}: return "GRPC" if not normalized: return "JSONRPC" - return value.strip() + raise ValueError(f"supported_transports contains unsupported transport {value!r}") def _parse_transports( diff --git a/src/opencode_a2a/contracts/extensions/catalog.py b/src/opencode_a2a/contracts/extensions/catalog.py index 4576458..256abce 100644 --- a/src/opencode_a2a/contracts/extensions/catalog.py +++ b/src/opencode_a2a/contracts/extensions/catalog.py @@ -139,11 +139,6 @@ class WorkspaceControlMethodContract: "roots", "start", "search", - "query.limit", - "query.directory", - "query.roots", - "query.start", - "query.search", ), unsupported_params=SESSION_QUERY_PAGINATION_UNSUPPORTED, result_fields=("items",), @@ -158,8 +153,6 @@ class WorkspaceControlMethodContract: "limit", "before", OPENCODE_WORKSPACE_METADATA_FIELD, - "query.limit", - "query.before", ), unsupported_params=SESSION_QUERY_PAGINATION_UNSUPPORTED, result_fields=("items", "next_cursor"), diff --git a/src/opencode_a2a/jsonrpc/params.py b/src/opencode_a2a/jsonrpc/params.py index 7702972..8dfd7b7 100644 --- a/src/opencode_a2a/jsonrpc/params.py +++ b/src/opencode_a2a/jsonrpc/params.py @@ -52,23 +52,18 @@ def _validation_error(field: str, message: str) -> JsonRpcParamsValidationError: ) -def _parse_query_object(params: dict[str, Any]) -> dict[str, Any]: - raw_query = params.get("query") - if raw_query is None: - return {} - if not isinstance(raw_query, dict): - raise JsonRpcParamsValidationError( - message="query must be an object", - data={"type": "INVALID_FIELD", "field": "query"}, - ) - return dict(raw_query) +def _reject_nested_query_params(params: dict[str, Any]) -> None: + if "query" not in params: + return + raise JsonRpcParamsValidationError( + message="query is not supported; use top-level params", + data={"type": "INVALID_FIELD", "field": "query"}, + ) -def _validate_pagination_fields(params: dict[str, Any], query: dict[str, Any]) -> None: +def _validate_pagination_fields(params: dict[str, Any]) -> None: unsupported_fields = tuple(SESSION_QUERY_PAGINATION_UNSUPPORTED) - if any(field in params for field in unsupported_fields) or any( - field in query for field in unsupported_fields - ): + if any(field in params for field in unsupported_fields): raise JsonRpcParamsValidationError( message="Only limit pagination is supported", data={ @@ -81,18 +76,9 @@ def _validate_pagination_fields(params: dict[str, Any], query: dict[str, Any]) - def _normalize_session_query_limit( *, - params: dict[str, Any], - query: dict[str, Any], + limit: Any, ) -> dict[str, Any]: - top_level_limit = _parse_required_positive_int(params.get("limit"), field="limit") - query_limit = _parse_required_positive_int(query.get("limit"), field="limit") - if top_level_limit is not None and query_limit is not None and top_level_limit != query_limit: - raise JsonRpcParamsValidationError( - message="limit is ambiguous between params.limit and params.query.limit", - data={"type": "INVALID_FIELD", "field": "limit"}, - ) - - normalized_limit = top_level_limit if top_level_limit is not None else query_limit + normalized_limit = _parse_required_positive_int(limit, field="limit") if normalized_limit is None: normalized_limit = SESSION_QUERY_DEFAULT_LIMIT elif normalized_limit > SESSION_QUERY_MAX_LIMIT: @@ -105,53 +91,39 @@ def _normalize_session_query_limit( }, ) - normalized_query = dict(query) - normalized_query["limit"] = normalized_limit - return normalized_query + return {"limit": normalized_limit} def _normalize_alias_field( *, params: dict[str, Any], - query: dict[str, Any], field: str, parser, ) -> Any: - top_level_value = parser(params.get(field), field=field) - query_value = parser(query.get(field), field=field) - if top_level_value is not None and query_value is not None and top_level_value != query_value: - raise JsonRpcParamsValidationError( - message=f"{field} is ambiguous between params.{field} and params.query.{field}", - data={"type": "INVALID_FIELD", "field": field}, - ) - return top_level_value if top_level_value is not None else query_value + return parser(params.get(field), field=field) def parse_list_sessions_params(params: dict[str, Any]) -> dict[str, Any]: - query = _parse_query_object(params) - _validate_pagination_fields(params, query) - normalized_query = _normalize_session_query_limit(params=params, query=query) + _reject_nested_query_params(params) + _validate_pagination_fields(params) + normalized_query = _normalize_session_query_limit(limit=params.get("limit")) directory = _normalize_alias_field( params=params, - query=query, field="directory", parser=_parse_string_field, ) roots = _normalize_alias_field( params=params, - query=query, field="roots", parser=_parse_bool_field, ) start = _normalize_alias_field( params=params, - query=query, field="start", parser=_parse_non_negative_int, ) search = _normalize_alias_field( params=params, - query=query, field="search", parser=_parse_string_field, ) @@ -183,12 +155,11 @@ def parse_get_session_messages_params(params: dict[str, Any]) -> tuple[str, dict data={"type": "MISSING_FIELD", "field": "session_id"}, ) - query = _parse_query_object(params) - _validate_pagination_fields(params, query) - normalized_query = _normalize_session_query_limit(params=params, query=query) + _reject_nested_query_params(params) + _validate_pagination_fields(params) + normalized_query = _normalize_session_query_limit(limit=params.get("limit")) before = _normalize_alias_field( params=params, - query=query, field="before", parser=_parse_string_field, ) diff --git a/tests/client/test_client_config.py b/tests/client/test_client_config.py index 04daaf6..8d95d65 100644 --- a/tests/client/test_client_config.py +++ b/tests/client/test_client_config.py @@ -48,6 +48,12 @@ def test_load_settings_invalid_transport_raises() -> None: load_settings({"A2A_CLIENT_SUPPORTED_TRANSPORTS": 1}) +@pytest.mark.parametrize("value", ("json_rpc", "httpjson", "http+json+")) +def test_load_settings_rejects_obsolete_transport_aliases(value: str) -> None: + with pytest.raises(ValueError, match="unsupported transport"): + load_settings({"A2A_CLIENT_SUPPORTED_TRANSPORTS": value}) + + def test_load_settings_invalid_bool_raises() -> None: with pytest.raises(ValueError, match="boolean-like"): load_settings({"A2A_CLIENT_USE_CLIENT_PREFERENCE": "maybe"}) diff --git a/tests/jsonrpc/test_dispatch_registry.py b/tests/jsonrpc/test_dispatch_registry.py index 2213350..700d9f1 100644 --- a/tests/jsonrpc/test_dispatch_registry.py +++ b/tests/jsonrpc/test_dispatch_registry.py @@ -101,10 +101,19 @@ async def _fake_core_handle(self, request, body, base_request): # noqa: ANN001 def test_core_jsonrpc_methods_are_canonical_pascalcase() -> None: - assert "SendMessage" in CORE_JSONRPC_METHODS - assert "SendStreamingMessage" in CORE_JSONRPC_METHODS - assert "GetTask" in CORE_JSONRPC_METHODS - assert "CancelTask" in CORE_JSONRPC_METHODS + assert CORE_JSONRPC_METHODS == ( + "SendMessage", + "SendStreamingMessage", + "GetTask", + "ListTasks", + "CancelTask", + "CreateTaskPushNotificationConfig", + "GetTaskPushNotificationConfig", + "ListTaskPushNotificationConfigs", + "DeleteTaskPushNotificationConfig", + "SubscribeToTask", + "GetExtendedAgentCard", + ) assert "message/send" not in CORE_JSONRPC_METHODS assert "tasks/get" not in CORE_JSONRPC_METHODS diff --git a/tests/jsonrpc/test_jsonrpc_params.py b/tests/jsonrpc/test_jsonrpc_params.py index 26e1ccd..ef15288 100644 --- a/tests/jsonrpc/test_jsonrpc_params.py +++ b/tests/jsonrpc/test_jsonrpc_params.py @@ -16,11 +16,12 @@ def test_parse_list_sessions_params_applies_default_limit() -> None: assert parse_list_sessions_params({}) == {"limit": SESSION_QUERY_DEFAULT_LIMIT} -def test_parse_list_sessions_params_accepts_equivalent_query_and_top_level_limit() -> None: - assert parse_list_sessions_params({"limit": "10", "query": {"limit": 10, "tag": "ops"}}) == { - "tag": "ops", - "limit": 10, - } +def test_parse_list_sessions_params_rejects_nested_query_object() -> None: + with pytest.raises(JsonRpcParamsValidationError) as exc_info: + parse_list_sessions_params({"limit": "10", "query": {"limit": 10}}) + + assert str(exc_info.value) == "query is not supported; use top-level params" + assert exc_info.value.data == {"type": "INVALID_FIELD", "field": "query"} def test_parse_list_sessions_params_accepts_filters() -> None: @@ -81,19 +82,19 @@ def test_parse_get_session_messages_params_accepts_before_cursor() -> None: assert query == {"limit": 5, "before": "cursor-1"} -def test_parse_get_session_messages_params_rejects_ambiguous_limit() -> None: +def test_parse_get_session_messages_params_rejects_nested_query_object() -> None: with pytest.raises(JsonRpcParamsValidationError) as exc_info: - parse_get_session_messages_params({"session_id": "s-1", "limit": 5, "query": {"limit": 6}}) + parse_get_session_messages_params({"session_id": "s-1", "limit": 5, "query": {"limit": 5}}) - assert str(exc_info.value) == "limit is ambiguous between params.limit and params.query.limit" - assert exc_info.value.data == {"type": "INVALID_FIELD", "field": "limit"} + assert str(exc_info.value) == "query is not supported; use top-level params" + assert exc_info.value.data == {"type": "INVALID_FIELD", "field": "query"} -def test_parse_list_sessions_params_rejects_non_object_query() -> None: +def test_parse_list_sessions_params_rejects_query_field_even_when_null() -> None: with pytest.raises(JsonRpcParamsValidationError) as exc_info: - parse_list_sessions_params({"query": "invalid"}) + parse_list_sessions_params({"query": None}) - assert str(exc_info.value) == "query must be an object" + assert str(exc_info.value) == "query is not supported; use top-level params" assert exc_info.value.data == {"type": "INVALID_FIELD", "field": "query"} @@ -117,22 +118,6 @@ def test_parse_list_sessions_params_rejects_boolean_limit() -> None: assert exc_info.value.data == {"type": "INVALID_FIELD", "field": "limit"} -def test_parse_list_sessions_params_rejects_ambiguous_directory() -> None: - with pytest.raises(JsonRpcParamsValidationError) as exc_info: - parse_list_sessions_params( - { - "directory": "services/api", - "query": {"directory": "services/web"}, - } - ) - - assert ( - str(exc_info.value) - == "directory is ambiguous between params.directory and params.query.directory" - ) - assert exc_info.value.data == {"type": "INVALID_FIELD", "field": "directory"} - - def test_parse_get_session_messages_params_rejects_invalid_before_type() -> None: with pytest.raises(JsonRpcParamsValidationError) as exc_info: parse_get_session_messages_params({"session_id": "s-1", "before": 123}) diff --git a/tests/jsonrpc/test_opencode_session_extension_queries.py b/tests/jsonrpc/test_opencode_session_extension_queries.py index dbf7b1e..00b1a56 100644 --- a/tests/jsonrpc/test_opencode_session_extension_queries.py +++ b/tests/jsonrpc/test_opencode_session_extension_queries.py @@ -1082,7 +1082,7 @@ async def test_session_query_extension_rejects_limit_above_max(monkeypatch): @pytest.mark.asyncio -async def test_session_query_extension_accepts_equivalent_string_and_integer_limit( +async def test_session_query_extension_rejects_nested_query_object( monkeypatch, ): import opencode_a2a.server.application as app_module @@ -1122,8 +1122,10 @@ async def test_session_query_extension_accepts_equivalent_string_and_integer_lim payload = resp.json() assert payload["jsonrpc"] == "2.0" assert payload["id"] == 4 - assert "error" not in payload - assert dummy.last_sessions_params == {"limit": 2} + assert payload["error"]["code"] == -32602 + assert payload["error"]["message"] == "Invalid parameters" + assert payload["error"]["data"] == {"field": "query"} + assert dummy.last_sessions_params is None @pytest.mark.asyncio diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index e840ff4..4c858f2 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -476,18 +476,11 @@ def test_agent_card_injects_profile_into_extensions() -> None: "roots", "start", "search", - "query.limit", - "query.directory", - "query.roots", - "query.start", - "query.search", ] assert messages_contract["params"]["optional"] == [ "limit", "before", "metadata.opencode.workspace.id", - "query.limit", - "query.before", ] assert messages_contract["result"]["fields"] == ["items", "next_cursor"] assert list_contract["notification_response_status"] == 204 From 32b4d694ae8b3e39b67e1659e554ad4075ffc04a Mon Sep 17 00:00:00 2001 From: "helen@cloud" Date: Wed, 6 May 2026 18:18:53 +0800 Subject: [PATCH 5/5] refactor: retain underscore transport aliases (#476) --- src/opencode_a2a/client/config.py | 4 ++-- tests/client/test_client_config.py | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/opencode_a2a/client/config.py b/src/opencode_a2a/client/config.py index 6f58fc5..8c1a0cf 100644 --- a/src/opencode_a2a/client/config.py +++ b/src/opencode_a2a/client/config.py @@ -72,9 +72,9 @@ def _coerce_optional_str(name: str, value: Any) -> str | None: def _normalize_transport(value: str) -> str: normalized = value.strip().lower() - if normalized in {"jsonrpc", "json-rpc"}: + if normalized in {"jsonrpc", "json-rpc", "json_rpc"}: return "JSONRPC" - if normalized in {"http+json", "http-json"}: + if normalized in {"http+json", "http-json", "http_json"}: return "HTTP+JSON" if normalized in {"grpc"}: return "GRPC" diff --git a/tests/client/test_client_config.py b/tests/client/test_client_config.py index 8d95d65..32e8e53 100644 --- a/tests/client/test_client_config.py +++ b/tests/client/test_client_config.py @@ -48,12 +48,18 @@ def test_load_settings_invalid_transport_raises() -> None: load_settings({"A2A_CLIENT_SUPPORTED_TRANSPORTS": 1}) -@pytest.mark.parametrize("value", ("json_rpc", "httpjson", "http+json+")) +@pytest.mark.parametrize("value", ("httpjson", "http+json+")) def test_load_settings_rejects_obsolete_transport_aliases(value: str) -> None: with pytest.raises(ValueError, match="unsupported transport"): load_settings({"A2A_CLIENT_SUPPORTED_TRANSPORTS": value}) +def test_load_settings_accepts_underscore_transport_aliases() -> None: + settings = load_settings({"A2A_CLIENT_SUPPORTED_TRANSPORTS": "json_rpc,http_json"}) + + assert settings.supported_transports == ("JSONRPC", "HTTP+JSON") + + def test_load_settings_invalid_bool_raises() -> None: with pytest.raises(ValueError, match="boolean-like"): load_settings({"A2A_CLIENT_USE_CLIENT_PREFERENCE": "maybe"})