Skip to content

Commit 00ab922

Browse files
authored
Merge branch 'main' into main
2 parents 5d60e93 + c03129b commit 00ab922

20 files changed

+439
-305
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## [0.3.11](https://github.com/a2aproject/a2a-python/compare/v0.3.10...v0.3.11) (2025-11-07)
4+
5+
6+
### Bug Fixes
7+
8+
* add metadata to send message request ([12b4a1d](https://github.com/a2aproject/a2a-python/commit/12b4a1d565a53794f5b55c8bd1728221c906ed41))
9+
310
## [0.3.10](https://github.com/a2aproject/a2a-python/compare/v0.3.9...v0.3.10) (2025-10-21)
411

512

src/a2a/client/base_client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collections.abc import AsyncIterator
2+
from typing import Any
23

34
from a2a.client.client import (
45
Client,
@@ -47,6 +48,7 @@ async def send_message(
4748
request: Message,
4849
*,
4950
context: ClientCallContext | None = None,
51+
request_metadata: dict[str, Any] | None = None,
5052
) -> AsyncIterator[ClientEvent | Message]:
5153
"""Sends a message to the agent.
5254
@@ -57,6 +59,7 @@ async def send_message(
5759
Args:
5860
request: The message to send to the agent.
5961
context: The client call context.
62+
request_metadata: Extensions Metadata attached to the request.
6063
6164
Yields:
6265
An async iterator of `ClientEvent` or a final `Message` response.
@@ -70,7 +73,9 @@ async def send_message(
7073
else None
7174
),
7275
)
73-
params = MessageSendParams(message=request, configuration=config)
76+
params = MessageSendParams(
77+
message=request, configuration=config, metadata=request_metadata
78+
)
7479

7580
if not self._config.streaming or not self._card.capabilities.streaming:
7681
response = await self._transport.send_message(

src/a2a/client/client.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ async def send_message(
110110
request: Message,
111111
*,
112112
context: ClientCallContext | None = None,
113+
request_metadata: dict[str, Any] | None = None,
113114
) -> AsyncIterator[ClientEvent | Message]:
114115
"""Sends a message to the server.
115116

src/a2a/utils/proto_utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def make_dict_serializable(value: Any) -> Any:
5757
Returns:
5858
A serializable value.
5959
"""
60-
if isinstance(value, (str, int, float, bool)) or value is None:
60+
if isinstance(value, str | int | float | bool) or value is None:
6161
return value
6262
if isinstance(value, dict):
6363
return {k: make_dict_serializable(v) for k, v in value.items()}
@@ -140,6 +140,7 @@ def message(cls, message: types.Message | None) -> a2a_pb2.Message | None:
140140
task_id=message.task_id or '',
141141
role=cls.role(message.role),
142142
metadata=cls.metadata(message.metadata),
143+
extensions=message.extensions or [],
143144
)
144145

145146
@classmethod
@@ -239,6 +240,7 @@ def artifact(cls, artifact: types.Artifact) -> a2a_pb2.Artifact:
239240
metadata=cls.metadata(artifact.metadata),
240241
name=artifact.name,
241242
parts=[cls.part(p) for p in artifact.parts],
243+
extensions=artifact.extensions or [],
242244
)
243245

244246
@classmethod
@@ -695,6 +697,7 @@ def artifact(cls, artifact: a2a_pb2.Artifact) -> types.Artifact:
695697
metadata=cls.metadata(artifact.metadata),
696698
name=artifact.name,
697699
parts=[cls.part(p) for p in artifact.parts],
700+
extensions=artifact.extensions or None,
698701
)
699702

700703
@classmethod

tests/client/test_auth_middleware.py

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,10 @@ def store():
106106

107107

108108
@pytest.mark.asyncio
109-
async def test_auth_interceptor_skips_when_no_agent_card(store):
110-
"""
111-
Tests that the AuthInterceptor does not modify the request when no AgentCard is provided.
112-
"""
109+
async def test_auth_interceptor_skips_when_no_agent_card(
110+
store: InMemoryContextCredentialStore,
111+
) -> None:
112+
"""Tests that the AuthInterceptor does not modify the request when no AgentCard is provided."""
113113
request_payload = {'foo': 'bar'}
114114
http_kwargs = {'fizz': 'buzz'}
115115
auth_interceptor = AuthInterceptor(credential_service=store)
@@ -126,9 +126,10 @@ async def test_auth_interceptor_skips_when_no_agent_card(store):
126126

127127

128128
@pytest.mark.asyncio
129-
async def test_in_memory_context_credential_store(store):
130-
"""
131-
Verifies that InMemoryContextCredentialStore correctly stores and retrieves
129+
async def test_in_memory_context_credential_store(
130+
store: InMemoryContextCredentialStore,
131+
) -> None:
132+
"""Verifies that InMemoryContextCredentialStore correctly stores and retrieves
132133
credentials based on the session ID in the client context.
133134
"""
134135
session_id = 'session-id'
@@ -163,11 +164,8 @@ async def test_in_memory_context_credential_store(store):
163164

164165
@pytest.mark.asyncio
165166
@respx.mock
166-
async def test_client_with_simple_interceptor():
167-
"""
168-
Ensures that a custom HeaderInterceptor correctly injects a static header
169-
into outbound HTTP requests from the A2AClient.
170-
"""
167+
async def test_client_with_simple_interceptor() -> None:
168+
"""Ensures that a custom HeaderInterceptor correctly injects a static header into outbound HTTP requests from the A2AClient."""
171169
url = 'http://agent.com/rpc'
172170
interceptor = HeaderInterceptor('X-Test-Header', 'Test-Value-123')
173171
card = AgentCard(
@@ -196,9 +194,7 @@ async def test_client_with_simple_interceptor():
196194

197195
@dataclass
198196
class AuthTestCase:
199-
"""
200-
Represents a test scenario for verifying authentication behavior in AuthInterceptor.
201-
"""
197+
"""Represents a test scenario for verifying authentication behavior in AuthInterceptor."""
202198

203199
url: str
204200
"""The endpoint URL of the agent to which the request is sent."""
@@ -284,11 +280,10 @@ class AuthTestCase:
284280
[api_key_test_case, oauth2_test_case, oidc_test_case, bearer_test_case],
285281
)
286282
@respx.mock
287-
async def test_auth_interceptor_variants(test_case, store):
288-
"""
289-
Parametrized test verifying that AuthInterceptor correctly attaches credentials
290-
based on the defined security scheme in the AgentCard.
291-
"""
283+
async def test_auth_interceptor_variants(
284+
test_case: AuthTestCase, store: InMemoryContextCredentialStore
285+
) -> None:
286+
"""Parametrized test verifying that AuthInterceptor correctly attaches credentials based on the defined security scheme in the AgentCard."""
292287
await store.set_credentials(
293288
test_case.session_id, test_case.scheme_name, test_case.credential
294289
)
@@ -329,12 +324,9 @@ async def test_auth_interceptor_variants(test_case, store):
329324

330325
@pytest.mark.asyncio
331326
async def test_auth_interceptor_skips_when_scheme_not_in_security_schemes(
332-
store,
333-
):
334-
"""
335-
Tests that AuthInterceptor skips a scheme if it's listed in security requirements
336-
but not defined in security_schemes.
337-
"""
327+
store: InMemoryContextCredentialStore,
328+
) -> None:
329+
"""Tests that AuthInterceptor skips a scheme if it's listed in security requirements but not defined in security_schemes."""
338330
scheme_name = 'missing'
339331
session_id = 'session-id'
340332
credential = 'dummy-token'

tests/client/test_base_client.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@
1919

2020

2121
@pytest.fixture
22-
def mock_transport():
22+
def mock_transport() -> AsyncMock:
2323
return AsyncMock(spec=ClientTransport)
2424

2525

2626
@pytest.fixture
27-
def sample_agent_card():
27+
def sample_agent_card() -> AgentCard:
2828
return AgentCard(
2929
name='Test Agent',
3030
description='An agent for testing',
@@ -38,7 +38,7 @@ def sample_agent_card():
3838

3939

4040
@pytest.fixture
41-
def sample_message():
41+
def sample_message() -> Message:
4242
return Message(
4343
role=Role.user,
4444
message_id='msg-1',
@@ -47,7 +47,9 @@ def sample_message():
4747

4848

4949
@pytest.fixture
50-
def base_client(sample_agent_card, mock_transport):
50+
def base_client(
51+
sample_agent_card: AgentCard, mock_transport: AsyncMock
52+
) -> BaseClient:
5153
config = ClientConfig(streaming=True)
5254
return BaseClient(
5355
card=sample_agent_card,
@@ -61,7 +63,7 @@ def base_client(sample_agent_card, mock_transport):
6163
@pytest.mark.asyncio
6264
async def test_send_message_streaming(
6365
base_client: BaseClient, mock_transport: MagicMock, sample_message: Message
64-
):
66+
) -> None:
6567
async def create_stream(*args, **kwargs):
6668
yield Task(
6769
id='task-123',
@@ -71,9 +73,14 @@ async def create_stream(*args, **kwargs):
7173

7274
mock_transport.send_message_streaming.return_value = create_stream()
7375

74-
events = [event async for event in base_client.send_message(sample_message)]
76+
meta = {'test': 1}
77+
stream = base_client.send_message(sample_message, request_metadata=meta)
78+
events = [event async for event in stream]
7579

7680
mock_transport.send_message_streaming.assert_called_once()
81+
assert (
82+
mock_transport.send_message_streaming.call_args[0][0].metadata == meta
83+
)
7784
assert not mock_transport.send_message.called
7885
assert len(events) == 1
7986
assert events[0][0].id == 'task-123'
@@ -82,17 +89,20 @@ async def create_stream(*args, **kwargs):
8289
@pytest.mark.asyncio
8390
async def test_send_message_non_streaming(
8491
base_client: BaseClient, mock_transport: MagicMock, sample_message: Message
85-
):
92+
) -> None:
8693
base_client._config.streaming = False
8794
mock_transport.send_message.return_value = Task(
8895
id='task-456',
8996
context_id='ctx-789',
9097
status=TaskStatus(state=TaskState.completed),
9198
)
9299

93-
events = [event async for event in base_client.send_message(sample_message)]
100+
meta = {'test': 1}
101+
stream = base_client.send_message(sample_message, request_metadata=meta)
102+
events = [event async for event in stream]
94103

95104
mock_transport.send_message.assert_called_once()
105+
assert mock_transport.send_message.call_args[0][0].metadata == meta
96106
assert not mock_transport.send_message_streaming.called
97107
assert len(events) == 1
98108
assert events[0][0].id == 'task-456'
@@ -101,7 +111,7 @@ async def test_send_message_non_streaming(
101111
@pytest.mark.asyncio
102112
async def test_send_message_non_streaming_agent_capability_false(
103113
base_client: BaseClient, mock_transport: MagicMock, sample_message: Message
104-
):
114+
) -> None:
105115
base_client._card.capabilities.streaming = False
106116
mock_transport.send_message.return_value = Task(
107117
id='task-789',

tests/client/test_client_task_manager.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,12 @@
2222

2323

2424
@pytest.fixture
25-
def task_manager():
25+
def task_manager() -> ClientTaskManager:
2626
return ClientTaskManager()
2727

2828

2929
@pytest.fixture
30-
def sample_task():
30+
def sample_task() -> Task:
3131
return Task(
3232
id='task123',
3333
context_id='context456',
@@ -38,29 +38,31 @@ def sample_task():
3838

3939

4040
@pytest.fixture
41-
def sample_message():
41+
def sample_message() -> Message:
4242
return Message(
4343
message_id='msg1',
4444
role=Role.user,
4545
parts=[Part(root=TextPart(text='Hello'))],
4646
)
4747

4848

49-
def test_get_task_no_task_id_returns_none(task_manager: ClientTaskManager):
49+
def test_get_task_no_task_id_returns_none(
50+
task_manager: ClientTaskManager,
51+
) -> None:
5052
assert task_manager.get_task() is None
5153

5254

5355
def test_get_task_or_raise_no_task_raises_error(
5456
task_manager: ClientTaskManager,
55-
):
57+
) -> None:
5658
with pytest.raises(A2AClientInvalidStateError, match='no current Task'):
5759
task_manager.get_task_or_raise()
5860

5961

6062
@pytest.mark.asyncio
6163
async def test_save_task_event_with_task(
6264
task_manager: ClientTaskManager, sample_task: Task
63-
):
65+
) -> None:
6466
await task_manager.save_task_event(sample_task)
6567
assert task_manager.get_task() == sample_task
6668
assert task_manager._task_id == sample_task.id
@@ -70,7 +72,7 @@ async def test_save_task_event_with_task(
7072
@pytest.mark.asyncio
7173
async def test_save_task_event_with_task_already_set_raises_error(
7274
task_manager: ClientTaskManager, sample_task: Task
73-
):
75+
) -> None:
7476
await task_manager.save_task_event(sample_task)
7577
with pytest.raises(
7678
A2AClientInvalidArgsError,
@@ -82,7 +84,7 @@ async def test_save_task_event_with_task_already_set_raises_error(
8284
@pytest.mark.asyncio
8385
async def test_save_task_event_with_status_update(
8486
task_manager: ClientTaskManager, sample_task: Task, sample_message: Message
85-
):
87+
) -> None:
8688
await task_manager.save_task_event(sample_task)
8789
status_update = TaskStatusUpdateEvent(
8890
task_id=sample_task.id,
@@ -98,7 +100,7 @@ async def test_save_task_event_with_status_update(
98100
@pytest.mark.asyncio
99101
async def test_save_task_event_with_artifact_update(
100102
task_manager: ClientTaskManager, sample_task: Task
101-
):
103+
) -> None:
102104
await task_manager.save_task_event(sample_task)
103105
artifact = Artifact(
104106
artifact_id='art1', parts=[Part(root=TextPart(text='artifact content'))]
@@ -119,7 +121,7 @@ async def test_save_task_event_with_artifact_update(
119121
@pytest.mark.asyncio
120122
async def test_save_task_event_creates_task_if_not_exists(
121123
task_manager: ClientTaskManager,
122-
):
124+
) -> None:
123125
status_update = TaskStatusUpdateEvent(
124126
task_id='new_task',
125127
context_id='new_context',
@@ -135,7 +137,7 @@ async def test_save_task_event_creates_task_if_not_exists(
135137
@pytest.mark.asyncio
136138
async def test_process_with_task_event(
137139
task_manager: ClientTaskManager, sample_task: Task
138-
):
140+
) -> None:
139141
with patch.object(
140142
task_manager, 'save_task_event', new_callable=AsyncMock
141143
) as mock_save:
@@ -144,7 +146,9 @@ async def test_process_with_task_event(
144146

145147

146148
@pytest.mark.asyncio
147-
async def test_process_with_non_task_event(task_manager: ClientTaskManager):
149+
async def test_process_with_non_task_event(
150+
task_manager: ClientTaskManager,
151+
) -> None:
148152
with patch.object(
149153
task_manager, 'save_task_event', new_callable=Mock
150154
) as mock_save:
@@ -155,14 +159,14 @@ async def test_process_with_non_task_event(task_manager: ClientTaskManager):
155159

156160
def test_update_with_message(
157161
task_manager: ClientTaskManager, sample_task: Task, sample_message: Message
158-
):
162+
) -> None:
159163
updated_task = task_manager.update_with_message(sample_message, sample_task)
160164
assert updated_task.history == [sample_message]
161165

162166

163167
def test_update_with_message_moves_status_message(
164168
task_manager: ClientTaskManager, sample_task: Task, sample_message: Message
165-
):
169+
) -> None:
166170
status_message = Message(
167171
message_id='status_msg',
168172
role=Role.agent,

0 commit comments

Comments
 (0)