Skip to content

Commit 088be86

Browse files
Brumbelowcopybara-github
authored andcommitted
fix: emit only new artifact parts on streaming artifact updates
Merge #6386 Fixes #6343 PiperOrigin-RevId: 952172971
1 parent 968845f commit 088be86

2 files changed

Lines changed: 170 additions & 68 deletions

File tree

src/google/adk/agents/remote_a2a_agent.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -543,21 +543,26 @@ 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
565+
event.partial = not update.last_chunk
561566
else:
562567
# This is a streaming update without a message (e.g. status change)
563568
# or a partial artifact update. We don't emit an event for these

tests/unittests/agents/test_remote_a2a_agent.py

Lines changed: 154 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,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+
14101504
class 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

18271926
class 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

Comments
 (0)