From f8871c21b4351e959d528dcc1ba8a7480888f2cf Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Tue, 18 Nov 2025 10:47:08 -0500 Subject: [PATCH 1/3] fix(a2a): base64 decode byte data before placing in ContentBlocks --- src/strands/multiagent/a2a/executor.py | 20 +++- tests/strands/multiagent/a2a/test_executor.py | 104 +++++++++++------- tests_integ/test_a2a_executor.py | 83 ++++++++++++++ 3 files changed, 164 insertions(+), 43 deletions(-) create mode 100644 tests_integ/test_a2a_executor.py diff --git a/src/strands/multiagent/a2a/executor.py b/src/strands/multiagent/a2a/executor.py index 74ecc6531..c87861a65 100644 --- a/src/strands/multiagent/a2a/executor.py +++ b/src/strands/multiagent/a2a/executor.py @@ -8,6 +8,7 @@ streamed requests to the A2AServer. """ +import base64 import json import logging import mimetypes @@ -268,18 +269,31 @@ def _convert_a2a_parts_to_content_blocks(self, parts: list[Part]) -> list[Conten file_name = self._strip_file_extension(raw_file_name) file_type = self._get_file_type_from_mime_type(mime_type) file_format = self._get_file_format_from_mime_type(mime_type, file_type) + logger.info( + "File processing: name=%s, mime=%s, type=%s, format=%s", + raw_file_name, + mime_type, + file_type, + file_format, + ) # Handle FileWithBytes vs FileWithUri bytes_data = getattr(file_obj, "bytes", None) uri_data = getattr(file_obj, "uri", None) if bytes_data: + # A2A bytes are always base64-encoded strings + try: + decoded_bytes = base64.b64decode(bytes_data) + except Exception as e: + raise ValueError(f"Failed to decode base64 data for file '{raw_file_name}': {e}") from e + if file_type == "image": content_blocks.append( ContentBlock( image=ImageContent( format=file_format, # type: ignore - source=ImageSource(bytes=bytes_data), + source=ImageSource(bytes=decoded_bytes), ) ) ) @@ -288,7 +302,7 @@ def _convert_a2a_parts_to_content_blocks(self, parts: list[Part]) -> list[Conten ContentBlock( video=VideoContent( format=file_format, # type: ignore - source=VideoSource(bytes=bytes_data), + source=VideoSource(bytes=decoded_bytes), ) ) ) @@ -298,7 +312,7 @@ def _convert_a2a_parts_to_content_blocks(self, parts: list[Part]) -> list[Conten document=DocumentContent( format=file_format, # type: ignore name=file_name, - source=DocumentSource(bytes=bytes_data), + source=DocumentSource(bytes=decoded_bytes), ) ) ) diff --git a/tests/strands/multiagent/a2a/test_executor.py b/tests/strands/multiagent/a2a/test_executor.py index 3f63119f2..5057c8279 100644 --- a/tests/strands/multiagent/a2a/test_executor.py +++ b/tests/strands/multiagent/a2a/test_executor.py @@ -1,15 +1,24 @@ """Tests for the StrandsA2AExecutor class.""" +import base64 from unittest.mock import AsyncMock, MagicMock, patch import pytest -from a2a.types import InternalError, UnsupportedOperationError +from a2a.types import DataPart, FilePart, InternalError, TextPart, UnsupportedOperationError from a2a.utils.errors import ServerError from strands.agent.agent_result import AgentResult as SAAgentResult from strands.multiagent.a2a.executor import StrandsA2AExecutor from strands.types.content import ContentBlock +# Test data constants +VALID_PNG_BYTES = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82" +) +VALID_MP4_BYTES = b"\x00\x00\x00\x20ftypmp42\x00\x00\x00\x00mp42isom" +VALID_DOCUMENT_BYTES = b"fake_document_data" + def test_executor_initialization(mock_strands_agent): """Test that StrandsA2AExecutor initializes correctly.""" @@ -96,18 +105,15 @@ def test_convert_a2a_parts_to_content_blocks_text_part(): def test_convert_a2a_parts_to_content_blocks_file_part_image_bytes(): """Test conversion of FilePart with image bytes to ContentBlock.""" - from a2a.types import FilePart - executor = StrandsA2AExecutor(MagicMock()) - # Create test image bytes (no base64 encoding needed) - test_bytes = b"fake_image_data" + base64_bytes = base64.b64encode(VALID_PNG_BYTES).decode("utf-8") # Mock file object file_obj = MagicMock() - file_obj.name = "test_image.jpeg" - file_obj.mime_type = "image/jpeg" - file_obj.bytes = test_bytes + file_obj.name = "test_image.png" + file_obj.mime_type = "image/png" + file_obj.bytes = base64_bytes file_obj.uri = None # Mock FilePart with proper spec @@ -123,24 +129,21 @@ def test_convert_a2a_parts_to_content_blocks_file_part_image_bytes(): assert len(result) == 1 content_block = result[0] assert "image" in content_block - assert content_block["image"]["format"] == "jpeg" - assert content_block["image"]["source"]["bytes"] == test_bytes + assert content_block["image"]["format"] == "png" + assert content_block["image"]["source"]["bytes"] == VALID_PNG_BYTES def test_convert_a2a_parts_to_content_blocks_file_part_video_bytes(): """Test conversion of FilePart with video bytes to ContentBlock.""" - from a2a.types import FilePart - executor = StrandsA2AExecutor(MagicMock()) - # Create test video bytes (no base64 encoding needed) - test_bytes = b"fake_video_data" + base64_bytes = base64.b64encode(VALID_MP4_BYTES).decode("utf-8") # Mock file object file_obj = MagicMock() file_obj.name = "test_video.mp4" file_obj.mime_type = "video/mp4" - file_obj.bytes = test_bytes + file_obj.bytes = base64_bytes file_obj.uri = None # Mock FilePart with proper spec @@ -157,23 +160,20 @@ def test_convert_a2a_parts_to_content_blocks_file_part_video_bytes(): content_block = result[0] assert "video" in content_block assert content_block["video"]["format"] == "mp4" - assert content_block["video"]["source"]["bytes"] == test_bytes + assert content_block["video"]["source"]["bytes"] == VALID_MP4_BYTES def test_convert_a2a_parts_to_content_blocks_file_part_document_bytes(): """Test conversion of FilePart with document bytes to ContentBlock.""" - from a2a.types import FilePart - executor = StrandsA2AExecutor(MagicMock()) - # Create test document bytes (no base64 encoding needed) - test_bytes = b"fake_document_data" + base64_bytes = base64.b64encode(VALID_DOCUMENT_BYTES).decode("utf-8") # Mock file object file_obj = MagicMock() file_obj.name = "test_document.pdf" file_obj.mime_type = "application/pdf" - file_obj.bytes = test_bytes + file_obj.bytes = base64_bytes file_obj.uri = None # Mock FilePart with proper spec @@ -191,7 +191,7 @@ def test_convert_a2a_parts_to_content_blocks_file_part_document_bytes(): assert "document" in content_block assert content_block["document"]["format"] == "pdf" assert content_block["document"]["name"] == "test_document" - assert content_block["document"]["source"]["bytes"] == test_bytes + assert content_block["document"]["source"]["bytes"] == VALID_DOCUMENT_BYTES def test_convert_a2a_parts_to_content_blocks_file_part_uri(): @@ -226,15 +226,15 @@ def test_convert_a2a_parts_to_content_blocks_file_part_uri(): def test_convert_a2a_parts_to_content_blocks_file_part_with_bytes(): """Test conversion of FilePart with bytes data.""" - from a2a.types import FilePart - executor = StrandsA2AExecutor(MagicMock()) + base64_bytes = base64.b64encode(VALID_PNG_BYTES).decode("utf-8") + # Mock file object with bytes (no validation needed since no decoding) file_obj = MagicMock() file_obj.name = "test_image.png" file_obj.mime_type = "image/png" - file_obj.bytes = b"some_binary_data" + file_obj.bytes = base64_bytes file_obj.uri = None # Mock FilePart with proper spec @@ -250,7 +250,34 @@ def test_convert_a2a_parts_to_content_blocks_file_part_with_bytes(): assert len(result) == 1 content_block = result[0] assert "image" in content_block - assert content_block["image"]["source"]["bytes"] == b"some_binary_data" + assert content_block["image"]["source"]["bytes"] == VALID_PNG_BYTES + + +def test_convert_a2a_parts_to_content_blocks_file_part_invalid_base64(): + """Test conversion of FilePart with invalid base64 data raises ValueError.""" + executor = StrandsA2AExecutor(MagicMock()) + + # Invalid base64 string - contains invalid characters + invalid_base64 = "SGVsbG8gV29ybGQ@#$%" + + # Mock file object with invalid base64 bytes + file_obj = MagicMock() + file_obj.name = "test.txt" + file_obj.mime_type = "text/plain" + file_obj.bytes = invalid_base64 + file_obj.uri = None + + # Mock FilePart + file_part = MagicMock(spec=FilePart) + file_part.file = file_obj + part = MagicMock() + part.root = file_part + + # Should handle the base64 decode error gracefully and return empty list + result = executor._convert_a2a_parts_to_content_blocks([part]) + assert isinstance(result, list) + # The part should be skipped due to base64 decode error + assert len(result) == 0 def test_convert_a2a_parts_to_content_blocks_data_part(): @@ -704,15 +731,15 @@ def test_convert_a2a_parts_to_content_blocks_empty_list(): def test_convert_a2a_parts_to_content_blocks_file_part_no_name(): """Test conversion of FilePart with no file name.""" - from a2a.types import FilePart - executor = StrandsA2AExecutor(MagicMock()) + base64_bytes = base64.b64encode(VALID_DOCUMENT_BYTES).decode("utf-8") + # Mock file object without name file_obj = MagicMock() delattr(file_obj, "name") # Remove name attribute file_obj.mime_type = "text/plain" - file_obj.bytes = b"test content" + file_obj.bytes = base64_bytes file_obj.uri = None # Mock FilePart with proper spec @@ -733,15 +760,15 @@ def test_convert_a2a_parts_to_content_blocks_file_part_no_name(): def test_convert_a2a_parts_to_content_blocks_file_part_no_mime_type(): """Test conversion of FilePart with no MIME type.""" - from a2a.types import FilePart - executor = StrandsA2AExecutor(MagicMock()) + base64_bytes = base64.b64encode(VALID_DOCUMENT_BYTES).decode("utf-8") + # Mock file object without MIME type file_obj = MagicMock() file_obj.name = "test_file" delattr(file_obj, "mime_type") - file_obj.bytes = b"test content" + file_obj.bytes = base64_bytes file_obj.uri = None # Mock FilePart with proper spec @@ -837,7 +864,6 @@ async def test_execute_streaming_mode_raises_error_for_empty_content_blocks( @pytest.mark.asyncio async def test_execute_with_mixed_part_types(mock_strands_agent, mock_request_context, mock_event_queue): """Test execute with a message containing mixed A2A part types.""" - from a2a.types import DataPart, FilePart, TextPart async def mock_stream(content_blocks): """Mock streaming function.""" @@ -866,7 +892,7 @@ async def mock_stream(content_blocks): file_obj = MagicMock() file_obj.name = "image.png" file_obj.mime_type = "image/png" - file_obj.bytes = b"fake_image" + file_obj.bytes = base64.b64encode(VALID_PNG_BYTES).decode("utf-8") file_obj.uri = None file_part = MagicMock(spec=FilePart) file_part.file = file_obj @@ -907,8 +933,6 @@ def test_integration_example(): This test serves as documentation for the conversion functionality. """ - from a2a.types import DataPart, FilePart, TextPart - executor = StrandsA2AExecutor(MagicMock()) # Example 1: Text content @@ -918,7 +942,7 @@ def test_integration_example(): text_part_mock.root = text_part # Example 2: Image file - image_bytes = b"fake_image_content" + image_bytes = base64.b64encode(VALID_PNG_BYTES).decode("utf-8") image_file = MagicMock() image_file.name = "photo.jpg" image_file.mime_type = "image/jpeg" @@ -931,7 +955,7 @@ def test_integration_example(): image_part_mock.root = image_part # Example 3: Document file - doc_bytes = b"PDF document content" + doc_bytes = base64.b64encode(VALID_DOCUMENT_BYTES).decode("utf-8") doc_file = MagicMock() doc_file.name = "report.pdf" doc_file.mime_type = "application/pdf" @@ -962,13 +986,13 @@ def test_integration_example(): # Image part becomes image ContentBlock with proper format and bytes assert "image" in content_blocks[1] assert content_blocks[1]["image"]["format"] == "jpeg" - assert content_blocks[1]["image"]["source"]["bytes"] == image_bytes + assert content_blocks[1]["image"]["source"]["bytes"] == VALID_PNG_BYTES # Document part becomes document ContentBlock assert "document" in content_blocks[2] assert content_blocks[2]["document"]["format"] == "pdf" assert content_blocks[2]["document"]["name"] == "report" # Extension stripped - assert content_blocks[2]["document"]["source"]["bytes"] == doc_bytes + assert content_blocks[2]["document"]["source"]["bytes"] == VALID_DOCUMENT_BYTES # Data part becomes text ContentBlock with JSON representation assert "text" in content_blocks[3] diff --git a/tests_integ/test_a2a_executor.py b/tests_integ/test_a2a_executor.py new file mode 100644 index 000000000..c95c09611 --- /dev/null +++ b/tests_integ/test_a2a_executor.py @@ -0,0 +1,83 @@ +"""Integration tests for A2A executor with real file processing.""" + +import base64 +import os +from unittest.mock import MagicMock + +import pytest +from a2a.types import FilePart, TextPart + +from strands.multiagent.a2a.executor import StrandsA2AExecutor + + +@pytest.mark.asyncio +async def test_a2a_executor_with_real_image(): + """Test A2A executor processes a real image file correctly.""" + # Read the test image file + test_image_path = os.path.join(os.path.dirname(__file__), "yellow.png") + with open(test_image_path, "rb") as f: + original_image_bytes = f.read() + + # Encode as base64 (A2A format) + base64_image = base64.b64encode(original_image_bytes).decode("utf-8") + + # Create executor + executor = StrandsA2AExecutor(MagicMock()) + + # Create A2A message parts + text_part = MagicMock(spec=TextPart) + text_part.text = "Please analyze this image" + text_part_mock = MagicMock() + text_part_mock.root = text_part + + # Create file part with real image data + file_obj = MagicMock() + file_obj.name = "yellow.png" + file_obj.mime_type = "image/png" + file_obj.bytes = base64_image # A2A sends base64-encoded string + file_obj.uri = None + + file_part = MagicMock(spec=FilePart) + file_part.file = file_obj + file_part_mock = MagicMock() + file_part_mock.root = file_part + + # Convert parts to content blocks + parts = [text_part_mock, file_part_mock] + content_blocks = executor._convert_a2a_parts_to_content_blocks(parts) + + # Verify conversion worked correctly + assert len(content_blocks) == 2 + + # Verify text conversion + assert content_blocks[0]["text"] == "Please analyze this image" + + # Verify image conversion - most importantly, bytes should match original + assert "image" in content_blocks[1] + assert content_blocks[1]["image"]["format"] == "png" + assert content_blocks[1]["image"]["source"]["bytes"] == original_image_bytes + + # Verify the round-trip: original -> base64 -> decoded == original + assert len(content_blocks[1]["image"]["source"]["bytes"]) == len(original_image_bytes) + assert content_blocks[1]["image"]["source"]["bytes"] == original_image_bytes + + +def test_a2a_executor_image_roundtrip(): + """Test that image data survives the A2A base64 encoding/decoding roundtrip.""" + # Read the test image + test_image_path = os.path.join(os.path.dirname(__file__), "yellow.png") + with open(test_image_path, "rb") as f: + original_bytes = f.read() + + # Simulate A2A protocol: encode to base64 string + base64_string = base64.b64encode(original_bytes).decode("utf-8") + + # Simulate executor decoding + decoded_bytes = base64.b64decode(base64_string) + + # Verify perfect roundtrip + assert decoded_bytes == original_bytes + assert len(decoded_bytes) == len(original_bytes) + + # Verify it's actually image data (PNG signature) + assert decoded_bytes.startswith(b"\x89PNG\r\n\x1a\n") From 1e235f6ee041152badd242415e15c06ee32862fe Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Tue, 18 Nov 2025 11:05:07 -0500 Subject: [PATCH 2/3] update integ test --- src/strands/multiagent/a2a/executor.py | 9 +-- tests_integ/test_a2a_executor.py | 101 ++++++++++++++----------- 2 files changed, 59 insertions(+), 51 deletions(-) diff --git a/src/strands/multiagent/a2a/executor.py b/src/strands/multiagent/a2a/executor.py index c87861a65..52b6d2ef1 100644 --- a/src/strands/multiagent/a2a/executor.py +++ b/src/strands/multiagent/a2a/executor.py @@ -269,21 +269,14 @@ def _convert_a2a_parts_to_content_blocks(self, parts: list[Part]) -> list[Conten file_name = self._strip_file_extension(raw_file_name) file_type = self._get_file_type_from_mime_type(mime_type) file_format = self._get_file_format_from_mime_type(mime_type, file_type) - logger.info( - "File processing: name=%s, mime=%s, type=%s, format=%s", - raw_file_name, - mime_type, - file_type, - file_format, - ) # Handle FileWithBytes vs FileWithUri bytes_data = getattr(file_obj, "bytes", None) uri_data = getattr(file_obj, "uri", None) if bytes_data: - # A2A bytes are always base64-encoded strings try: + # A2A bytes are always base64-encoded strings decoded_bytes = base64.b64decode(bytes_data) except Exception as e: raise ValueError(f"Failed to decode base64 data for file '{raw_file_name}': {e}") from e diff --git a/tests_integ/test_a2a_executor.py b/tests_integ/test_a2a_executor.py index c95c09611..ddca0bfa6 100644 --- a/tests_integ/test_a2a_executor.py +++ b/tests_integ/test_a2a_executor.py @@ -2,17 +2,20 @@ import base64 import os -from unittest.mock import MagicMock +import threading +import time import pytest -from a2a.types import FilePart, TextPart +import requests +import uvicorn -from strands.multiagent.a2a.executor import StrandsA2AExecutor +from strands import Agent +from strands.multiagent.a2a import A2AServer @pytest.mark.asyncio async def test_a2a_executor_with_real_image(): - """Test A2A executor processes a real image file correctly.""" + """Test A2A server processes a real image file correctly via HTTP.""" # Read the test image file test_image_path = os.path.join(os.path.dirname(__file__), "yellow.png") with open(test_image_path, "rb") as f: @@ -21,45 +24,57 @@ async def test_a2a_executor_with_real_image(): # Encode as base64 (A2A format) base64_image = base64.b64encode(original_image_bytes).decode("utf-8") - # Create executor - executor = StrandsA2AExecutor(MagicMock()) - - # Create A2A message parts - text_part = MagicMock(spec=TextPart) - text_part.text = "Please analyze this image" - text_part_mock = MagicMock() - text_part_mock.root = text_part - - # Create file part with real image data - file_obj = MagicMock() - file_obj.name = "yellow.png" - file_obj.mime_type = "image/png" - file_obj.bytes = base64_image # A2A sends base64-encoded string - file_obj.uri = None - - file_part = MagicMock(spec=FilePart) - file_part.file = file_obj - file_part_mock = MagicMock() - file_part_mock.root = file_part - - # Convert parts to content blocks - parts = [text_part_mock, file_part_mock] - content_blocks = executor._convert_a2a_parts_to_content_blocks(parts) - - # Verify conversion worked correctly - assert len(content_blocks) == 2 - - # Verify text conversion - assert content_blocks[0]["text"] == "Please analyze this image" - - # Verify image conversion - most importantly, bytes should match original - assert "image" in content_blocks[1] - assert content_blocks[1]["image"]["format"] == "png" - assert content_blocks[1]["image"]["source"]["bytes"] == original_image_bytes - - # Verify the round-trip: original -> base64 -> decoded == original - assert len(content_blocks[1]["image"]["source"]["bytes"]) == len(original_image_bytes) - assert content_blocks[1]["image"]["source"]["bytes"] == original_image_bytes + # Create real Strands agent + strands_agent = Agent(name="Test Image Agent", description="Agent for testing image processing") + + # Create A2A server + a2a_server = A2AServer(agent=strands_agent, port=9001) + fastapi_app = a2a_server.to_fastapi_app() + + # Start server in background + server_thread = threading.Thread(target=lambda: uvicorn.run(fastapi_app, port=9001), daemon=True) + server_thread.start() + time.sleep(1) # Give server time to start + + try: + # Create A2A message with real image + message_payload = { + "jsonrpc": "2.0", + "id": "test-image-request", + "method": "message/send", + "params": { + "message": { + "messageId": "msg-123", + "role": "user", + "parts": [ + { + "kind": "text", + "text": "What primary color is this image, respond with NONE if you are unsure", + "metadata": None, + }, + { + "kind": "file", + "file": {"name": "image.png", "mimeType": "image/png", "bytes": base64_image}, + "metadata": None, + }, + ], + } + }, + } + + # Send request to A2A server + response = requests.post( + "http://127.0.0.1:9001", headers={"Content-Type": "application/json"}, json=message_payload, timeout=30 + ) + + # Verify response + assert response.status_code == 200 + response_data = response.json() + assert "completed" == response_data["result"]["status"]["state"] + assert "yellow" in response_data["result"]["history"][1]["parts"][0]["text"].lower() + + except Exception as e: + pytest.fail(f"Integration test failed: {e}") def test_a2a_executor_image_roundtrip(): From e44fb3761d268b622169293886650006630e8a36 Mon Sep 17 00:00:00 2001 From: Dean Schmigelski Date: Tue, 18 Nov 2025 11:11:55 -0500 Subject: [PATCH 3/3] simplify unit test --- tests/strands/multiagent/a2a/test_executor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/strands/multiagent/a2a/test_executor.py b/tests/strands/multiagent/a2a/test_executor.py index 5057c8279..1463d3f48 100644 --- a/tests/strands/multiagent/a2a/test_executor.py +++ b/tests/strands/multiagent/a2a/test_executor.py @@ -12,11 +12,8 @@ from strands.types.content import ContentBlock # Test data constants -VALID_PNG_BYTES = ( - b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" - b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xdb\x00\x00\x00\x00IEND\xaeB`\x82" -) -VALID_MP4_BYTES = b"\x00\x00\x00\x20ftypmp42\x00\x00\x00\x00mp42isom" +VALID_PNG_BYTES = b"fake_png_data" +VALID_MP4_BYTES = b"fake_mp4_data" VALID_DOCUMENT_BYTES = b"fake_document_data"