Skip to content

Commit e36d5cb

Browse files
committed
fix(a2a): emit only new artifact parts on streaming artifact updates
1 parent bb3b2a4 commit e36d5cb

2 files changed

Lines changed: 163 additions & 68 deletions

File tree

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -543,18 +543,22 @@ async def _handle_a2a_response(
543543
for part in event.content.parts:
544544
part.thought = True
545545
_add_mock_function_call(event, update.status.state)
546-
elif isinstance(update, A2ATaskArtifactUpdateEvent) and (
547-
not update.append or update.last_chunk
548-
):
546+
elif isinstance(update, A2ATaskArtifactUpdateEvent):
549547
# This is a streaming task artifact update.
550-
# We only handle full artifact updates and ignore partial updates.
551-
# Note: Depends on the server implementation, there is no clear
552-
# definition of what a partial update is currently. We use the two
553-
# signals:
554-
# 1. append: True for partial updates, False for full updates.
555-
# 2. last_chunk: True for full updates, False for partial updates.
556-
event = convert_a2a_task_to_event(
557-
task, self.name, ctx, self._a2a_part_converter
548+
# Convert only the parts carried by this update. Converting the
549+
# accumulated task here would re-emit earlier chunks of the same
550+
# artifact, duplicating already-streamed content.
551+
if not update.artifact.parts:
552+
return None
553+
event = convert_a2a_message_to_event(
554+
_compat.make_message(
555+
message_id="",
556+
role="agent",
557+
parts=update.artifact.parts,
558+
),
559+
self.name,
560+
ctx,
561+
self._a2a_part_converter,
558562
)
559563
if not event:
560564
return None

tests/unittests/agents/test_remote_a2a_agent.py

Lines changed: 148 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,39 @@ def _make_stream_message(message: A2AMessage):
106106
return message
107107

108108

109+
def _make_artifact_chunk(text: str, *, append: bool, last_chunk: bool):
110+
"""Build one streamed chunk of an artifact, version-agnostically."""
111+
return TaskArtifactUpdateEvent(
112+
task_id="task-123",
113+
context_id="context-123",
114+
append=append,
115+
last_chunk=last_chunk,
116+
artifact=_compat.make_artifact(
117+
artifact_id="artifact-1",
118+
parts=[_compat.make_text_part(text)],
119+
),
120+
)
121+
122+
123+
def _make_accumulated_task(part_texts):
124+
"""Build the running Task the stream normalizer yields alongside an update.
125+
126+
The task carries the artifact parts accumulated across all chunks received
127+
so far, mirroring the 0.3.x ClientTaskManager / 1.x stream normalizer.
128+
"""
129+
return _compat.make_task(
130+
id="task-123",
131+
status=_compat.make_task_status(_compat.TS_WORKING),
132+
context_id="context-123",
133+
artifacts=[
134+
_compat.make_artifact(
135+
artifact_id="artifact-1",
136+
parts=[_compat.make_text_part(text) for text in part_texts],
137+
)
138+
],
139+
)
140+
141+
109142
# Helper function to create a proper AgentCard for testing
110143
def create_test_agent_card(
111144
name: str = "test-agent",
@@ -1326,11 +1359,7 @@ async def test_handle_a2a_response_with_artifact_update(self):
13261359
mock_a2a_task.id = "task-123"
13271360
mock_a2a_task.context_id = "context-123"
13281361

1329-
mock_artifact = Mock(spec=Artifact)
1330-
mock_update = Mock(spec=TaskArtifactUpdateEvent)
1331-
mock_update.artifact = mock_artifact
1332-
mock_update.append = False
1333-
mock_update.last_chunk = True
1362+
update = _make_artifact_chunk("chunk", append=False, last_chunk=True)
13341363

13351364
# Create a proper Event mock that can handle custom_metadata
13361365
mock_event = Event(
@@ -1339,45 +1368,53 @@ async def test_handle_a2a_response_with_artifact_update(self):
13391368
branch=self.mock_context.branch,
13401369
)
13411370

1342-
with patch.object(
1343-
remote_a2a_agent,
1344-
"convert_a2a_task_to_event",
1345-
autospec=True,
1371+
with patch(
1372+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
13461373
) as mock_convert:
13471374
mock_convert.return_value = mock_event
13481375

13491376
result = await self.agent._handle_a2a_response(
1350-
(mock_a2a_task, mock_update), self.mock_context
1377+
(mock_a2a_task, update), self.mock_context
13511378
)
13521379

13531380
assert result == mock_event
1354-
mock_convert.assert_called_once_with(
1355-
mock_a2a_task,
1356-
self.agent.name,
1357-
self.mock_context,
1358-
self.agent._a2a_part_converter,
1359-
)
1381+
mock_convert.assert_called_once()
1382+
# Only the parts carried by this update are converted, not the
1383+
# accumulated task.
1384+
converted_message = mock_convert.call_args[0][0]
1385+
assert list(converted_message.parts) == list(update.artifact.parts)
13601386
# Check that metadata was added
13611387
assert result.custom_metadata is not None
13621388
assert A2A_METADATA_PREFIX + "task_id" in result.custom_metadata
13631389
assert A2A_METADATA_PREFIX + "context_id" in result.custom_metadata
13641390

13651391
@pytest.mark.asyncio
1366-
async def test_handle_a2a_response_with_partial_artifact_update(self):
1367-
"""Test that partial artifact updates are ignored."""
1392+
async def test_handle_a2a_response_with_appended_artifact_chunk(self):
1393+
"""An appended (middle) artifact chunk emits only its own parts."""
13681394
mock_a2a_task = Mock(spec=A2ATask)
13691395
mock_a2a_task.id = "task-123"
1396+
mock_a2a_task.context_id = "context-123"
13701397

1371-
mock_update = Mock(spec=TaskArtifactUpdateEvent)
1372-
mock_update.artifact = Mock(spec=Artifact)
1373-
mock_update.append = True
1374-
mock_update.last_chunk = False
1398+
update = _make_artifact_chunk("middle", append=True, last_chunk=False)
13751399

1376-
result = await self.agent._handle_a2a_response(
1377-
(mock_a2a_task, mock_update), self.mock_context
1400+
mock_event = Event(
1401+
author=self.agent.name,
1402+
invocation_id=self.mock_context.invocation_id,
1403+
branch=self.mock_context.branch,
13781404
)
13791405

1380-
assert result is None
1406+
with patch(
1407+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
1408+
) as mock_convert:
1409+
mock_convert.return_value = mock_event
1410+
1411+
result = await self.agent._handle_a2a_response(
1412+
(mock_a2a_task, update), self.mock_context
1413+
)
1414+
1415+
assert result == mock_event
1416+
converted_message = mock_convert.call_args[0][0]
1417+
assert list(converted_message.parts) == list(update.artifact.parts)
13811418

13821419
@pytest.mark.asyncio
13831420
async def test_handle_a2a_response_with_real_empty_status_message(self):
@@ -1407,6 +1444,58 @@ async def test_handle_a2a_response_with_real_empty_status_message(self):
14071444
assert result is None
14081445

14091446

1447+
class TestRemoteA2aAgentStreamingArtifactChunks:
1448+
"""Regression tests for chunked artifact streams (#6343)."""
1449+
1450+
def setup_method(self):
1451+
"""Setup test fixtures."""
1452+
self.agent = RemoteA2aAgent(
1453+
name="test_agent",
1454+
agent_card=create_test_agent_card(),
1455+
)
1456+
self.mock_context = Mock(spec=InvocationContext)
1457+
self.mock_context.invocation_id = "invocation-123"
1458+
self.mock_context.branch = "main"
1459+
1460+
@pytest.mark.asyncio
1461+
async def test_chunked_artifact_stream_emits_each_part_exactly_once(self):
1462+
"""A two-chunk artifact stream renders its parts without duplication."""
1463+
chunk1 = _make_artifact_chunk("Hello, ", append=False, last_chunk=False)
1464+
chunk2 = _make_artifact_chunk("world!", append=True, last_chunk=True)
1465+
# (task, update) pairs as the client stream yields them: the task carries
1466+
# the artifact parts accumulated so far.
1467+
stream = [
1468+
(_make_accumulated_task(["Hello, "]), chunk1),
1469+
(_make_accumulated_task(["Hello, ", "world!"]), chunk2),
1470+
]
1471+
1472+
rendered = []
1473+
for pair in stream:
1474+
event = await self.agent._handle_a2a_response(pair, self.mock_context)
1475+
if event and event.content and event.content.parts:
1476+
rendered.extend(part.text for part in event.content.parts if part.text)
1477+
1478+
assert "".join(rendered) == "Hello, world!"
1479+
1480+
@pytest.mark.asyncio
1481+
async def test_artifact_update_without_parts_is_ignored(self):
1482+
"""An artifact update carrying no parts must not emit a spurious event."""
1483+
update = TaskArtifactUpdateEvent(
1484+
task_id="task-123",
1485+
context_id="context-123",
1486+
append=False,
1487+
last_chunk=True,
1488+
artifact=_compat.make_artifact(artifact_id="artifact-1", parts=[]),
1489+
)
1490+
task = _make_accumulated_task(["already streamed"])
1491+
1492+
result = await self.agent._handle_a2a_response(
1493+
(task, update), self.mock_context
1494+
)
1495+
1496+
assert result is None
1497+
1498+
14101499
class TestRemoteA2aAgentMessageHandlingFromFactory:
14111500
"""Test message handling functionality."""
14121501

@@ -1770,11 +1859,7 @@ async def test_handle_a2a_response_with_artifact_update(self):
17701859
mock_a2a_task.id = "task-123"
17711860
mock_a2a_task.context_id = "context-123"
17721861

1773-
mock_artifact = Mock(spec=Artifact)
1774-
mock_update = Mock(spec=TaskArtifactUpdateEvent)
1775-
mock_update.artifact = mock_artifact
1776-
mock_update.append = False
1777-
mock_update.last_chunk = True
1862+
update = _make_artifact_chunk("chunk", append=False, last_chunk=True)
17781863

17791864
# Create a proper Event mock that can handle custom_metadata
17801865
mock_event = Event(
@@ -1783,45 +1868,53 @@ async def test_handle_a2a_response_with_artifact_update(self):
17831868
branch=self.mock_context.branch,
17841869
)
17851870

1786-
with patch.object(
1787-
remote_a2a_agent,
1788-
"convert_a2a_task_to_event",
1789-
autospec=True,
1871+
with patch(
1872+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
17901873
) as mock_convert:
17911874
mock_convert.return_value = mock_event
17921875

17931876
result = await self.agent._handle_a2a_response(
1794-
(mock_a2a_task, mock_update), self.mock_context
1877+
(mock_a2a_task, update), self.mock_context
17951878
)
17961879

17971880
assert result == mock_event
1798-
mock_convert.assert_called_once_with(
1799-
mock_a2a_task,
1800-
self.agent.name,
1801-
self.mock_context,
1802-
self.agent._a2a_part_converter,
1803-
)
1881+
mock_convert.assert_called_once()
1882+
# Only the parts carried by this update are converted, not the
1883+
# accumulated task.
1884+
converted_message = mock_convert.call_args[0][0]
1885+
assert list(converted_message.parts) == list(update.artifact.parts)
18041886
# Check that metadata was added
18051887
assert result.custom_metadata is not None
18061888
assert A2A_METADATA_PREFIX + "task_id" in result.custom_metadata
18071889
assert A2A_METADATA_PREFIX + "context_id" in result.custom_metadata
18081890

18091891
@pytest.mark.asyncio
1810-
async def test_handle_a2a_response_with_partial_artifact_update(self):
1811-
"""Test that partial artifact updates are ignored."""
1892+
async def test_handle_a2a_response_with_appended_artifact_chunk(self):
1893+
"""An appended (middle) artifact chunk emits only its own parts."""
18121894
mock_a2a_task = Mock(spec=A2ATask)
18131895
mock_a2a_task.id = "task-123"
1896+
mock_a2a_task.context_id = "context-123"
18141897

1815-
mock_update = Mock(spec=TaskArtifactUpdateEvent)
1816-
mock_update.artifact = Mock(spec=Artifact)
1817-
mock_update.append = True
1818-
mock_update.last_chunk = False
1898+
update = _make_artifact_chunk("middle", append=True, last_chunk=False)
18191899

1820-
result = await self.agent._handle_a2a_response(
1821-
(mock_a2a_task, mock_update), self.mock_context
1900+
mock_event = Event(
1901+
author=self.agent.name,
1902+
invocation_id=self.mock_context.invocation_id,
1903+
branch=self.mock_context.branch,
18221904
)
18231905

1824-
assert result is None
1906+
with patch(
1907+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
1908+
) as mock_convert:
1909+
mock_convert.return_value = mock_event
1910+
1911+
result = await self.agent._handle_a2a_response(
1912+
(mock_a2a_task, update), self.mock_context
1913+
)
1914+
1915+
assert result == mock_event
1916+
converted_message = mock_convert.call_args[0][0]
1917+
assert list(converted_message.parts) == list(update.artifact.parts)
18251918

18261919

18271920
class TestRemoteA2aAgentMessageHandlingV2:
@@ -2250,23 +2343,21 @@ async def test_legacy_message_converter_returns_none_status_update(self):
22502343
assert result is None
22512344

22522345
@pytest.mark.asyncio
2253-
async def test_legacy_task_converter_returns_none_artifact_update(self):
2254-
"""Legacy handler must not crash when task converter returns None for artifact update."""
2346+
async def test_legacy_message_converter_returns_none_artifact_update(self):
2347+
"""Legacy handler must not crash when message converter returns None for artifact update."""
22552348
mock_task = Mock(spec=A2ATask)
22562349
mock_task.id = "task-123"
22572350
mock_task.context_id = None
22582351

2259-
mock_update = Mock(spec=TaskArtifactUpdateEvent)
2260-
mock_update.append = False
2261-
mock_update.last_chunk = True
2352+
update = _make_artifact_chunk("chunk", append=False, last_chunk=True)
22622353

22632354
with patch(
2264-
"google.adk.agents.remote_a2a_agent.convert_a2a_task_to_event"
2355+
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
22652356
) as mock_convert:
22662357
mock_convert.return_value = None
22672358

22682359
result = await self.legacy_agent._handle_a2a_response(
2269-
(mock_task, mock_update), self.mock_context
2360+
(mock_task, update), self.mock_context
22702361
)
22712362

22722363
assert result is None

0 commit comments

Comments
 (0)