Skip to content

Commit a57602d

Browse files
authored
Add A2A conformance tests (#6)
* Add a2a tests * Run tests in CI * Update readme
1 parent 150ac07 commit a57602d

File tree

6 files changed

+363
-33
lines changed

6 files changed

+363
-33
lines changed
Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
name: Publish Agent
1+
name: Test and Publish Agent
22

3-
# Trigger this workflow when pushing main branch and tags
43
on:
4+
pull_request:
55
push:
66
branches:
77
- main
88
tags:
99
- 'v*' # Trigger on version tags like v1.0.0, v1.1.0
1010

1111
jobs:
12-
publish:
12+
test-and-publish:
1313
runs-on: ubuntu-latest
1414

1515
# These permissions are required for the workflow to:
@@ -23,52 +23,63 @@ jobs:
2323
- name: Checkout repository
2424
uses: actions/checkout@v4
2525

26-
- name: Log in to GitHub Container Registry
27-
uses: docker/login-action@v3
28-
with:
29-
registry: ghcr.io
30-
username: ${{ github.actor }}
31-
# GITHUB_TOKEN is automatically provided by GitHub Actions
32-
# No manual secret configuration needed!
33-
# It has permissions based on the 'permissions' block above
34-
password: ${{ secrets.GITHUB_TOKEN }}
35-
3626
- name: Extract metadata for Docker
3727
id: meta
3828
uses: docker/metadata-action@v5
3929
with:
4030
images: ghcr.io/${{ github.repository }}
4131
tags: |
42-
# For tags like v1.0, create tag '1.0'
32+
type=ref,event=pr
4333
type=semver,pattern={{version}}
44-
# For tags like v1.0, create tag '1'
4534
type=semver,pattern={{major}}
46-
# For main branch, create tag 'latest'
4735
type=raw,value=latest,enable={{is_default_branch}}
48-
# For PRs, create tag 'pr-123'
49-
type=ref,event=pr
5036
51-
- name: Build and push Docker image
52-
id: build
37+
- name: Build Docker image
5338
uses: docker/build-push-action@v5
5439
with:
5540
context: .
56-
file: Dockerfile
57-
# Only push if this is a push event (not a PR)
58-
# PRs will build but not push to avoid polluting the registry
59-
push: ${{ github.event_name != 'pull_request' }}
41+
push: false
6042
tags: ${{ steps.meta.outputs.tags }}
6143
labels: ${{ steps.meta.outputs.labels }}
62-
# Explicitly build for linux/amd64 (GitHub Actions default)
44+
load: true
6345
platforms: linux/amd64
6446

47+
- name: Start agent container
48+
run: |
49+
docker run -d -p 9009:9009 --name agent-container $(echo "${{ steps.meta.outputs.tags }}" | head -n1) --host 0.0.0.0 --port 9009
50+
timeout 30 bash -c 'until curl -sf http://localhost:9009/.well-known/agent-card.json > /dev/null; do sleep 1; done'
51+
52+
- name: Set up uv
53+
uses: astral-sh/setup-uv@v4
54+
55+
- name: Install test dependencies
56+
run: uv sync --extra test
57+
58+
- name: Run tests
59+
run: uv run pytest -v --agent-url http://localhost:9009
60+
61+
- name: Stop container and show logs
62+
if: always()
63+
run: |
64+
echo "=== Agent Container Logs ==="
65+
docker logs agent-container || true
66+
docker stop agent-container || true
67+
68+
- name: Log in to GitHub Container Registry
69+
if: success() && github.event_name != 'pull_request'
70+
uses: docker/login-action@v3
71+
with:
72+
registry: ghcr.io
73+
username: ${{ github.actor }}
74+
password: ${{ secrets.GITHUB_TOKEN }}
75+
76+
- name: Push Docker image
77+
if: success() && github.event_name != 'pull_request'
78+
run: docker push --all-tags ghcr.io/${{ github.repository }}
79+
6580
- name: Output image digest
66-
if: github.event_name != 'pull_request'
81+
if: success() && github.event_name != 'pull_request'
6782
run: |
6883
echo "## Docker Image Published :rocket:" >> $GITHUB_STEP_SUMMARY
6984
echo "" >> $GITHUB_STEP_SUMMARY
7085
echo "**Tags:** ${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
71-
echo "" >> $GITHUB_STEP_SUMMARY
72-
echo "**Digest:** \`${{ steps.build.outputs.digest }}\`" >> $GITHUB_STEP_SUMMARY
73-
echo "" >> $GITHUB_STEP_SUMMARY
74-
echo "Use this digest in your MANIFEST.json for reproducibility." >> $GITHUB_STEP_SUMMARY

README.md

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ uv.lock # Locked dependencies
1919

2020
1. **Create your repository** - Click "Use this template" to create your own repository from this template
2121

22-
2. **Implement your agent** - Add your agent logic to the `run` method in [`src/agent.py`](src/agent.py)
22+
2. **Implement your agent** - Add your agent logic to [`src/agent.py`](src/agent.py)
2323

2424
3. **Configure your agent card** - Fill in your agent's metadata (name, skills, description) in [`src/server.py`](src/server.py)
2525

@@ -43,9 +43,23 @@ docker build -t my-agent .
4343
docker run -p 9009:9009 my-agent
4444
```
4545

46+
## Testing
47+
48+
Run A2A conformance tests against your agent.
49+
50+
```bash
51+
# Install test dependencies
52+
uv sync --extra test
53+
54+
# Start your agent (uv or docker; see above)
55+
56+
# Run tests against your running agent URL
57+
uv run pytest --agent-url http://localhost:9009
58+
```
59+
4660
## Publishing
4761

48-
The repository includes a GitHub Actions workflow that automatically builds and publishes a Docker image of your agent to GitHub Container Registry:
62+
The repository includes a GitHub Actions workflow that automatically builds, tests, and publishes a Docker image of your agent to GitHub Container Registry:
4963

5064
- **Push to `main`** → publishes `latest` tag:
5165
```
@@ -60,4 +74,4 @@ ghcr.io/<your-username>/<your-repo-name>:1
6074

6175
Once the workflow completes, find your Docker image in the Packages section (right sidebar of your repository). Configure the package visibility in package settings.
6276

63-
> **Note:** Organization repositories may need package write permissions enabled manually (Settings → Actions → General). Version tags must follow [semantic versioning](https://semver.org/) (e.g., `v1.0.0`).
77+
> **Note:** Organization repositories may need package write permissions enabled manually (Settings → Actions → General). Version tags must follow [semantic versioning](https://semver.org/) (e.g., `v1.0.0`).

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,10 @@ dependencies = [
88
"a2a-sdk[http-server]>=0.3.20",
99
"uvicorn>=0.38.0",
1010
]
11+
12+
[project.optional-dependencies]
13+
test = [
14+
"pytest>=8.0.0",
15+
"pytest-asyncio>=0.24.0",
16+
"httpx>=0.28.1",
17+
]

tests/conftest.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import httpx
2+
import pytest
3+
4+
5+
def pytest_addoption(parser):
6+
parser.addoption(
7+
"--agent-url",
8+
default="http://localhost:9009",
9+
help="Agent URL (default: http://localhost:9009)",
10+
)
11+
12+
13+
@pytest.fixture(scope="session")
14+
def agent(request):
15+
"""Agent URL fixture. Agent must be running before tests start."""
16+
url = request.config.getoption("--agent-url")
17+
18+
try:
19+
response = httpx.get(f"{url}/.well-known/agent-card.json", timeout=2)
20+
if response.status_code != 200:
21+
pytest.exit(f"Agent at {url} returned status {response.status_code}", returncode=1)
22+
except Exception as e:
23+
pytest.exit(f"Could not connect to agent at {url}: {e}", returncode=1)
24+
25+
return url

tests/test_agent.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
from typing import Any
2+
import pytest
3+
import httpx
4+
from uuid import uuid4
5+
6+
from a2a.client import A2ACardResolver, ClientConfig, ClientFactory
7+
from a2a.types import Message, Part, Role, TextPart
8+
9+
10+
# A2A validation helpers - adapted from https://github.com/a2aproject/a2a-inspector/blob/main/backend/validators.py
11+
12+
def validate_agent_card(card_data: dict[str, Any]) -> list[str]:
13+
"""Validate the structure and fields of an agent card."""
14+
errors: list[str] = []
15+
16+
# Use a frozenset for efficient checking and to indicate immutability.
17+
required_fields = frozenset(
18+
[
19+
'name',
20+
'description',
21+
'url',
22+
'version',
23+
'capabilities',
24+
'defaultInputModes',
25+
'defaultOutputModes',
26+
'skills',
27+
]
28+
)
29+
30+
# Check for the presence of all required fields
31+
for field in required_fields:
32+
if field not in card_data:
33+
errors.append(f"Required field is missing: '{field}'.")
34+
35+
# Check if 'url' is an absolute URL (basic check)
36+
if 'url' in card_data and not (
37+
card_data['url'].startswith('http://')
38+
or card_data['url'].startswith('https://')
39+
):
40+
errors.append(
41+
"Field 'url' must be an absolute URL starting with http:// or https://."
42+
)
43+
44+
# Check if capabilities is a dictionary
45+
if 'capabilities' in card_data and not isinstance(
46+
card_data['capabilities'], dict
47+
):
48+
errors.append("Field 'capabilities' must be an object.")
49+
50+
# Check if defaultInputModes and defaultOutputModes are arrays of strings
51+
for field in ['defaultInputModes', 'defaultOutputModes']:
52+
if field in card_data:
53+
if not isinstance(card_data[field], list):
54+
errors.append(f"Field '{field}' must be an array of strings.")
55+
elif not all(isinstance(item, str) for item in card_data[field]):
56+
errors.append(f"All items in '{field}' must be strings.")
57+
58+
# Check skills array
59+
if 'skills' in card_data:
60+
if not isinstance(card_data['skills'], list):
61+
errors.append(
62+
"Field 'skills' must be an array of AgentSkill objects."
63+
)
64+
elif not card_data['skills']:
65+
errors.append(
66+
"Field 'skills' array is empty. Agent must have at least one skill if it performs actions."
67+
)
68+
69+
return errors
70+
71+
72+
def _validate_task(data: dict[str, Any]) -> list[str]:
73+
errors = []
74+
if 'id' not in data:
75+
errors.append("Task object missing required field: 'id'.")
76+
if 'status' not in data or 'state' not in data.get('status', {}):
77+
errors.append("Task object missing required field: 'status.state'.")
78+
return errors
79+
80+
81+
def _validate_status_update(data: dict[str, Any]) -> list[str]:
82+
errors = []
83+
if 'status' not in data or 'state' not in data.get('status', {}):
84+
errors.append(
85+
"StatusUpdate object missing required field: 'status.state'."
86+
)
87+
return errors
88+
89+
90+
def _validate_artifact_update(data: dict[str, Any]) -> list[str]:
91+
errors = []
92+
if 'artifact' not in data:
93+
errors.append(
94+
"ArtifactUpdate object missing required field: 'artifact'."
95+
)
96+
elif (
97+
'parts' not in data.get('artifact', {})
98+
or not isinstance(data.get('artifact', {}).get('parts'), list)
99+
or not data.get('artifact', {}).get('parts')
100+
):
101+
errors.append("Artifact object must have a non-empty 'parts' array.")
102+
return errors
103+
104+
105+
def _validate_message(data: dict[str, Any]) -> list[str]:
106+
errors = []
107+
if (
108+
'parts' not in data
109+
or not isinstance(data.get('parts'), list)
110+
or not data.get('parts')
111+
):
112+
errors.append("Message object must have a non-empty 'parts' array.")
113+
if 'role' not in data or data.get('role') != 'agent':
114+
errors.append("Message from agent must have 'role' set to 'agent'.")
115+
return errors
116+
117+
118+
def validate_event(data: dict[str, Any]) -> list[str]:
119+
"""Validate an incoming event from the agent based on its kind."""
120+
if 'kind' not in data:
121+
return ["Response from agent is missing required 'kind' field."]
122+
123+
kind = data.get('kind')
124+
validators = {
125+
'task': _validate_task,
126+
'status-update': _validate_status_update,
127+
'artifact-update': _validate_artifact_update,
128+
'message': _validate_message,
129+
}
130+
131+
validator = validators.get(str(kind))
132+
if validator:
133+
return validator(data)
134+
135+
return [f"Unknown message kind received: '{kind}'."]
136+
137+
138+
# A2A messaging helpers
139+
140+
async def send_text_message(text: str, url: str, context_id: str | None = None, streaming: bool = False):
141+
async with httpx.AsyncClient(timeout=10) as httpx_client:
142+
resolver = A2ACardResolver(httpx_client=httpx_client, base_url=url)
143+
agent_card = await resolver.get_agent_card()
144+
config = ClientConfig(httpx_client=httpx_client, streaming=streaming)
145+
factory = ClientFactory(config)
146+
client = factory.create(agent_card)
147+
148+
msg = Message(
149+
kind="message",
150+
role=Role.user,
151+
parts=[Part(TextPart(text=text))],
152+
message_id=uuid4().hex,
153+
context_id=context_id,
154+
)
155+
156+
events = [event async for event in client.send_message(msg)]
157+
158+
return events
159+
160+
161+
# A2A conformance tests
162+
163+
def test_agent_card(agent):
164+
"""Validate agent card structure and required fields."""
165+
response = httpx.get(f"{agent}/.well-known/agent-card.json")
166+
assert response.status_code == 200, "Agent card endpoint must return 200"
167+
168+
card_data = response.json()
169+
errors = validate_agent_card(card_data)
170+
171+
assert not errors, f"Agent card validation failed:\n" + "\n".join(errors)
172+
173+
@pytest.mark.asyncio
174+
@pytest.mark.parametrize("streaming", [True, False])
175+
async def test_message(agent, streaming):
176+
"""Test that agent returns valid A2A message format."""
177+
events = await send_text_message("Hello", agent, streaming=streaming)
178+
179+
all_errors = []
180+
for event in events:
181+
match event:
182+
case Message() as msg:
183+
errors = validate_event(msg.model_dump())
184+
all_errors.extend(errors)
185+
186+
case (task, update):
187+
errors = validate_event(task.model_dump())
188+
all_errors.extend(errors)
189+
if update:
190+
errors = validate_event(update.model_dump())
191+
all_errors.extend(errors)
192+
193+
case _:
194+
pytest.fail(f"Unexpected event type: {type(event)}")
195+
196+
assert events, "Agent should respond with at least one event"
197+
assert not all_errors, f"Message validation failed:\n" + "\n".join(all_errors)
198+
199+
# Add your custom tests here

0 commit comments

Comments
 (0)