-
Notifications
You must be signed in to change notification settings - Fork 7
Add A2A conformance tests #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import httpx | ||
| import pytest | ||
|
|
||
|
|
||
| def pytest_addoption(parser): | ||
| parser.addoption( | ||
| "--agent-url", | ||
| default="http://localhost:9009", | ||
| help="Agent URL (default: http://localhost:9009)", | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(scope="session") | ||
| def agent(request): | ||
| """Agent URL fixture. Agent must be running before tests start.""" | ||
| url = request.config.getoption("--agent-url") | ||
|
|
||
| try: | ||
| response = httpx.get(f"{url}/.well-known/agent-card.json", timeout=2) | ||
| if response.status_code != 200: | ||
| pytest.exit(f"Agent at {url} returned status {response.status_code}", returncode=1) | ||
| except Exception as e: | ||
| pytest.exit(f"Could not connect to agent at {url}: {e}", returncode=1) | ||
|
|
||
| return url |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| from typing import Any | ||
| import pytest | ||
| import httpx | ||
| from uuid import uuid4 | ||
|
|
||
| from a2a.client import A2ACardResolver, ClientConfig, ClientFactory | ||
| from a2a.types import Message, Part, Role, TextPart | ||
|
|
||
|
|
||
| # A2A validation helpers - adapted from https://github.com/a2aproject/a2a-inspector/blob/main/backend/validators.py | ||
|
|
||
| def validate_agent_card(card_data: dict[str, Any]) -> list[str]: | ||
| """Validate the structure and fields of an agent card.""" | ||
| errors: list[str] = [] | ||
|
|
||
| # Use a frozenset for efficient checking and to indicate immutability. | ||
| required_fields = frozenset( | ||
| [ | ||
| 'name', | ||
| 'description', | ||
| 'url', | ||
| 'version', | ||
| 'capabilities', | ||
| 'defaultInputModes', | ||
| 'defaultOutputModes', | ||
| 'skills', | ||
| ] | ||
| ) | ||
|
|
||
| # Check for the presence of all required fields | ||
| for field in required_fields: | ||
| if field not in card_data: | ||
| errors.append(f"Required field is missing: '{field}'.") | ||
|
|
||
| # Check if 'url' is an absolute URL (basic check) | ||
| if 'url' in card_data and not ( | ||
| card_data['url'].startswith('http://') | ||
| or card_data['url'].startswith('https://') | ||
| ): | ||
| errors.append( | ||
| "Field 'url' must be an absolute URL starting with http:// or https://." | ||
| ) | ||
|
|
||
| # Check if capabilities is a dictionary | ||
| if 'capabilities' in card_data and not isinstance( | ||
| card_data['capabilities'], dict | ||
| ): | ||
| errors.append("Field 'capabilities' must be an object.") | ||
|
|
||
| # Check if defaultInputModes and defaultOutputModes are arrays of strings | ||
| for field in ['defaultInputModes', 'defaultOutputModes']: | ||
| if field in card_data: | ||
| if not isinstance(card_data[field], list): | ||
| errors.append(f"Field '{field}' must be an array of strings.") | ||
| elif not all(isinstance(item, str) for item in card_data[field]): | ||
| errors.append(f"All items in '{field}' must be strings.") | ||
|
|
||
| # Check skills array | ||
| if 'skills' in card_data: | ||
| if not isinstance(card_data['skills'], list): | ||
| errors.append( | ||
| "Field 'skills' must be an array of AgentSkill objects." | ||
| ) | ||
| elif not card_data['skills']: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validation code was taken from a2a-inspector which was developed by google so I assumed it's correct. The A2A SDK doesn't enforce non-empty skills list, but the type annotation is
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably makes sense to stick with the check then--just remember to verify debater example when you rebase 😄 |
||
| errors.append( | ||
| "Field 'skills' array is empty. Agent must have at least one skill if it performs actions." | ||
| ) | ||
|
|
||
| return errors | ||
|
|
||
|
|
||
| def _validate_task(data: dict[str, Any]) -> list[str]: | ||
| errors = [] | ||
| if 'id' not in data: | ||
| errors.append("Task object missing required field: 'id'.") | ||
| if 'status' not in data or 'state' not in data.get('status', {}): | ||
| errors.append("Task object missing required field: 'status.state'.") | ||
| return errors | ||
|
|
||
|
|
||
| def _validate_status_update(data: dict[str, Any]) -> list[str]: | ||
| errors = [] | ||
| if 'status' not in data or 'state' not in data.get('status', {}): | ||
| errors.append( | ||
| "StatusUpdate object missing required field: 'status.state'." | ||
| ) | ||
| return errors | ||
|
|
||
|
|
||
| def _validate_artifact_update(data: dict[str, Any]) -> list[str]: | ||
| errors = [] | ||
| if 'artifact' not in data: | ||
| errors.append( | ||
| "ArtifactUpdate object missing required field: 'artifact'." | ||
| ) | ||
| elif ( | ||
| 'parts' not in data.get('artifact', {}) | ||
| or not isinstance(data.get('artifact', {}).get('parts'), list) | ||
| or not data.get('artifact', {}).get('parts') | ||
| ): | ||
| errors.append("Artifact object must have a non-empty 'parts' array.") | ||
| return errors | ||
|
|
||
|
|
||
| def _validate_message(data: dict[str, Any]) -> list[str]: | ||
| errors = [] | ||
| if ( | ||
| 'parts' not in data | ||
| or not isinstance(data.get('parts'), list) | ||
| or not data.get('parts') | ||
| ): | ||
| errors.append("Message object must have a non-empty 'parts' array.") | ||
| if 'role' not in data or data.get('role') != 'agent': | ||
| errors.append("Message from agent must have 'role' set to 'agent'.") | ||
| return errors | ||
|
|
||
|
|
||
| def validate_event(data: dict[str, Any]) -> list[str]: | ||
| """Validate an incoming event from the agent based on its kind.""" | ||
| if 'kind' not in data: | ||
| return ["Response from agent is missing required 'kind' field."] | ||
|
|
||
| kind = data.get('kind') | ||
| validators = { | ||
| 'task': _validate_task, | ||
| 'status-update': _validate_status_update, | ||
| 'artifact-update': _validate_artifact_update, | ||
| 'message': _validate_message, | ||
| } | ||
|
|
||
| validator = validators.get(str(kind)) | ||
| if validator: | ||
| return validator(data) | ||
|
|
||
| return [f"Unknown message kind received: '{kind}'."] | ||
|
|
||
|
|
||
| # A2A messaging helpers | ||
|
|
||
| async def send_text_message(text: str, url: str, context_id: str | None = None, streaming: bool = False): | ||
| async with httpx.AsyncClient(timeout=10) as httpx_client: | ||
| resolver = A2ACardResolver(httpx_client=httpx_client, base_url=url) | ||
| agent_card = await resolver.get_agent_card() | ||
| config = ClientConfig(httpx_client=httpx_client, streaming=streaming) | ||
| factory = ClientFactory(config) | ||
| client = factory.create(agent_card) | ||
|
|
||
| msg = Message( | ||
| kind="message", | ||
| role=Role.user, | ||
| parts=[Part(TextPart(text=text))], | ||
| message_id=uuid4().hex, | ||
| context_id=context_id, | ||
| ) | ||
|
|
||
| events = [event async for event in client.send_message(msg)] | ||
|
|
||
| return events | ||
|
|
||
|
|
||
| # A2A conformance tests | ||
|
|
||
| def test_agent_card(agent): | ||
| """Validate agent card structure and required fields.""" | ||
| response = httpx.get(f"{agent}/.well-known/agent-card.json") | ||
| assert response.status_code == 200, "Agent card endpoint must return 200" | ||
|
|
||
| card_data = response.json() | ||
| errors = validate_agent_card(card_data) | ||
|
|
||
| assert not errors, f"Agent card validation failed:\n" + "\n".join(errors) | ||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize("streaming", [True, False]) | ||
| async def test_message(agent, streaming): | ||
| """Test that agent returns valid A2A message format.""" | ||
| events = await send_text_message("Hello", agent, streaming=streaming) | ||
|
|
||
| all_errors = [] | ||
| for event in events: | ||
| match event: | ||
| case Message() as msg: | ||
| errors = validate_event(msg.model_dump()) | ||
| all_errors.extend(errors) | ||
|
|
||
| case (task, update): | ||
| errors = validate_event(task.model_dump()) | ||
| all_errors.extend(errors) | ||
| if update: | ||
| errors = validate_event(update.model_dump()) | ||
| all_errors.extend(errors) | ||
|
|
||
| case _: | ||
| pytest.fail(f"Unexpected event type: {type(event)}") | ||
|
|
||
| assert events, "Agent should respond with at least one event" | ||
| assert not all_errors, f"Message validation failed:\n" + "\n".join(all_errors) | ||
|
|
||
| # Add your custom tests here | ||
Uh oh!
There was an error while loading. Please reload this page.