diff --git a/examples/helloworld/__main__.py b/examples/helloworld/__main__.py index dfd9818f..b1cb4095 100644 --- a/examples/helloworld/__main__.py +++ b/examples/helloworld/__main__.py @@ -5,7 +5,11 @@ from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler from a2a.server.tasks import InMemoryTaskStore -from a2a.types import AgentCapabilities, AgentCard, AgentSkill +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, +) if __name__ == '__main__': @@ -17,7 +21,16 @@ examples=['hi', 'hello world'], ) - agent_card = AgentCard( + extended_skill = AgentSkill( + id='super_hello_world', + name='Returns a SUPER Hello World', + description='A more enthusiastic greeting, only for authenticated users.', + tags=['hello world', 'super', 'extended'], + examples=['super hi', 'give me a super hello'], + ) + + # This will be the public-facing agent card + public_agent_card = AgentCard( name='Hello World Agent', description='Just a hello world agent', url='http://localhost:9999/', @@ -25,7 +38,21 @@ defaultInputModes=['text'], defaultOutputModes=['text'], capabilities=AgentCapabilities(streaming=True), - skills=[skill], + skills=[skill], # Only the basic skill for the public card + supportsAuthenticatedExtendedCard=True, + ) + + # This will be the authenticated extended agent card + # It includes the additional 'extended_skill' + specific_extended_agent_card = public_agent_card.model_copy( + update={ + 'name': 'Hello World Agent - Extended Edition', # Different name for clarity + 'description': 'The full-featured hello world agent for authenticated users.', + 'version': '1.0.1', # Could even be a different version + # Capabilities and other fields like url, defaultInputModes, defaultOutputModes, + # supportsAuthenticatedExtendedCard are inherited from public_agent_card unless specified here. + 'skills': [skill, extended_skill], # Both skills for the extended card + } ) request_handler = DefaultRequestHandler( @@ -33,9 +60,9 @@ task_store=InMemoryTaskStore(), ) - server = A2AStarletteApplication( - agent_card=agent_card, http_handler=request_handler - ) + server = A2AStarletteApplication(agent_card=public_agent_card, + http_handler=request_handler, + extended_agent_card=specific_extended_agent_card) import uvicorn uvicorn.run(server.build(), host='0.0.0.0', port=9999) diff --git a/examples/helloworld/test_client.py b/examples/helloworld/test_client.py index 561784ad..d5409171 100644 --- a/examples/helloworld/test_client.py +++ b/examples/helloworld/test_client.py @@ -1,19 +1,70 @@ -from a2a.client import A2AClient +import logging # Import the logging module from typing import Any -import httpx from uuid import uuid4 -from a2a.types import ( - SendMessageRequest, - MessageSendParams, - SendStreamingMessageRequest, -) + +import httpx + +from a2a.client import A2ACardResolver, A2AClient +from a2a.types import (AgentCard, MessageSendParams, SendMessageRequest, + SendStreamingMessageRequest) async def main() -> None: + PUBLIC_AGENT_CARD_PATH = "/.well-known/agent.json" + EXTENDED_AGENT_CARD_PATH = "/agent/authenticatedExtendedCard" + + # Configure logging to show INFO level messages + logging.basicConfig(level=logging.INFO) + logger = logging.getLogger(__name__) # Get a logger instance + + base_url = 'http://localhost:9999' + async with httpx.AsyncClient() as httpx_client: - client = await A2AClient.get_client_from_agent_card_url( - httpx_client, 'http://localhost:9999' + # Initialize A2ACardResolver + resolver = A2ACardResolver( + httpx_client=httpx_client, + base_url=base_url, + # agent_card_path uses default, extended_agent_card_path also uses default ) + + # Fetch Public Agent Card and Initialize Client + final_agent_card_to_use: AgentCard | None = None + + try: + logger.info(f"Attempting to fetch public agent card from: {base_url}{PUBLIC_AGENT_CARD_PATH}") + _public_card = await resolver.get_agent_card() # Fetches from default public path + logger.info("Successfully fetched public agent card:") + logger.info(_public_card.model_dump_json(indent=2, exclude_none=True)) + final_agent_card_to_use = _public_card + logger.info("\nUsing PUBLIC agent card for client initialization (default).") + + if _public_card.supportsAuthenticatedExtendedCard: + try: + logger.info(f"\nPublic card supports authenticated extended card. Attempting to fetch from: {base_url}{EXTENDED_AGENT_CARD_PATH}") + auth_headers_dict = {"Authorization": "Bearer dummy-token-for-extended-card"} + _extended_card = await resolver.get_agent_card( + relative_card_path=EXTENDED_AGENT_CARD_PATH, + http_kwargs={"headers": auth_headers_dict} + ) + logger.info("Successfully fetched authenticated extended agent card:") + logger.info(_extended_card.model_dump_json(indent=2, exclude_none=True)) + final_agent_card_to_use = _extended_card # Update to use the extended card + logger.info("\nUsing AUTHENTICATED EXTENDED agent card for client initialization.") + except Exception as e_extended: + logger.warning(f"Failed to fetch extended agent card: {e_extended}. Will proceed with public card.", exc_info=True) + elif _public_card: # supportsAuthenticatedExtendedCard is False or None + logger.info("\nPublic card does not indicate support for an extended card. Using public card.") + + except Exception as e: + logger.error(f"Critical error fetching public agent card: {e}", exc_info=True) + raise RuntimeError("Failed to fetch the public agent card. Cannot continue.") from e + + + client = A2AClient( + httpx_client=httpx_client, agent_card=final_agent_card_to_use + ) + logger.info("A2AClient initialized.") + send_message_payload: dict[str, Any] = { 'message': { 'role': 'user', diff --git a/src/a2a/client/client.py b/src/a2a/client/client.py index 24584c65..1899f0b2 100644 --- a/src/a2a/client/client.py +++ b/src/a2a/client/client.py @@ -1,31 +1,25 @@ import json - +import logging from collections.abc import AsyncGenerator from typing import Any from uuid import uuid4 import httpx - from httpx_sse import SSEError, aconnect_sse +from pydantic import ValidationError from a2a.client.errors import A2AClientHTTPError, A2AClientJSONError -from a2a.types import ( - AgentCard, - CancelTaskRequest, - CancelTaskResponse, - GetTaskPushNotificationConfigRequest, - GetTaskPushNotificationConfigResponse, - GetTaskRequest, - GetTaskResponse, - SendMessageRequest, - SendMessageResponse, - SendStreamingMessageRequest, - SendStreamingMessageResponse, - SetTaskPushNotificationConfigRequest, - SetTaskPushNotificationConfigResponse, -) +from a2a.types import (AgentCard, CancelTaskRequest, CancelTaskResponse, + GetTaskPushNotificationConfigRequest, + GetTaskPushNotificationConfigResponse, GetTaskRequest, + GetTaskResponse, SendMessageRequest, + SendMessageResponse, SendStreamingMessageRequest, + SendStreamingMessageResponse, + SetTaskPushNotificationConfigRequest, + SetTaskPushNotificationConfigResponse) from a2a.utils.telemetry import SpanKind, trace_class +logger = logging.getLogger(__name__) class A2ACardResolver: """Agent Card resolver.""" @@ -48,11 +42,19 @@ def __init__( self.httpx_client = httpx_client async def get_agent_card( - self, http_kwargs: dict[str, Any] | None = None + self, + relative_card_path: str | None = None, + http_kwargs: dict[str, Any] | None = None, ) -> AgentCard: - """Fetches the agent card from the specified URL. + """Fetches an agent card from a specified path relative to the base_url. + + If relative_card_path is None, it defaults to the resolver's configured + agent_card_path (for the public agent card). Args: + relative_card_path: Optional path to the agent card endpoint, + relative to the base URL. If None, uses the default public + agent card path. http_kwargs: Optional dictionary of keyword arguments to pass to the underlying httpx.get request. @@ -64,21 +66,47 @@ async def get_agent_card( A2AClientJSONError: If the response body cannot be decoded as JSON or validated against the AgentCard schema. """ + if relative_card_path is None: + # Use the default public agent card path configured during initialization + path_segment = self.agent_card_path + else: + path_segment = relative_card_path.lstrip('/') + + target_url = f'{self.base_url}/{path_segment}' + try: response = await self.httpx_client.get( - f'{self.base_url}/{self.agent_card_path}', + target_url, **(http_kwargs or {}), ) response.raise_for_status() - return AgentCard.model_validate(response.json()) + agent_card_data = response.json() + logger.info( + 'Successfully fetched agent card data from %s: %s', + target_url, + agent_card_data, + ) + agent_card = AgentCard.model_validate(agent_card_data) except httpx.HTTPStatusError as e: - raise A2AClientHTTPError(e.response.status_code, str(e)) from e + raise A2AClientHTTPError( + e.response.status_code, + f'Failed to fetch agent card from {target_url}: {e}', + ) from e except json.JSONDecodeError as e: - raise A2AClientJSONError(str(e)) from e + raise A2AClientJSONError( + f'Failed to parse JSON for agent card from {target_url}: {e}' + ) from e except httpx.RequestError as e: raise A2AClientHTTPError( - 503, f'Network communication error: {e}' + 503, + f'Network communication error fetching agent card from {target_url}: {e}', ) from e + except ValidationError as e: # Pydantic validation error + raise A2AClientJSONError( + f'Failed to validate agent card structure from {target_url}: {e.json()}' + ) from e + + return agent_card @trace_class(kind=SpanKind.CLIENT) @@ -119,7 +147,12 @@ async def get_client_from_agent_card_url( agent_card_path: str = '/.well-known/agent.json', http_kwargs: dict[str, Any] | None = None, ) -> 'A2AClient': - """Fetches the AgentCard and initializes an A2A client. + """Fetches the public AgentCard and initializes an A2A client. + + This method will always fetch the public agent card. If an authenticated + or extended agent card is required, the A2ACardResolver should be used + directly to fetch the specific card, and then the A2AClient should be + instantiated with it. Args: httpx_client: An async HTTP client instance (e.g., httpx.AsyncClient). @@ -127,7 +160,6 @@ async def get_client_from_agent_card_url( agent_card_path: The path to the agent card endpoint, relative to the base URL. http_kwargs: Optional dictionary of keyword arguments to pass to the underlying httpx.get request when fetching the agent card. - Returns: An initialized `A2AClient` instance. @@ -137,7 +169,7 @@ async def get_client_from_agent_card_url( """ agent_card: AgentCard = await A2ACardResolver( httpx_client, base_url=base_url, agent_card_path=agent_card_path - ).get_agent_card(http_kwargs=http_kwargs) + ).get_agent_card(http_kwargs=http_kwargs) # Fetches public card by default return A2AClient(httpx_client=httpx_client, agent_card=agent_card) async def send_message( diff --git a/src/a2a/server/apps/starlette_app.py b/src/a2a/server/apps/starlette_app.py index 7f4d6e8b..b9e5c14d 100644 --- a/src/a2a/server/apps/starlette_app.py +++ b/src/a2a/server/apps/starlette_app.py @@ -1,7 +1,6 @@ import json import logging import traceback - from abc import ABC, abstractmethod from collections.abc import AsyncGenerator from typing import Any @@ -16,29 +15,16 @@ from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler from a2a.server.request_handlers.request_handler import RequestHandler -from a2a.types import ( - A2AError, - A2ARequest, - AgentCard, - CancelTaskRequest, - GetTaskPushNotificationConfigRequest, - GetTaskRequest, - InternalError, - InvalidRequestError, - JSONParseError, - JSONRPCError, - JSONRPCErrorResponse, - JSONRPCResponse, - SendMessageRequest, - SendStreamingMessageRequest, - SendStreamingMessageResponse, - SetTaskPushNotificationConfigRequest, - TaskResubscriptionRequest, - UnsupportedOperationError, -) +from a2a.types import (A2AError, A2ARequest, AgentCard, CancelTaskRequest, + GetTaskPushNotificationConfigRequest, GetTaskRequest, + InternalError, InvalidRequestError, JSONParseError, + JSONRPCError, JSONRPCErrorResponse, JSONRPCResponse, + SendMessageRequest, SendStreamingMessageRequest, + SendStreamingMessageResponse, + SetTaskPushNotificationConfigRequest, + TaskResubscriptionRequest, UnsupportedOperationError) from a2a.utils.errors import MethodNotImplementedError - logger = logging.getLogger(__name__) @@ -62,6 +48,7 @@ def __init__( self, agent_card: AgentCard, http_handler: RequestHandler, + extended_agent_card: AgentCard | None = None, context_builder: CallContextBuilder | None = None, ): """Initializes the A2AStarletteApplication. @@ -70,14 +57,24 @@ def __init__( agent_card: The AgentCard describing the agent's capabilities. http_handler: The handler instance responsible for processing A2A requests via http. + extended_agent_card: An optional, distinct AgentCard to be served + at the authenticated extended card endpoint. context_builder: The CallContextBuilder used to construct the ServerCallContext passed to the http_handler. If None, no ServerCallContext is passed. """ self.agent_card = agent_card + self.extended_agent_card = extended_agent_card self.handler = JSONRPCHandler( agent_card=agent_card, request_handler=http_handler ) + if ( + self.agent_card.supportsAuthenticatedExtendedCard + and self.extended_agent_card is None + ): + logger.error( + 'AgentCard.supportsAuthenticatedExtendedCard is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.' + ) self._context_builder = context_builder def _generate_error_response( @@ -320,13 +317,41 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse: Returns: A JSONResponse containing the agent card data. """ + # The public agent card is a direct serialization of the agent_card + # provided at initialization. return JSONResponse( self.agent_card.model_dump(mode='json', exclude_none=True) ) + async def _handle_get_authenticated_extended_agent_card( + self, request: Request + ) -> JSONResponse: + """Handles GET requests for the authenticated extended agent card.""" + if not self.agent_card.supportsAuthenticatedExtendedCard: + return JSONResponse( + {'error': 'Extended agent card not supported or not enabled.'}, + status_code=404, + ) + + # If an explicit extended_agent_card is provided, serve that. + if self.extended_agent_card: + return JSONResponse( + self.extended_agent_card.model_dump( + mode='json', exclude_none=True + ) + ) + # If supportsAuthenticatedExtendedCard is true, but no specific + # extended_agent_card was provided during server initialization, + # return a 404 + return JSONResponse( + {'error': 'Authenticated extended agent card is supported but not configured on the server.'}, + status_code=404, + ) + def routes( self, agent_card_url: str = '/.well-known/agent.json', + extended_agent_card_url: str = '/agent/authenticatedExtendedCard', rpc_url: str = '/', ) -> list[Route]: """Returns the Starlette Routes for handling A2A requests. @@ -334,11 +359,12 @@ def routes( Args: agent_card_url: The URL path for the agent card endpoint. rpc_url: The URL path for the A2A JSON-RPC endpoint (POST requests). + extended_agent_card_url: The URL for the authenticated extended agent card endpoint. Returns: A list of Starlette Route objects. """ - return [ + app_routes = [ Route( rpc_url, self._handle_requests, @@ -353,9 +379,21 @@ def routes( ), ] + if self.agent_card.supportsAuthenticatedExtendedCard: + app_routes.append( + Route( + extended_agent_card_url, + self._handle_get_authenticated_extended_agent_card, + methods=['GET'], + name='authenticated_extended_agent_card', + ) + ) + return app_routes + def build( self, agent_card_url: str = '/.well-known/agent.json', + extended_agent_card_url: str = '/agent/authenticatedExtendedCard', rpc_url: str = '/', **kwargs: Any, ) -> Starlette: @@ -364,16 +402,19 @@ def build( Args: agent_card_url: The URL path for the agent card endpoint. rpc_url: The URL path for the A2A JSON-RPC endpoint (POST requests). + extended_agent_card_url: The URL for the authenticated extended agent card endpoint. **kwargs: Additional keyword arguments to pass to the Starlette constructor. Returns: A configured Starlette application instance. """ - routes = self.routes(agent_card_url, rpc_url) + app_routes = self.routes( + agent_card_url, extended_agent_card_url, rpc_url + ) if 'routes' in kwargs: - kwargs['routes'] += routes + kwargs['routes'].extend(app_routes) else: - kwargs['routes'] = routes + kwargs['routes'] = app_routes return Starlette(**kwargs) diff --git a/src/a2a/types.py b/src/a2a/types.py index b976d3b2..f0c12f3a 100644 --- a/src/a2a/types.py +++ b/src/a2a/types.py @@ -1416,6 +1416,10 @@ class AgentCard(BaseModel): """ The version of the agent - format is up to the provider. """ + supportsAuthenticatedExtendedCard: bool | None = Field(default=None) + """ + Optional field indicating there is an extended card available post authentication at the /agent/authenticatedExtendedCard endpoint. + """ class Task(BaseModel): diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..bab99450 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,11 @@ +## Running the tests + +1. Run the tests + ```bash + uv run pytest -v -s client/test_client.py + ``` + +In case of failures, you can cleanup the cache: + +1. `uv clean` +2. `rm -fR .pytest_cache .venv __pycache__` diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 7c7926ce..e7cf5fe7 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,45 +1,24 @@ import json - from collections.abc import AsyncGenerator from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest - from httpx_sse import EventSource, ServerSentEvent - -from a2a.client import ( - A2ACardResolver, - A2AClient, - A2AClientHTTPError, - A2AClientJSONError, - create_text_message_object, -) -from a2a.types import ( - A2ARequest, - AgentCapabilities, - AgentCard, - AgentSkill, - CancelTaskRequest, - CancelTaskResponse, - CancelTaskSuccessResponse, - GetTaskRequest, - GetTaskResponse, - InvalidParamsError, - JSONRPCErrorResponse, - MessageSendParams, - Role, - SendMessageRequest, - SendMessageResponse, - SendMessageSuccessResponse, - SendStreamingMessageRequest, - SendStreamingMessageResponse, - TaskIdParams, - TaskNotCancelableError, - TaskQueryParams, -) - +from pydantic import ValidationError as PydanticValidationError + +from a2a.client import (A2ACardResolver, A2AClient, A2AClientHTTPError, + A2AClientJSONError, create_text_message_object) +from a2a.types import (A2ARequest, AgentCapabilities, AgentCard, AgentSkill, + CancelTaskRequest, CancelTaskResponse, + CancelTaskSuccessResponse, GetTaskRequest, + GetTaskResponse, InvalidParamsError, + JSONRPCErrorResponse, MessageSendParams, Role, + SendMessageRequest, SendMessageResponse, + SendMessageSuccessResponse, SendStreamingMessageRequest, + SendStreamingMessageResponse, TaskIdParams, + TaskNotCancelableError, TaskQueryParams) AGENT_CARD = AgentCard( name='Hello World Agent', @@ -60,6 +39,30 @@ ], ) +AGENT_CARD_EXTENDED = AGENT_CARD.model_copy( + update={ + 'name': 'Hello World Agent - Extended Edition', + 'skills': AGENT_CARD.skills + + [ + AgentSkill( + id='extended_skill', + name='Super Greet', + description='A more enthusiastic greeting.', + tags=['extended'], + examples=['super hi'], + ) + ], + 'version': '1.0.1', + } +) + +AGENT_CARD_SUPPORTS_EXTENDED = AGENT_CARD.model_copy( + update={'supportsAuthenticatedExtendedCard': True} +) +AGENT_CARD_NO_URL_SUPPORTS_EXTENDED = AGENT_CARD_SUPPORTS_EXTENDED.model_copy( + update={'url': ''} +) + MINIMAL_TASK: dict[str, Any] = { 'id': 'task-abc', 'contextId': 'session-xyz', @@ -97,6 +100,7 @@ class TestA2ACardResolver: BASE_URL = 'http://example.com' AGENT_CARD_PATH = '/.well-known/agent.json' FULL_AGENT_CARD_URL = f'{BASE_URL}{AGENT_CARD_PATH}' + EXTENDED_AGENT_CARD_PATH = '/agent/authenticatedExtendedCard' # Default path @pytest.mark.asyncio async def test_init_strips_slashes(self, mock_httpx_client: AsyncMock): @@ -110,22 +114,13 @@ async def test_init_strips_slashes(self, mock_httpx_client: AsyncMock): resolver.agent_card_path == '.well-known/agent.json/' ) # Path is only lstrip'd - resolver_no_leading_slash_path = A2ACardResolver( - httpx_client=AsyncMock(), - base_url='http://example.com', - agent_card_path='.well-known/agent.json', - ) - assert resolver_no_leading_slash_path.base_url == 'http://example.com' - assert ( - resolver_no_leading_slash_path.agent_card_path - == '.well-known/agent.json' - ) - @pytest.mark.asyncio - async def test_get_agent_card_success(self, mock_httpx_client: AsyncMock): + async def test_get_agent_card_success_public_only( + self, mock_httpx_client: AsyncMock + ): mock_response = AsyncMock(spec=httpx.Response) mock_response.status_code = 200 - mock_response.json.return_value = AGENT_CARD.model_dump() + mock_response.json.return_value = AGENT_CARD.model_dump(mode='json') mock_httpx_client.get.return_value = mock_response resolver = A2ACardResolver( @@ -141,6 +136,61 @@ async def test_get_agent_card_success(self, mock_httpx_client: AsyncMock): mock_response.raise_for_status.assert_called_once() assert isinstance(agent_card, AgentCard) assert agent_card == AGENT_CARD + # Ensure only one call was made (for the public card) + assert mock_httpx_client.get.call_count == 1 + + @pytest.mark.asyncio + async def test_get_agent_card_success_with_specified_path_for_extended_card( + self, mock_httpx_client: AsyncMock): + extended_card_response = AsyncMock(spec=httpx.Response) + extended_card_response.status_code = 200 + extended_card_response.json.return_value = AGENT_CARD_EXTENDED.model_dump( + mode='json' + ) + + # Mock the single call for the extended card + mock_httpx_client.get.return_value = extended_card_response + + resolver = A2ACardResolver( + httpx_client=mock_httpx_client, + base_url=self.BASE_URL, + agent_card_path=self.AGENT_CARD_PATH, + ) + + # Fetch the extended card by providing its relative path and example auth + auth_kwargs = {"headers": {"Authorization": "Bearer test token"}} + agent_card_result = await resolver.get_agent_card( + relative_card_path=self.EXTENDED_AGENT_CARD_PATH, + http_kwargs=auth_kwargs + ) + + expected_extended_url = f'{self.BASE_URL}/{self.EXTENDED_AGENT_CARD_PATH.lstrip("/")}' + mock_httpx_client.get.assert_called_once_with(expected_extended_url, **auth_kwargs) + extended_card_response.raise_for_status.assert_called_once() + + assert isinstance(agent_card_result, AgentCard) + assert agent_card_result == AGENT_CARD_EXTENDED # Should return the extended card + + @pytest.mark.asyncio + async def test_get_agent_card_validation_error( + self, mock_httpx_client: AsyncMock + ): + mock_response = AsyncMock(spec=httpx.Response) + mock_response.status_code = 200 + # Data that will cause a Pydantic ValidationError + mock_response.json.return_value = {"invalid_field": "value", "name": "Test Agent"} + mock_httpx_client.get.return_value = mock_response + + resolver = A2ACardResolver( + httpx_client=mock_httpx_client, base_url=self.BASE_URL + ) + # The call that is expected to raise an error should be within pytest.raises + with pytest.raises(A2AClientJSONError) as exc_info: + await resolver.get_agent_card() # Fetches from default path + + assert f'Failed to validate agent card structure from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert 'invalid_field' in str(exc_info.value) # Check if Pydantic error details are present + assert mock_httpx_client.get.call_count == 1 # Should only be called once @pytest.mark.asyncio async def test_get_agent_card_http_status_error( @@ -167,7 +217,8 @@ async def test_get_agent_card_http_status_error( await resolver.get_agent_card() assert exc_info.value.status_code == 404 - assert 'HTTP Error 404: Not Found' in str(exc_info.value) + assert f'Failed to fetch agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert 'Not Found' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) @pytest.mark.asyncio @@ -176,6 +227,7 @@ async def test_get_agent_card_json_decode_error( ): mock_response = AsyncMock(spec=httpx.Response) mock_response.status_code = 200 + # Define json_error before using it json_error = json.JSONDecodeError('Expecting value', 'doc', 0) mock_response.json.side_effect = json_error mock_httpx_client.get.return_value = mock_response @@ -189,7 +241,9 @@ async def test_get_agent_card_json_decode_error( with pytest.raises(A2AClientJSONError) as exc_info: await resolver.get_agent_card() - assert 'JSON Error: Expecting value' in str(exc_info.value) + # Assertions using exc_info must be after the with block + assert f'Failed to parse JSON for agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert 'Expecting value' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) @pytest.mark.asyncio @@ -209,9 +263,8 @@ async def test_get_agent_card_request_error( await resolver.get_agent_card() assert exc_info.value.status_code == 503 - assert 'Network communication error: Network issue' in str( - exc_info.value - ) + assert f'Network communication error fetching agent card from {self.FULL_AGENT_CARD_URL}' in str(exc_info.value) + assert 'Network issue' in str(exc_info.value) mock_httpx_client.get.assert_called_once_with(self.FULL_AGENT_CARD_URL) @@ -279,7 +332,8 @@ async def test_get_client_from_agent_card_url_success( agent_card_path=agent_card_path, ) mock_resolver_instance.get_agent_card.assert_called_once_with( - http_kwargs=resolver_kwargs + http_kwargs=resolver_kwargs, + # relative_card_path=None is implied by not passing it ) assert isinstance(client, A2AClient) assert client.url == mock_agent_card.url diff --git a/tests/server/test_integration.py b/tests/server/test_integration.py index b116b2cc..c0a54e94 100644 --- a/tests/server/test_integration.py +++ b/tests/server/test_integration.py @@ -1,36 +1,21 @@ import asyncio - from typing import Any from unittest import mock import pytest - from starlette.responses import JSONResponse from starlette.routing import Route from starlette.testclient import TestClient from a2a.server.apps.starlette_app import A2AStarletteApplication -from a2a.types import ( - AgentCapabilities, - AgentCard, - Artifact, - DataPart, - InternalError, - InvalidRequestError, - JSONParseError, - Part, - PushNotificationConfig, - Task, - TaskArtifactUpdateEvent, - TaskPushNotificationConfig, - TaskState, - TaskStatus, - TextPart, - UnsupportedOperationError, -) +from a2a.types import (AgentCapabilities, AgentCard, Artifact, DataPart, + InternalError, InvalidRequestError, JSONParseError, + Part, PushNotificationConfig, Task, + TaskArtifactUpdateEvent, TaskPushNotificationConfig, + TaskState, TaskStatus, TextPart, + UnsupportedOperationError) from a2a.utils.errors import MethodNotImplementedError - # === TEST SETUP === MINIMAL_AGENT_SKILL: dict[str, Any] = { @@ -58,6 +43,20 @@ 'version': '1.0', } +EXTENDED_AGENT_CARD_DATA: dict[str, Any] = { + **MINIMAL_AGENT_CARD, + 'name': 'TestAgent Extended', + 'description': 'Test Agent with more details', + 'skills': [ + MINIMAL_AGENT_SKILL, + { + 'id': 'skill-extended', + 'name': 'Extended Skill', + 'description': 'Does more things', + 'tags': ['extended'], + }, + ], +} TEXT_PART_DATA: dict[str, Any] = {'kind': 'text', 'text': 'Hello'} DATA_PART_DATA: dict[str, Any] = {'kind': 'data', 'data': {'key': 'value'}} @@ -83,6 +82,11 @@ def agent_card(): return AgentCard(**MINIMAL_AGENT_CARD) +@pytest.fixture +def extended_agent_card_fixture(): + return AgentCard(**EXTENDED_AGENT_CARD_DATA) + + @pytest.fixture def handler(): handler = mock.AsyncMock() @@ -120,6 +124,45 @@ def test_agent_card_endpoint(client: TestClient, agent_card: AgentCard): assert 'streaming' in data['capabilities'] +def test_authenticated_extended_agent_card_endpoint_not_supported( + agent_card: AgentCard, handler: mock.AsyncMock +): + """Test extended card endpoint returns 404 if not supported by main card.""" + # Ensure supportsAuthenticatedExtendedCard is False or None + agent_card.supportsAuthenticatedExtendedCard = False + app_instance = A2AStarletteApplication(agent_card, handler) + # The route should not even be added if supportsAuthenticatedExtendedCard is false + # So, building the app and trying to hit it should result in 404 from Starlette itself + client = TestClient(app_instance.build()) + response = client.get('/agent/authenticatedExtendedCard') + assert response.status_code == 404 # Starlette's default for no route + + +def test_authenticated_extended_agent_card_endpoint_supported_with_specific_extended_card( + agent_card: AgentCard, + extended_agent_card_fixture: AgentCard, + handler: mock.AsyncMock, +): + """Test extended card endpoint returns the specific extended card when provided.""" + agent_card.supportsAuthenticatedExtendedCard = True # Main card must support it + app_instance = A2AStarletteApplication( + agent_card, handler, extended_agent_card=extended_agent_card_fixture + ) + client = TestClient(app_instance.build()) + + response = client.get('/agent/authenticatedExtendedCard') + assert response.status_code == 200 + data = response.json() + # Verify it's the extended card's data + assert data['name'] == extended_agent_card_fixture.name + assert data['version'] == extended_agent_card_fixture.version + assert len(data['skills']) == len(extended_agent_card_fixture.skills) + assert any( + skill['id'] == 'skill-extended' for skill in data['skills'] + ), "Extended skill not found in served card" + + + def test_agent_card_custom_url( app: A2AStarletteApplication, agent_card: AgentCard ):