diff --git a/src/strands/multiagent/a2a/executor.py b/src/strands/multiagent/a2a/executor.py index 74ecc6531..52b6d2ef1 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 @@ -274,12 +275,18 @@ def _convert_a2a_parts_to_content_blocks(self, parts: list[Part]) -> list[Conten uri_data = getattr(file_obj, "uri", None) if bytes_data: + 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 + 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 +295,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 +305,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..1463d3f48 100644 --- a/tests/strands/multiagent/a2a/test_executor.py +++ b/tests/strands/multiagent/a2a/test_executor.py @@ -1,15 +1,21 @@ """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"fake_png_data" +VALID_MP4_BYTES = b"fake_mp4_data" +VALID_DOCUMENT_BYTES = b"fake_document_data" + def test_executor_initialization(mock_strands_agent): """Test that StrandsA2AExecutor initializes correctly.""" @@ -96,18 +102,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 +126,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 +157,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 +188,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 +223,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 +247,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 +728,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 +757,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 +861,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 +889,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 +930,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 +939,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 +952,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 +983,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..ddca0bfa6 --- /dev/null +++ b/tests_integ/test_a2a_executor.py @@ -0,0 +1,98 @@ +"""Integration tests for A2A executor with real file processing.""" + +import base64 +import os +import threading +import time + +import pytest +import requests +import uvicorn + +from strands import Agent +from strands.multiagent.a2a import A2AServer + + +@pytest.mark.asyncio +async def test_a2a_executor_with_real_image(): + """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: + original_image_bytes = f.read() + + # Encode as base64 (A2A format) + base64_image = base64.b64encode(original_image_bytes).decode("utf-8") + + # 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(): + """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")