Skip to content

Commit 38eabc7

Browse files
authored
Merge branch '1.0-dev' into migration
2 parents 79aac23 + d77cd68 commit 38eabc7

6 files changed

Lines changed: 254 additions & 14 deletions

File tree

.release-please-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{".":"1.0.0-alpha.2"}
1+
{".":"1.0.0-alpha.3"}

CHANGELOG.md

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

3+
## [1.0.0-alpha.3](https://github.com/a2aproject/a2a-python/compare/v1.0.0-alpha.2...v1.0.0-alpha.3) (2026-04-17)
4+
5+
6+
### Bug Fixes
7+
8+
* update `with_a2a_extensions` to append instead of overwriting ([#985](https://github.com/a2aproject/a2a-python/issues/985)) ([e1d0e7a](https://github.com/a2aproject/a2a-python/commit/e1d0e7a72e2b9633be0b76c952f6c2e6fe11e3e5))
9+
310
## [1.0.0-alpha.2](https://github.com/a2aproject/a2a-python/compare/v1.0.0-alpha.1...v1.0.0-alpha.2) (2026-04-17)
411

512

src/a2a/client/service_parameters.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
from collections.abc import Callable
22
from typing import TypeAlias
33

4-
from a2a.extensions.common import HTTP_EXTENSION_HEADER
4+
from a2a.extensions.common import (
5+
HTTP_EXTENSION_HEADER,
6+
get_requested_extensions,
7+
)
58

69

710
ServiceParameters: TypeAlias = dict[str, str]
@@ -44,17 +47,18 @@ def create_from(
4447

4548

4649
def with_a2a_extensions(extensions: list[str]) -> ServiceParametersUpdate:
47-
"""Create a ServiceParametersUpdate that adds A2A extensions.
50+
"""Create a ServiceParametersUpdate that merges A2A extension URIs.
4851
49-
Args:
50-
extensions: List of extension strings.
51-
52-
Returns:
53-
A function that updates ServiceParameters with the extensions header.
52+
Unions the supplied URIs with any already present in the A2A-Extensions
53+
parameter, deduplicating and emitting them in sorted order. Repeated
54+
calls accumulate rather than overwrite.
5455
"""
5556

5657
def update(parameters: ServiceParameters) -> None:
57-
if extensions:
58-
parameters[HTTP_EXTENSION_HEADER] = ','.join(extensions)
58+
if not extensions:
59+
return
60+
existing = parameters.get(HTTP_EXTENSION_HEADER, '')
61+
merged = sorted(get_requested_extensions([existing, *extensions]))
62+
parameters[HTTP_EXTENSION_HEADER] = ','.join(merged)
5963

6064
return update

src/a2a/server/request_handlers/default_request_handler_v2.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,17 @@ async def on_message_send( # noqa: D102
271271
):
272272
self._validate_task_id_match(task_id, event.id)
273273
result = event
274+
# DO break here as it's "return_immediately".
275+
# AgentExecutor will continue to run in the background.
274276
break
275277

276278
if isinstance(event, Message):
277279
result = event
278-
break
280+
# Do NOT break here as Message is supposed to be the only
281+
# event in "Message-only" interaction.
282+
# ActiveTask consumer (see active_task.py) validates the event
283+
# stream and raises InvalidAgentResponseError if more events are
284+
# pushed after a Message.
279285

280286
if result is None:
281287
logger.debug('Missing result for task %s', request_context.task_id)
@@ -311,15 +317,18 @@ async def on_message_send_stream( # noqa: D102
311317
request=request_context,
312318
include_initial_task=False,
313319
):
320+
# Do NOT break here as we rely on AgentExecutor to yield control.
321+
# ActiveTask consumer (see active_task.py) validates the event
322+
# stream and raises InvalidAgentResponseError on misbehaving agents:
323+
# - an event after a Message
324+
# - Message after entering task mode
325+
# - an event after a terminal state
314326
if isinstance(event, Task):
315327
self._validate_task_id_match(task_id, event.id)
316328
yield apply_history_length(event, params.configuration)
317329
else:
318330
yield event
319331

320-
if isinstance(event, Message):
321-
break
322-
323332
@validate_request_params
324333
@validate(
325334
lambda self: self._agent_card.capabilities.push_notifications,
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Tests for a2a.client.service_parameters module."""
2+
3+
from a2a.client.service_parameters import (
4+
ServiceParametersFactory,
5+
with_a2a_extensions,
6+
)
7+
from a2a.extensions.common import HTTP_EXTENSION_HEADER
8+
9+
10+
def test_with_a2a_extensions_merges_dedupes_and_sorts():
11+
"""Repeated calls accumulate; duplicates collapse; output is sorted."""
12+
parameters = ServiceParametersFactory.create(
13+
[
14+
with_a2a_extensions(['ext-c', 'ext-a']),
15+
with_a2a_extensions(['ext-b', 'ext-a']),
16+
]
17+
)
18+
19+
assert parameters[HTTP_EXTENSION_HEADER] == 'ext-a,ext-b,ext-c'
20+
21+
22+
def test_with_a2a_extensions_merges_existing_header_value():
23+
"""Pre-existing comma-separated header values are parsed and merged."""
24+
parameters = ServiceParametersFactory.create_from(
25+
{HTTP_EXTENSION_HEADER: 'ext-a, ext-b'},
26+
[with_a2a_extensions(['ext-c'])],
27+
)
28+
29+
assert parameters[HTTP_EXTENSION_HEADER] == 'ext-a,ext-b,ext-c'
30+
31+
32+
def test_with_a2a_extensions_empty_is_noop():
33+
"""An empty extensions list leaves the header untouched / absent."""
34+
parameters = ServiceParametersFactory.create(
35+
[
36+
with_a2a_extensions(['ext-a']),
37+
with_a2a_extensions([]),
38+
]
39+
)
40+
41+
assert parameters[HTTP_EXTENSION_HEADER] == 'ext-a'
42+
assert HTTP_EXTENSION_HEADER not in ServiceParametersFactory.create(
43+
[with_a2a_extensions([])]
44+
)
45+
46+
47+
def test_with_a2a_extensions_normalizes_input_strings():
48+
"""Input strings are split on commas and stripped, like header values."""
49+
parameters = ServiceParametersFactory.create(
50+
[with_a2a_extensions(['ext-a, ext-b', ' ext-c '])]
51+
)
52+
53+
assert parameters[HTTP_EXTENSION_HEADER] == 'ext-a,ext-b,ext-c'

tests/server/request_handlers/test_default_request_handler_v2.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
)
2929
from a2a.types import (
3030
InternalError,
31+
InvalidAgentResponseError,
3132
InvalidParamsError,
3233
TaskNotFoundError,
3334
PushNotificationNotSupportedError,
@@ -1244,3 +1245,169 @@ async def test_on_message_send_with_push_notification():
12441245
push_store.set_info.assert_awaited_once_with(
12451246
result.id, push_config, context
12461247
)
1248+
1249+
1250+
class MultipleMessagesAgentExecutor(AgentExecutor):
1251+
"""Misbehaving agent that yields more than one Message."""
1252+
1253+
async def execute(self, context: RequestContext, event_queue: EventQueue):
1254+
await event_queue.enqueue_event(
1255+
new_text_message('first', role=Role.ROLE_AGENT)
1256+
)
1257+
await event_queue.enqueue_event(
1258+
new_text_message('second', role=Role.ROLE_AGENT)
1259+
)
1260+
1261+
async def cancel(self, context: RequestContext, event_queue: EventQueue):
1262+
pass
1263+
1264+
1265+
class MessageAfterTaskEventAgentExecutor(AgentExecutor):
1266+
"""Misbehaving agent that yields a task-mode event then a Message."""
1267+
1268+
async def execute(self, context: RequestContext, event_queue: EventQueue):
1269+
task = new_task_from_user_message(context.message)
1270+
await event_queue.enqueue_event(task)
1271+
updater = TaskUpdater(event_queue, task.id, task.context_id)
1272+
await updater.update_status(TaskState.TASK_STATE_WORKING)
1273+
await event_queue.enqueue_event(
1274+
new_text_message('stray message', role=Role.ROLE_AGENT)
1275+
)
1276+
1277+
async def cancel(self, context: RequestContext, event_queue: EventQueue):
1278+
pass
1279+
1280+
1281+
class TaskEventAfterMessageAgentExecutor(AgentExecutor):
1282+
"""Misbehaving agent that yields a Message and then a task-mode event."""
1283+
1284+
async def execute(self, context: RequestContext, event_queue: EventQueue):
1285+
await event_queue.enqueue_event(
1286+
new_text_message('only message', role=Role.ROLE_AGENT)
1287+
)
1288+
await event_queue.enqueue_event(
1289+
TaskStatusUpdateEvent(
1290+
task_id=str(context.task_id or ''),
1291+
context_id=str(context.context_id or ''),
1292+
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
1293+
)
1294+
)
1295+
1296+
async def cancel(self, context: RequestContext, event_queue: EventQueue):
1297+
pass
1298+
1299+
1300+
class EventAfterTerminalStateAgentExecutor(AgentExecutor):
1301+
"""Misbehaving agent that yields an event after reaching a terminal state."""
1302+
1303+
async def execute(self, context: RequestContext, event_queue: EventQueue):
1304+
task = new_task_from_user_message(context.message)
1305+
await event_queue.enqueue_event(task)
1306+
updater = TaskUpdater(event_queue, task.id, task.context_id)
1307+
await updater.complete()
1308+
await event_queue.enqueue_event(
1309+
new_text_message('after terminal', role=Role.ROLE_AGENT)
1310+
)
1311+
1312+
async def cancel(self, context: RequestContext, event_queue: EventQueue):
1313+
pass
1314+
1315+
1316+
@pytest.mark.asyncio
1317+
@pytest.mark.timeout(1)
1318+
async def test_on_message_send_stream_rejects_multiple_messages():
1319+
"""Stream surfaces InvalidAgentResponseError when the agent yields a
1320+
second Message after the first one (see comment in on_message_send_stream)."""
1321+
request_handler = DefaultRequestHandlerV2(
1322+
agent_executor=MultipleMessagesAgentExecutor(),
1323+
task_store=InMemoryTaskStore(),
1324+
agent_card=create_default_agent_card(),
1325+
)
1326+
params = SendMessageRequest(
1327+
message=Message(
1328+
role=Role.ROLE_USER,
1329+
message_id='msg_multi_stream',
1330+
parts=[Part(text='Hi')],
1331+
)
1332+
)
1333+
with pytest.raises(InvalidAgentResponseError, match='Multiple Message'):
1334+
async for _ in request_handler.on_message_send_stream(
1335+
params, create_server_call_context()
1336+
):
1337+
pass
1338+
1339+
1340+
@pytest.mark.asyncio
1341+
@pytest.mark.timeout(1)
1342+
async def test_on_message_send_stream_rejects_message_after_task_event():
1343+
"""Stream surfaces InvalidAgentResponseError when the agent yields a
1344+
Message after entering task mode (see comment in on_message_send_stream)."""
1345+
request_handler = DefaultRequestHandlerV2(
1346+
agent_executor=MessageAfterTaskEventAgentExecutor(),
1347+
task_store=InMemoryTaskStore(),
1348+
agent_card=create_default_agent_card(),
1349+
)
1350+
params = SendMessageRequest(
1351+
message=Message(
1352+
role=Role.ROLE_USER,
1353+
message_id='msg_after_task_stream',
1354+
parts=[Part(text='Hi')],
1355+
)
1356+
)
1357+
with pytest.raises(
1358+
InvalidAgentResponseError, match='Message object in task mode'
1359+
):
1360+
async for _ in request_handler.on_message_send_stream(
1361+
params, create_server_call_context()
1362+
):
1363+
pass
1364+
1365+
1366+
@pytest.mark.asyncio
1367+
@pytest.mark.timeout(1)
1368+
async def test_on_message_send_stream_rejects_task_event_after_message():
1369+
"""Stream surfaces InvalidAgentResponseError when the agent yields a
1370+
task-mode event after a Message (see comment in on_message_send_stream)."""
1371+
request_handler = DefaultRequestHandlerV2(
1372+
agent_executor=TaskEventAfterMessageAgentExecutor(),
1373+
task_store=InMemoryTaskStore(),
1374+
agent_card=create_default_agent_card(),
1375+
)
1376+
params = SendMessageRequest(
1377+
message=Message(
1378+
role=Role.ROLE_USER,
1379+
message_id='msg_then_task_stream',
1380+
parts=[Part(text='Hi')],
1381+
)
1382+
)
1383+
with pytest.raises(InvalidAgentResponseError, match='in message mode'):
1384+
async for _ in request_handler.on_message_send_stream(
1385+
params, create_server_call_context()
1386+
):
1387+
pass
1388+
1389+
1390+
@pytest.mark.asyncio
1391+
@pytest.mark.timeout(1)
1392+
async def test_on_message_send_stream_rejects_event_after_terminal_state():
1393+
"""Stream surfaces InvalidAgentResponseError when the agent yields an event
1394+
after reaching a terminal state (see comment in on_message_send_stream)."""
1395+
request_handler = DefaultRequestHandlerV2(
1396+
agent_executor=EventAfterTerminalStateAgentExecutor(),
1397+
task_store=InMemoryTaskStore(),
1398+
agent_card=create_default_agent_card(),
1399+
)
1400+
params = SendMessageRequest(
1401+
message=Message(
1402+
role=Role.ROLE_USER,
1403+
message_id='msg_after_terminal_stream',
1404+
parts=[Part(text='Hi')],
1405+
)
1406+
)
1407+
with pytest.raises(
1408+
InvalidAgentResponseError, match='Message object in task mode'
1409+
):
1410+
async for _ in request_handler.on_message_send_stream(
1411+
params, create_server_call_context()
1412+
):
1413+
pass

0 commit comments

Comments
 (0)