@@ -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
110143def 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+
14101499class 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
18271920class 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