fix: emit only new artifact parts on streaming artifact updates

Merge https://github.com/google/adk-python/pull/6386

Fixes #6343

PiperOrigin-RevId: 952172971
This commit is contained in:
Brumbelow
2026-07-22 09:40:30 -07:00
committed by Copybara-Service
parent 968845fd5c
commit 088be86040
2 changed files with 170 additions and 68 deletions
+16 -11
View File
@@ -543,21 +543,26 @@ class RemoteA2aAgent(BaseAgent):
for part in event.content.parts:
part.thought = True
_add_mock_function_call(event, update.status.state)
elif isinstance(update, A2ATaskArtifactUpdateEvent) and (
not update.append or update.last_chunk
):
elif isinstance(update, A2ATaskArtifactUpdateEvent):
# This is a streaming task artifact update.
# We only handle full artifact updates and ignore partial updates.
# Note: Depends on the server implementation, there is no clear
# definition of what a partial update is currently. We use the two
# signals:
# 1. append: True for partial updates, False for full updates.
# 2. last_chunk: True for full updates, False for partial updates.
event = convert_a2a_task_to_event(
task, self.name, ctx, self._a2a_part_converter
# Convert only the parts carried by this update. Converting the
# accumulated task here would re-emit earlier chunks of the same
# artifact, duplicating already-streamed content.
if not update.artifact.parts:
return None
event = convert_a2a_message_to_event(
_compat.make_message(
message_id="",
role="agent",
parts=update.artifact.parts,
),
self.name,
ctx,
self._a2a_part_converter,
)
if not event:
return None
event.partial = not update.last_chunk
else:
# This is a streaming update without a message (e.g. status change)
# or a partial artifact update. We don't emit an event for these
+154 -57
View File
@@ -106,6 +106,39 @@ def _make_stream_message(message: A2AMessage):
return message
def _make_artifact_chunk(text: str, *, append: bool, last_chunk: bool):
"""Build one streamed chunk of an artifact, version-agnostically."""
return TaskArtifactUpdateEvent(
task_id="task-123",
context_id="context-123",
append=append,
last_chunk=last_chunk,
artifact=_compat.make_artifact(
artifact_id="artifact-1",
parts=[_compat.make_text_part(text)],
),
)
def _make_accumulated_task(part_texts):
"""Build the running Task the stream normalizer yields alongside an update.
The task carries the artifact parts accumulated across all chunks received
so far, mirroring the 0.3.x ClientTaskManager / 1.x stream normalizer.
"""
return _compat.make_task(
id="task-123",
status=_compat.make_task_status(_compat.TS_WORKING),
context_id="context-123",
artifacts=[
_compat.make_artifact(
artifact_id="artifact-1",
parts=[_compat.make_text_part(text) for text in part_texts],
)
],
)
# Helper function to create a proper AgentCard for testing
def create_test_agent_card(
name: str = "test-agent",
@@ -1326,11 +1359,7 @@ class TestRemoteA2aAgentMessageHandling:
mock_a2a_task.id = "task-123"
mock_a2a_task.context_id = "context-123"
mock_artifact = Mock(spec=Artifact)
mock_update = Mock(spec=TaskArtifactUpdateEvent)
mock_update.artifact = mock_artifact
mock_update.append = False
mock_update.last_chunk = True
update = _make_artifact_chunk("chunk", append=False, last_chunk=True)
# Create a proper Event mock that can handle custom_metadata
mock_event = Event(
@@ -1339,45 +1368,54 @@ class TestRemoteA2aAgentMessageHandling:
branch=self.mock_context.branch,
)
with patch.object(
remote_a2a_agent,
"convert_a2a_task_to_event",
autospec=True,
with patch(
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
) as mock_convert:
mock_convert.return_value = mock_event
result = await self.agent._handle_a2a_response(
(mock_a2a_task, mock_update), self.mock_context
(mock_a2a_task, update), self.mock_context
)
assert result == mock_event
mock_convert.assert_called_once_with(
mock_a2a_task,
self.agent.name,
self.mock_context,
self.agent._a2a_part_converter,
)
mock_convert.assert_called_once()
# Only the parts carried by this update are converted, not the
# accumulated task.
converted_message = mock_convert.call_args[0][0]
assert list(converted_message.parts) == list(update.artifact.parts)
# Check that metadata was added
assert result.custom_metadata is not None
assert A2A_METADATA_PREFIX + "task_id" in result.custom_metadata
assert A2A_METADATA_PREFIX + "context_id" in result.custom_metadata
@pytest.mark.asyncio
async def test_handle_a2a_response_with_partial_artifact_update(self):
"""Test that partial artifact updates are ignored."""
async def test_handle_a2a_response_with_appended_artifact_chunk(self):
"""An appended (middle) artifact chunk emits only its own parts."""
mock_a2a_task = Mock(spec=A2ATask)
mock_a2a_task.id = "task-123"
mock_a2a_task.context_id = "context-123"
mock_update = Mock(spec=TaskArtifactUpdateEvent)
mock_update.artifact = Mock(spec=Artifact)
mock_update.append = True
mock_update.last_chunk = False
update = _make_artifact_chunk("middle", append=True, last_chunk=False)
result = await self.agent._handle_a2a_response(
(mock_a2a_task, mock_update), self.mock_context
mock_event = Event(
author=self.agent.name,
invocation_id=self.mock_context.invocation_id,
branch=self.mock_context.branch,
)
assert result is None
with patch(
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
) as mock_convert:
mock_convert.return_value = mock_event
result = await self.agent._handle_a2a_response(
(mock_a2a_task, update), self.mock_context
)
assert result == mock_event
assert result.partial is True
converted_message = mock_convert.call_args[0][0]
assert list(converted_message.parts) == list(update.artifact.parts)
@pytest.mark.asyncio
async def test_handle_a2a_response_with_real_empty_status_message(self):
@@ -1407,6 +1445,62 @@ class TestRemoteA2aAgentMessageHandling:
assert result is None
class TestRemoteA2aAgentStreamingArtifactChunks:
"""Regression tests for chunked artifact streams (#6343)."""
def setup_method(self):
"""Setup test fixtures."""
self.agent = RemoteA2aAgent(
name="test_agent",
agent_card=create_test_agent_card(),
)
self.mock_context = Mock(spec=InvocationContext)
self.mock_context.invocation_id = "invocation-123"
self.mock_context.branch = "main"
@pytest.mark.asyncio
async def test_chunked_artifact_stream_emits_each_part_exactly_once(self):
"""A two-chunk artifact stream renders its parts without duplication."""
chunk1 = _make_artifact_chunk("Hello, ", append=False, last_chunk=False)
chunk2 = _make_artifact_chunk("world!", append=True, last_chunk=True)
# (task, update) pairs as the client stream yields them: the task carries
# the artifact parts accumulated so far.
stream = [
(_make_accumulated_task(["Hello, "]), chunk1),
(_make_accumulated_task(["Hello, ", "world!"]), chunk2),
]
rendered = []
events = []
for pair in stream:
event = await self.agent._handle_a2a_response(pair, self.mock_context)
events.append(event)
if event and event.content and event.content.parts:
rendered.extend(part.text for part in event.content.parts if part.text)
assert "".join(rendered) == "Hello, world!"
assert events[0].partial is True
assert events[1].partial is False
@pytest.mark.asyncio
async def test_artifact_update_without_parts_is_ignored(self):
"""An artifact update carrying no parts must not emit a spurious event."""
update = TaskArtifactUpdateEvent(
task_id="task-123",
context_id="context-123",
append=False,
last_chunk=True,
artifact=_compat.make_artifact(artifact_id="artifact-1", parts=[]),
)
task = _make_accumulated_task(["already streamed"])
result = await self.agent._handle_a2a_response(
(task, update), self.mock_context
)
assert result is None
class TestRemoteA2aAgentMessageHandlingFromFactory:
"""Test message handling functionality."""
@@ -1770,11 +1864,7 @@ class TestRemoteA2aAgentMessageHandlingFromFactory:
mock_a2a_task.id = "task-123"
mock_a2a_task.context_id = "context-123"
mock_artifact = Mock(spec=Artifact)
mock_update = Mock(spec=TaskArtifactUpdateEvent)
mock_update.artifact = mock_artifact
mock_update.append = False
mock_update.last_chunk = True
update = _make_artifact_chunk("chunk", append=False, last_chunk=True)
# Create a proper Event mock that can handle custom_metadata
mock_event = Event(
@@ -1783,45 +1873,54 @@ class TestRemoteA2aAgentMessageHandlingFromFactory:
branch=self.mock_context.branch,
)
with patch.object(
remote_a2a_agent,
"convert_a2a_task_to_event",
autospec=True,
with patch(
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
) as mock_convert:
mock_convert.return_value = mock_event
result = await self.agent._handle_a2a_response(
(mock_a2a_task, mock_update), self.mock_context
(mock_a2a_task, update), self.mock_context
)
assert result == mock_event
mock_convert.assert_called_once_with(
mock_a2a_task,
self.agent.name,
self.mock_context,
self.agent._a2a_part_converter,
)
mock_convert.assert_called_once()
# Only the parts carried by this update are converted, not the
# accumulated task.
converted_message = mock_convert.call_args[0][0]
assert list(converted_message.parts) == list(update.artifact.parts)
# Check that metadata was added
assert result.custom_metadata is not None
assert A2A_METADATA_PREFIX + "task_id" in result.custom_metadata
assert A2A_METADATA_PREFIX + "context_id" in result.custom_metadata
@pytest.mark.asyncio
async def test_handle_a2a_response_with_partial_artifact_update(self):
"""Test that partial artifact updates are ignored."""
async def test_handle_a2a_response_with_appended_artifact_chunk(self):
"""An appended (middle) artifact chunk emits only its own parts."""
mock_a2a_task = Mock(spec=A2ATask)
mock_a2a_task.id = "task-123"
mock_a2a_task.context_id = "context-123"
mock_update = Mock(spec=TaskArtifactUpdateEvent)
mock_update.artifact = Mock(spec=Artifact)
mock_update.append = True
mock_update.last_chunk = False
update = _make_artifact_chunk("middle", append=True, last_chunk=False)
result = await self.agent._handle_a2a_response(
(mock_a2a_task, mock_update), self.mock_context
mock_event = Event(
author=self.agent.name,
invocation_id=self.mock_context.invocation_id,
branch=self.mock_context.branch,
)
assert result is None
with patch(
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
) as mock_convert:
mock_convert.return_value = mock_event
result = await self.agent._handle_a2a_response(
(mock_a2a_task, update), self.mock_context
)
assert result == mock_event
assert result.partial is True
converted_message = mock_convert.call_args[0][0]
assert list(converted_message.parts) == list(update.artifact.parts)
class TestRemoteA2aAgentMessageHandlingV2:
@@ -2250,23 +2349,21 @@ class TestRemoteA2aAgentNoneConverterResults:
assert result is None
@pytest.mark.asyncio
async def test_legacy_task_converter_returns_none_artifact_update(self):
"""Legacy handler must not crash when task converter returns None for artifact update."""
async def test_legacy_message_converter_returns_none_artifact_update(self):
"""Legacy handler must not crash when message converter returns None for artifact update."""
mock_task = Mock(spec=A2ATask)
mock_task.id = "task-123"
mock_task.context_id = None
mock_update = Mock(spec=TaskArtifactUpdateEvent)
mock_update.append = False
mock_update.last_chunk = True
update = _make_artifact_chunk("chunk", append=False, last_chunk=True)
with patch(
"google.adk.agents.remote_a2a_agent.convert_a2a_task_to_event"
"google.adk.agents.remote_a2a_agent.convert_a2a_message_to_event"
) as mock_convert:
mock_convert.return_value = None
result = await self.legacy_agent._handle_a2a_response(
(mock_task, mock_update), self.mock_context
(mock_task, update), self.mock_context
)
assert result is None