@@ -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,54 @@ 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+ assert result .partial is True
1417+ converted_message = mock_convert .call_args [0 ][0 ]
1418+ assert list (converted_message .parts ) == list (update .artifact .parts )
13811419
13821420 @pytest .mark .asyncio
13831421 async def test_handle_a2a_response_with_real_empty_status_message (self ):
@@ -1407,6 +1445,62 @@ async def test_handle_a2a_response_with_real_empty_status_message(self):
14071445 assert result is None
14081446
14091447
1448+ class TestRemoteA2aAgentStreamingArtifactChunks :
1449+ """Regression tests for chunked artifact streams (#6343)."""
1450+
1451+ def setup_method (self ):
1452+ """Setup test fixtures."""
1453+ self .agent = RemoteA2aAgent (
1454+ name = "test_agent" ,
1455+ agent_card = create_test_agent_card (),
1456+ )
1457+ self .mock_context = Mock (spec = InvocationContext )
1458+ self .mock_context .invocation_id = "invocation-123"
1459+ self .mock_context .branch = "main"
1460+
1461+ @pytest .mark .asyncio
1462+ async def test_chunked_artifact_stream_emits_each_part_exactly_once (self ):
1463+ """A two-chunk artifact stream renders its parts without duplication."""
1464+ chunk1 = _make_artifact_chunk ("Hello, " , append = False , last_chunk = False )
1465+ chunk2 = _make_artifact_chunk ("world!" , append = True , last_chunk = True )
1466+ # (task, update) pairs as the client stream yields them: the task carries
1467+ # the artifact parts accumulated so far.
1468+ stream = [
1469+ (_make_accumulated_task (["Hello, " ]), chunk1 ),
1470+ (_make_accumulated_task (["Hello, " , "world!" ]), chunk2 ),
1471+ ]
1472+
1473+ rendered = []
1474+ events = []
1475+ for pair in stream :
1476+ event = await self .agent ._handle_a2a_response (pair , self .mock_context )
1477+ events .append (event )
1478+ if event and event .content and event .content .parts :
1479+ rendered .extend (part .text for part in event .content .parts if part .text )
1480+
1481+ assert "" .join (rendered ) == "Hello, world!"
1482+ assert events [0 ].partial is True
1483+ assert events [1 ].partial is False
1484+
1485+ @pytest .mark .asyncio
1486+ async def test_artifact_update_without_parts_is_ignored (self ):
1487+ """An artifact update carrying no parts must not emit a spurious event."""
1488+ update = TaskArtifactUpdateEvent (
1489+ task_id = "task-123" ,
1490+ context_id = "context-123" ,
1491+ append = False ,
1492+ last_chunk = True ,
1493+ artifact = _compat .make_artifact (artifact_id = "artifact-1" , parts = []),
1494+ )
1495+ task = _make_accumulated_task (["already streamed" ])
1496+
1497+ result = await self .agent ._handle_a2a_response (
1498+ (task , update ), self .mock_context
1499+ )
1500+
1501+ assert result is None
1502+
1503+
14101504class TestRemoteA2aAgentMessageHandlingFromFactory :
14111505 """Test message handling functionality."""
14121506
@@ -1770,11 +1864,7 @@ async def test_handle_a2a_response_with_artifact_update(self):
17701864 mock_a2a_task .id = "task-123"
17711865 mock_a2a_task .context_id = "context-123"
17721866
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
1867+ update = _make_artifact_chunk ("chunk" , append = False , last_chunk = True )
17781868
17791869 # Create a proper Event mock that can handle custom_metadata
17801870 mock_event = Event (
@@ -1783,45 +1873,54 @@ async def test_handle_a2a_response_with_artifact_update(self):
17831873 branch = self .mock_context .branch ,
17841874 )
17851875
1786- with patch .object (
1787- remote_a2a_agent ,
1788- "convert_a2a_task_to_event" ,
1789- autospec = True ,
1876+ with patch (
1877+ "google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
17901878 ) as mock_convert :
17911879 mock_convert .return_value = mock_event
17921880
17931881 result = await self .agent ._handle_a2a_response (
1794- (mock_a2a_task , mock_update ), self .mock_context
1882+ (mock_a2a_task , update ), self .mock_context
17951883 )
17961884
17971885 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- )
1886+ mock_convert .assert_called_once ()
1887+ # Only the parts carried by this update are converted, not the
1888+ # accumulated task.
1889+ converted_message = mock_convert .call_args [0 ][0 ]
1890+ assert list (converted_message .parts ) == list (update .artifact .parts )
18041891 # Check that metadata was added
18051892 assert result .custom_metadata is not None
18061893 assert A2A_METADATA_PREFIX + "task_id" in result .custom_metadata
18071894 assert A2A_METADATA_PREFIX + "context_id" in result .custom_metadata
18081895
18091896 @pytest .mark .asyncio
1810- async def test_handle_a2a_response_with_partial_artifact_update (self ):
1811- """Test that partial artifact updates are ignored ."""
1897+ async def test_handle_a2a_response_with_appended_artifact_chunk (self ):
1898+ """An appended (middle) artifact chunk emits only its own parts ."""
18121899 mock_a2a_task = Mock (spec = A2ATask )
18131900 mock_a2a_task .id = "task-123"
1901+ mock_a2a_task .context_id = "context-123"
18141902
1815- mock_update = Mock (spec = TaskArtifactUpdateEvent )
1816- mock_update .artifact = Mock (spec = Artifact )
1817- mock_update .append = True
1818- mock_update .last_chunk = False
1903+ update = _make_artifact_chunk ("middle" , append = True , last_chunk = False )
18191904
1820- result = await self .agent ._handle_a2a_response (
1821- (mock_a2a_task , mock_update ), self .mock_context
1905+ mock_event = Event (
1906+ author = self .agent .name ,
1907+ invocation_id = self .mock_context .invocation_id ,
1908+ branch = self .mock_context .branch ,
18221909 )
18231910
1824- assert result is None
1911+ with patch (
1912+ "google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
1913+ ) as mock_convert :
1914+ mock_convert .return_value = mock_event
1915+
1916+ result = await self .agent ._handle_a2a_response (
1917+ (mock_a2a_task , update ), self .mock_context
1918+ )
1919+
1920+ assert result == mock_event
1921+ assert result .partial is True
1922+ converted_message = mock_convert .call_args [0 ][0 ]
1923+ assert list (converted_message .parts ) == list (update .artifact .parts )
18251924
18261925
18271926class TestRemoteA2aAgentMessageHandlingV2 :
@@ -2250,23 +2349,21 @@ async def test_legacy_message_converter_returns_none_status_update(self):
22502349 assert result is None
22512350
22522351 @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."""
2352+ async def test_legacy_message_converter_returns_none_artifact_update (self ):
2353+ """Legacy handler must not crash when message converter returns None for artifact update."""
22552354 mock_task = Mock (spec = A2ATask )
22562355 mock_task .id = "task-123"
22572356 mock_task .context_id = None
22582357
2259- mock_update = Mock (spec = TaskArtifactUpdateEvent )
2260- mock_update .append = False
2261- mock_update .last_chunk = True
2358+ update = _make_artifact_chunk ("chunk" , append = False , last_chunk = True )
22622359
22632360 with patch (
2264- "google.adk.agents.remote_a2a_agent.convert_a2a_task_to_event "
2361+ "google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event "
22652362 ) as mock_convert :
22662363 mock_convert .return_value = None
22672364
22682365 result = await self .legacy_agent ._handle_a2a_response (
2269- (mock_task , mock_update ), self .mock_context
2366+ (mock_task , update ), self .mock_context
22702367 )
22712368
22722369 assert result is None
0 commit comments