From 14b9171a02ab7eac9415a01268cf9aeba25edc5b Mon Sep 17 00:00:00 2001 From: "Liu Juanjuan (Helen)" <31027645+liujuanjuan1984@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:54:49 +0000 Subject: [PATCH 1/4] fix(client): send outbound headers through SDK transport (#513) --- docs/guide.md | 2 +- src/opencode_a2a/client/request_context.py | 16 ++- tests/client/test_client_facade.py | 6 +- tests/client/test_client_http_headers.py | 141 +++++++++++++++++++++ tests/client/test_request_context.py | 8 +- 5 files changed, 163 insertions(+), 10 deletions(-) create mode 100644 tests/client/test_client_http_headers.py diff --git a/docs/guide.md b/docs/guide.md index 06b38c4..86cf467 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -122,7 +122,7 @@ Current client facade API: - `A2AClient.cancel_task()` - `A2AClient.subscribe_to_task()` -Server-side outbound peer calls read outbound credentials from environment variables. Configure `A2A_CLIENT_BEARER_TOKEN` or `A2A_CLIENT_BASIC_AUTH` when the remote agent protects its runtime surface. CLI outbound calls follow the same environment-only model. +Server-side outbound peer calls read outbound credentials from environment variables. Configure `A2A_CLIENT_BEARER_TOKEN` or `A2A_CLIENT_BASIC_AUTH` when the remote agent protects its runtime surface. The selected credential and fixed `A2A-Version` header are sent during Agent Card discovery and on subsequent peer operations such as `SendMessage` and `GetTask`. CLI outbound calls follow the same environment-only model. The embedded `a2a_call(...)` tool lets the upstream model choose the target URL, so the adapter applies a fail-closed network policy before opening any connection: diff --git a/src/opencode_a2a/client/request_context.py b/src/opencode_a2a/client/request_context.py index 570b685..f4bf3a2 100644 --- a/src/opencode_a2a/client/request_context.py +++ b/src/opencode_a2a/client/request_context.py @@ -79,12 +79,16 @@ def build_call_context( if extra_headers: merged_headers.update(extra_headers) normalized_extensions = [value for value in (extensions or ()) if value] - service_parameters = None - if normalized_extensions: - service_parameters = ServiceParametersFactory.create_from( - None, - [with_a2a_extensions(normalized_extensions)], - ) + service_parameter_updates = ( + [with_a2a_extensions(normalized_extensions)] if normalized_extensions else [] + ) + # a2a-sdk's HTTP transports serialize service parameters as request + # headers. Keep the same headers in state for transport-independent + # consumers, but use the SDK's HTTP header channel for on-wire delivery. + service_parameters = ServiceParametersFactory.create_from( + merged_headers, + service_parameter_updates, + ) return ClientCallContext( state={ "headers": dict(merged_headers), diff --git a/tests/client/test_client_facade.py b/tests/client/test_client_facade.py index 2a24004..ca425db 100644 --- a/tests/client/test_client_facade.py +++ b/tests/client/test_client_facade.py @@ -405,7 +405,8 @@ async def test_send_message_negotiates_extensions_via_service_parameters( assert len(result) == 1 payload, _, kwargs = fake_client.send_message_inputs[0] assert kwargs["context"].service_parameters == { - "A2A-Extensions": "https://example.com/ext-a,https://example.com/ext-b" + "A2A-Version": "1.0", + "A2A-Extensions": "https://example.com/ext-a,https://example.com/ext-b", } @@ -551,7 +552,8 @@ async def test_get_task_negotiates_extensions_via_service_parameters( _params, kwargs = fake_client.task_inputs[0] assert kwargs["context"].service_parameters == { - "A2A-Extensions": "https://example.com/ext-a,https://example.com/ext-b" + "A2A-Version": "1.0", + "A2A-Extensions": "https://example.com/ext-a,https://example.com/ext-b", } diff --git a/tests/client/test_client_http_headers.py b/tests/client/test_client_http_headers.py new file mode 100644 index 0000000..1ad8cf7 --- /dev/null +++ b/tests/client/test_client_http_headers.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +from base64 import b64encode + +import httpx +import pytest +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentInterface, + Message, + Part, + Role, + SendMessageResponse, + Task, + TaskState, + TaskStatus, +) +from google.protobuf.json_format import MessageToDict + +from opencode_a2a.client import A2AClient +from opencode_a2a.client.config import A2AClientSettings + +_PEER_URL = "https://peer.example.com" +_TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + + +def _agent_card() -> AgentCard: + return AgentCard( + name="HTTP stub peer", + description="Exercises the real SDK JSON-RPC HTTP transport.", + version="1.0", + supported_interfaces=[ + AgentInterface( + url=f"{_PEER_URL}/", + protocol_binding="JSONRPC", + protocol_version="1.0", + ) + ], + capabilities=AgentCapabilities(streaming=False), + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + skills=[], + ) + + +def _http_stub(requests: dict[str, httpx.Request]) -> httpx.MockTransport: + card = _agent_card() + + def handle(request: httpx.Request) -> httpx.Response: + if request.method == "GET": + requests["GetAgentCard"] = request + return httpx.Response(200, json=MessageToDict(card)) + + payload = json.loads(request.content) + method = payload["method"] + requests[method] = request + if method == "SendMessage": + result = MessageToDict( + SendMessageResponse( + message=Message( + message_id="reply-1", + role=Role.ROLE_AGENT, + parts=[Part(text="ok")], + ) + ) + ) + elif method == "GetTask": + result = MessageToDict( + Task( + id="task-1", + context_id="context-1", + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), + ) + ) + else: # pragma: no cover - keeps unexpected SDK calls visible + raise AssertionError(f"Unexpected JSON-RPC method: {method}") + return httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": payload["id"], "result": result}, + ) + + return httpx.MockTransport(handle) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("settings", "expected_authorization"), + [ + ( + A2AClientSettings( + bearer_token="peer-token", + supported_transports=("JSONRPC",), + ), + "Bearer peer-token", + ), + ( + A2AClientSettings( + basic_auth="user:pass", + supported_transports=("JSONRPC",), + ), + f"Basic {b64encode(b'user:pass').decode()}", + ), + ], + ids=("bearer", "basic"), +) +async def test_sdk_jsonrpc_requests_send_configured_headers( + settings: A2AClientSettings, + expected_authorization: str, +) -> None: + requests: dict[str, httpx.Request] = {} + async with httpx.AsyncClient(transport=_http_stub(requests)) as http_client: + client = A2AClient(_PEER_URL, settings=settings, httpx_client=http_client) + + await client.send("hello", metadata={"traceparent": _TRACEPARENT}) + await client.get_task("task-1", extensions=["https://example.com/ext"]) + + for method in ("GetAgentCard", "SendMessage", "GetTask"): + assert requests[method].headers["Authorization"] == expected_authorization + assert requests[method].headers["A2A-Version"] == "1.0" + assert requests["SendMessage"].headers["traceparent"] == _TRACEPARENT + assert requests["GetTask"].headers["A2A-Extensions"] == "https://example.com/ext" + + +@pytest.mark.asyncio +async def test_sdk_jsonrpc_request_preserves_explicit_authorization_override() -> None: + requests: dict[str, httpx.Request] = {} + settings = A2AClientSettings( + bearer_token="default-token", + supported_transports=("JSONRPC",), + ) + async with httpx.AsyncClient(transport=_http_stub(requests)) as http_client: + client = A2AClient(_PEER_URL, settings=settings, httpx_client=http_client) + + await client.send( + "hello", + metadata={"authorization": "Bearer explicit-token"}, + ) + + assert requests["SendMessage"].headers["Authorization"] == "Bearer explicit-token" diff --git a/tests/client/test_request_context.py b/tests/client/test_request_context.py index 1d0ea67..aedb579 100644 --- a/tests/client/test_request_context.py +++ b/tests/client/test_request_context.py @@ -83,6 +83,7 @@ def test_build_call_context_includes_fixed_protocol_version() -> None: assert context is not None assert context.state["headers"] == {"A2A-Version": "1.0"} assert context.state["http_kwargs"]["headers"] == {"A2A-Version": "1.0"} + assert context.service_parameters == {"A2A-Version": "1.0"} def test_build_call_context_includes_current_trace_headers() -> None: @@ -101,6 +102,7 @@ def test_build_call_context_includes_current_trace_headers() -> None: "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "tracestate": "vendor=value", } + assert context.service_parameters == context.state["headers"] def test_build_call_context_preserves_explicit_trace_headers_over_current_context() -> None: @@ -142,6 +144,7 @@ def test_build_call_context_carries_default_headers_without_interceptor_layer() "A2A-Version": "1.0", "X-Trace-Id": "trace-1", } + assert context.service_parameters == context.state["headers"] def test_build_call_context_merges_extension_service_parameters() -> None: @@ -153,7 +156,10 @@ def test_build_call_context_merges_extension_service_parameters() -> None: assert isinstance(context, ClientCallContext) assert context.service_parameters == { - "A2A-Extensions": "https://example.com/ext-a,https://example.com/ext-b" + "Authorization": "Bearer peer-token", + "A2A-Version": "1.0", + "X-Trace-Id": "trace-1", + "A2A-Extensions": "https://example.com/ext-a,https://example.com/ext-b", } From c22b85a8ea85f30d50477f9f8d3b5f7ac3ff6cd8 Mon Sep 17 00:00:00 2001 From: "Liu Juanjuan (Helen)" <31027645+liujuanjuan1984@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:09:16 +0000 Subject: [PATCH 2/4] refactor(client): remove unused call context header state (#515) --- CONTRIBUTING.md | 8 ++++++++ docs/compatibility.md | 4 ++++ src/opencode_a2a/client/request_context.py | 13 +++---------- tests/client/test_client_facade.py | 14 +++++++------- tests/client/test_request_context.py | 18 +++++------------- .../test_opencode_agent_session_binding.py | 6 +++--- 6 files changed, 30 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 638cb22..9cddc89 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,6 +71,14 @@ uv run pytest tests/contracts/test_extension_contract_consistency.py uv run mypy src/opencode_a2a ``` +If you change outbound authentication, tracing, protocol-version, or extension headers, run the SDK transport regression directly: + +```bash +uv run pytest --no-cov tests/client/test_client_http_headers.py +``` + +Assertions against a mocked SDK client or `ClientCallContext` alone do not prove that headers reached the HTTP request. Keep at least one regression test across Agent Card resolution, the real SDK transport, and the final `httpx.Request` boundary. + ## Change Expectations - Keep code, comments, and docs in English. diff --git a/docs/compatibility.md b/docs/compatibility.md index b8e1892..a6150ca 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -13,6 +13,8 @@ This is the single canonical place where the supported upstream OpenCode version 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. +SDK upgrade reviews must validate behavior at integration boundaries, not only object construction. In particular, outbound headers must be asserted on the final `httpx.Request` produced by the real SDK transport, and database compatibility changes must keep the SDK shape/parity tests green. A populated `ClientCallContext` by itself is not evidence that a transport consumed its values. + ## Contract Honesty Machine-readable discovery surfaces must reflect actual runtime behavior: @@ -91,6 +93,8 @@ Task-store behavior that should remain stable for clients: - accepted output-mode negotiation for a task is persisted with the task so later reads keep the same filtered output contract - adapter-managed migrations only own adapter state tables; SDK-managed task schema remains SDK-owned +`DatabaseTaskStoreCompat` is an intentional, fail-fast boundary around the SDK database store shape. It remains necessary for the repository's atomic first-terminal-state-wins write policy, which the SDK public `TaskStore` API does not provide. SDK upgrades must either preserve its shape/parity tests or replace it with an equivalent upstream public capability before removing the compatibility layer. + The supported persistence profile is one application process using its own local SQLite database. Multiple Uvicorn workers or application replicas must not share that SQLite file. PostgreSQL and other SQLAlchemy dialects are not supported deployment targets; any apparent dialect compatibility is implementation latitude rather than a public promise. ## Extension Stability diff --git a/src/opencode_a2a/client/request_context.py b/src/opencode_a2a/client/request_context.py index f4bf3a2..5d5694f 100644 --- a/src/opencode_a2a/client/request_context.py +++ b/src/opencode_a2a/client/request_context.py @@ -82,17 +82,10 @@ def build_call_context( service_parameter_updates = ( [with_a2a_extensions(normalized_extensions)] if normalized_extensions else [] ) - # a2a-sdk's HTTP transports serialize service parameters as request - # headers. Keep the same headers in state for transport-independent - # consumers, but use the SDK's HTTP header channel for on-wire delivery. + # a2a-sdk transports serialize service parameters as HTTP headers or gRPC + # metadata. This is also the channel used by the SDK's AuthInterceptor. service_parameters = ServiceParametersFactory.create_from( merged_headers, service_parameter_updates, ) - return ClientCallContext( - state={ - "headers": dict(merged_headers), - "http_kwargs": {"headers": dict(merged_headers)}, - }, - service_parameters=service_parameters, - ) + return ClientCallContext(service_parameters=service_parameters) diff --git a/tests/client/test_client_facade.py b/tests/client/test_client_facade.py index ca425db..ebc0f75 100644 --- a/tests/client/test_client_facade.py +++ b/tests/client/test_client_facade.py @@ -333,7 +333,7 @@ async def test_send_message_adds_bearer_token_from_settings( request, _, kwargs = fake_client.send_message_inputs[0] assert request.metadata == {} assert kwargs["context"] is not None - assert kwargs["context"].state["headers"]["Authorization"] == "Bearer peer-token" + assert kwargs["context"].service_parameters["Authorization"] == "Bearer peer-token" @pytest.mark.asyncio @@ -354,7 +354,7 @@ async def test_send_message_adds_basic_auth_from_settings( request, _, kwargs = fake_client.send_message_inputs[0] assert request.metadata == {} assert kwargs["context"] is not None - assert kwargs["context"].state["headers"]["Authorization"] == ( + assert kwargs["context"].service_parameters["Authorization"] == ( f"Basic {b64encode(b'user:pass').decode()}" ) @@ -382,7 +382,7 @@ async def test_send_message_preserves_explicit_authorization_metadata( assert result[0].HasField("message") request, _, kwargs = fake_client.send_message_inputs[0] assert request.metadata == {"trace_id": "trace-1"} - assert kwargs["context"].state["headers"]["Authorization"] == "Bearer explicit-token" + assert kwargs["context"].service_parameters["Authorization"] == "Bearer explicit-token" @pytest.mark.asyncio @@ -429,7 +429,7 @@ async def test_send_message_prefers_explicit_authorization_without_default_token assert result[0].HasField("message") request, _, kwargs = fake_client.send_message_inputs[0] assert request.metadata == {} - assert kwargs["context"].state["headers"]["Authorization"] == "Bearer explicit-token" + assert kwargs["context"].service_parameters["Authorization"] == "Bearer explicit-token" @pytest.mark.asyncio @@ -533,7 +533,7 @@ async def test_get_task_uses_authorization_header_context( params, kwargs = fake_client.task_inputs[0] assert params.id == "task-id" - assert kwargs["context"].state["headers"]["Authorization"] == "Bearer explicit-token" + assert kwargs["context"].service_parameters["Authorization"] == "Bearer explicit-token" assert "request_metadata" not in kwargs @@ -572,7 +572,7 @@ async def test_cancel_task_uses_authorization_header_context( params, kwargs = fake_client.cancel_inputs[0] assert params.metadata == {"trace_id": "trace-1"} - assert kwargs["context"].state["headers"]["Authorization"] == "Bearer explicit-token" + assert kwargs["context"].service_parameters["Authorization"] == "Bearer explicit-token" @pytest.mark.asyncio @@ -623,7 +623,7 @@ async def test_subscribe_to_task_uses_authorization_header_context( assert [event.task.status.state for event in result] == [TaskState.TASK_STATE_WORKING] params, kwargs = fake_client.subscribe_inputs[0] assert params.id == "task-id" - assert kwargs["context"].state["headers"]["Authorization"] == "Bearer explicit-token" + assert kwargs["context"].service_parameters["Authorization"] == "Bearer explicit-token" assert "request_metadata" not in kwargs diff --git a/tests/client/test_request_context.py b/tests/client/test_request_context.py index aedb579..f85d103 100644 --- a/tests/client/test_request_context.py +++ b/tests/client/test_request_context.py @@ -81,9 +81,8 @@ def test_build_call_context_includes_fixed_protocol_version() -> None: context = build_call_context(None, None) assert context is not None - assert context.state["headers"] == {"A2A-Version": "1.0"} - assert context.state["http_kwargs"]["headers"] == {"A2A-Version": "1.0"} assert context.service_parameters == {"A2A-Version": "1.0"} + assert context.state == {} def test_build_call_context_includes_current_trace_headers() -> None: @@ -97,12 +96,11 @@ def test_build_call_context_includes_current_trace_headers() -> None: context = build_call_context(None, None) assert context is not None - assert context.state["headers"] == { + assert context.service_parameters == { "A2A-Version": "1.0", "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", "tracestate": "vendor=value", } - assert context.service_parameters == context.state["headers"] def test_build_call_context_preserves_explicit_trace_headers_over_current_context() -> None: @@ -122,7 +120,7 @@ def test_build_call_context_preserves_explicit_trace_headers_over_current_contex ) assert context is not None - assert context.state["headers"] == { + assert context.service_parameters == { "Authorization": "Bearer peer-token", "A2A-Version": "1.0", "traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", @@ -130,21 +128,15 @@ def test_build_call_context_preserves_explicit_trace_headers_over_current_contex } -def test_build_call_context_carries_default_headers_without_interceptor_layer() -> None: +def test_build_call_context_carries_headers_via_service_parameters() -> None: context = build_call_context("peer-token", {"X-Trace-Id": "trace-1"}) assert isinstance(context, ClientCallContext) - assert context.state["headers"] == { - "Authorization": "Bearer peer-token", - "A2A-Version": "1.0", - "X-Trace-Id": "trace-1", - } - assert context.state["http_kwargs"]["headers"] == { + assert context.service_parameters == { "Authorization": "Bearer peer-token", "A2A-Version": "1.0", "X-Trace-Id": "trace-1", } - assert context.service_parameters == context.state["headers"] def test_build_call_context_merges_extension_service_parameters() -> None: diff --git a/tests/execution/test_opencode_agent_session_binding.py b/tests/execution/test_opencode_agent_session_binding.py index 4e90de5..88ca690 100644 --- a/tests/execution/test_opencode_agent_session_binding.py +++ b/tests/execution/test_opencode_agent_session_binding.py @@ -588,7 +588,7 @@ async def test_agent_a2a_call_uses_server_side_basic_auth_headers( assert results[0]["output"] == "remote response" _, _, kwargs = fake_sdk_client.send_message_inputs[0] assert kwargs["context"] is not None - assert kwargs["context"].state["headers"]["Authorization"] == ( + assert kwargs["context"].service_parameters["Authorization"] == ( f"Basic {b64encode(b'user:pass').decode()}" ) @@ -658,10 +658,10 @@ async def test_agent_a2a_call_propagates_current_trace_headers( assert results[0]["output"] == "remote response" _, _, kwargs = fake_sdk_client.send_message_inputs[0] assert kwargs["context"] is not None - assert kwargs["context"].state["headers"]["traceparent"] == ( + assert kwargs["context"].service_parameters["traceparent"] == ( "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" ) - assert kwargs["context"].state["headers"]["tracestate"] == "vendor=value" + assert kwargs["context"].service_parameters["tracestate"] == "vendor=value" await manager.close_all() From 613850f6ff5f8f8e7798e292739a4158c626938b Mon Sep 17 00:00:00 2001 From: "Liu Juanjuan (Helen)" <31027645+liujuanjuan1984@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:25:07 +0000 Subject: [PATCH 3/4] docs: consolidate transport testing guidance (#515) --- docs/compatibility.md | 4 ---- docs/guide.md | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index a6150ca..b8e1892 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -13,8 +13,6 @@ This is the single canonical place where the supported upstream OpenCode version 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. -SDK upgrade reviews must validate behavior at integration boundaries, not only object construction. In particular, outbound headers must be asserted on the final `httpx.Request` produced by the real SDK transport, and database compatibility changes must keep the SDK shape/parity tests green. A populated `ClientCallContext` by itself is not evidence that a transport consumed its values. - ## Contract Honesty Machine-readable discovery surfaces must reflect actual runtime behavior: @@ -93,8 +91,6 @@ Task-store behavior that should remain stable for clients: - accepted output-mode negotiation for a task is persisted with the task so later reads keep the same filtered output contract - adapter-managed migrations only own adapter state tables; SDK-managed task schema remains SDK-owned -`DatabaseTaskStoreCompat` is an intentional, fail-fast boundary around the SDK database store shape. It remains necessary for the repository's atomic first-terminal-state-wins write policy, which the SDK public `TaskStore` API does not provide. SDK upgrades must either preserve its shape/parity tests or replace it with an equivalent upstream public capability before removing the compatibility layer. - The supported persistence profile is one application process using its own local SQLite database. Multiple Uvicorn workers or application replicas must not share that SQLite file. PostgreSQL and other SQLAlchemy dialects are not supported deployment targets; any apparent dialect compatibility is implementation latitude rather than a public promise. ## Extension Stability diff --git a/docs/guide.md b/docs/guide.md index 86cf467..06b38c4 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -122,7 +122,7 @@ Current client facade API: - `A2AClient.cancel_task()` - `A2AClient.subscribe_to_task()` -Server-side outbound peer calls read outbound credentials from environment variables. Configure `A2A_CLIENT_BEARER_TOKEN` or `A2A_CLIENT_BASIC_AUTH` when the remote agent protects its runtime surface. The selected credential and fixed `A2A-Version` header are sent during Agent Card discovery and on subsequent peer operations such as `SendMessage` and `GetTask`. CLI outbound calls follow the same environment-only model. +Server-side outbound peer calls read outbound credentials from environment variables. Configure `A2A_CLIENT_BEARER_TOKEN` or `A2A_CLIENT_BASIC_AUTH` when the remote agent protects its runtime surface. CLI outbound calls follow the same environment-only model. The embedded `a2a_call(...)` tool lets the upstream model choose the target URL, so the adapter applies a fail-closed network policy before opening any connection: From 5771f796ff2b54c072ba82fef91121988aa302ea Mon Sep 17 00:00:00 2001 From: "Liu Juanjuan (Helen)" <31027645+liujuanjuan1984@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:36:52 +0000 Subject: [PATCH 4/4] test(client): cover outbound headers across HTTP transports (#513) --- tests/client/test_client_http_headers.py | 71 +++++++++++++----------- 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/tests/client/test_client_http_headers.py b/tests/client/test_client_http_headers.py index 1ad8cf7..5d2e145 100644 --- a/tests/client/test_client_http_headers.py +++ b/tests/client/test_client_http_headers.py @@ -26,15 +26,15 @@ _TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" -def _agent_card() -> AgentCard: +def _agent_card(protocol_binding: str) -> AgentCard: return AgentCard( name="HTTP stub peer", - description="Exercises the real SDK JSON-RPC HTTP transport.", + description="Exercises the real SDK HTTP transports.", version="1.0", supported_interfaces=[ AgentInterface( url=f"{_PEER_URL}/", - protocol_binding="JSONRPC", + protocol_binding=protocol_binding, protocol_version="1.0", ) ], @@ -45,16 +45,26 @@ def _agent_card() -> AgentCard: ) -def _http_stub(requests: dict[str, httpx.Request]) -> httpx.MockTransport: - card = _agent_card() +def _http_stub( + requests: dict[str, httpx.Request], + protocol_binding: str, +) -> httpx.MockTransport: + card = _agent_card(protocol_binding) def handle(request: httpx.Request) -> httpx.Response: - if request.method == "GET": + if request.url.path == "/.well-known/agent-card.json": requests["GetAgentCard"] = request return httpx.Response(200, json=MessageToDict(card)) - payload = json.loads(request.content) - method = payload["method"] + payload = json.loads(request.content) if request.content else None + if protocol_binding == "JSONRPC": + method = payload["method"] + elif request.url.path == "/message:send": + method = "SendMessage" + elif request.url.path == "/tasks/task-1": + method = "GetTask" + else: # pragma: no cover - keeps unexpected SDK calls visible + raise AssertionError(f"Unexpected REST request: {request.method} {request.url}") requests[method] = request if method == "SendMessage": result = MessageToDict( @@ -76,41 +86,40 @@ def handle(request: httpx.Request) -> httpx.Response: ) else: # pragma: no cover - keeps unexpected SDK calls visible raise AssertionError(f"Unexpected JSON-RPC method: {method}") - return httpx.Response( - 200, - json={"jsonrpc": "2.0", "id": payload["id"], "result": result}, - ) + if protocol_binding == "JSONRPC": + result = {"jsonrpc": "2.0", "id": payload["id"], "result": result} + return httpx.Response(200, json=result) return httpx.MockTransport(handle) @pytest.mark.asyncio @pytest.mark.parametrize( - ("settings", "expected_authorization"), + "protocol_binding", + ("JSONRPC", "HTTP+JSON"), + ids=("jsonrpc", "rest"), +) +@pytest.mark.parametrize( + ("bearer_token", "basic_auth", "expected_authorization"), [ - ( - A2AClientSettings( - bearer_token="peer-token", - supported_transports=("JSONRPC",), - ), - "Bearer peer-token", - ), - ( - A2AClientSettings( - basic_auth="user:pass", - supported_transports=("JSONRPC",), - ), - f"Basic {b64encode(b'user:pass').decode()}", - ), + ("peer-token", None, "Bearer peer-token"), + (None, "user:pass", f"Basic {b64encode(b'user:pass').decode()}"), ], ids=("bearer", "basic"), ) -async def test_sdk_jsonrpc_requests_send_configured_headers( - settings: A2AClientSettings, +async def test_sdk_http_requests_send_configured_headers( + protocol_binding: str, + bearer_token: str | None, + basic_auth: str | None, expected_authorization: str, ) -> None: requests: dict[str, httpx.Request] = {} - async with httpx.AsyncClient(transport=_http_stub(requests)) as http_client: + settings = A2AClientSettings( + bearer_token=bearer_token, + basic_auth=basic_auth, + supported_transports=(protocol_binding,), + ) + async with httpx.AsyncClient(transport=_http_stub(requests, protocol_binding)) as http_client: client = A2AClient(_PEER_URL, settings=settings, httpx_client=http_client) await client.send("hello", metadata={"traceparent": _TRACEPARENT}) @@ -130,7 +139,7 @@ async def test_sdk_jsonrpc_request_preserves_explicit_authorization_override() - bearer_token="default-token", supported_transports=("JSONRPC",), ) - async with httpx.AsyncClient(transport=_http_stub(requests)) as http_client: + async with httpx.AsyncClient(transport=_http_stub(requests, "JSONRPC")) as http_client: client = A2AClient(_PEER_URL, settings=settings, httpx_client=http_client) await client.send(