Skip to content

Commit 6f0f71d

Browse files
committed
ci: run mandatory and capabilities TCK tests for JSON-RPC transport
1 parent fdbf22f commit 6f0f71d

2 files changed

Lines changed: 301 additions & 0 deletions

File tree

.github/workflows/run-tck.yaml

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
name: Run TCK
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
paths-ignore:
9+
- '**.md'
10+
- 'LICENSE'
11+
- '.github/CODEOWNERS'
12+
13+
env:
14+
TCK_VERSION: 0.3.0.beta3
15+
SUT_BASE_URL: http://localhost:41241
16+
SUT_JSONRPC_URL: http://localhost:41241/a2a/jsonrpc
17+
UV_SYSTEM_PYTHON: 1
18+
TCK_STREAMING_TIMEOUT: 5.0
19+
20+
concurrency:
21+
group: '${{ github.workflow }} @ ${{ github.head_ref || github.ref }}'
22+
cancel-in-progress: true
23+
24+
jobs:
25+
tck-test:
26+
runs-on: ubuntu-latest
27+
strategy:
28+
matrix:
29+
python-version: ["3.10", "3.11", "3.12"]
30+
steps:
31+
- name: Checkout a2a-python
32+
uses: actions/checkout@v4
33+
34+
- name: Install uv
35+
uses: astral-sh/setup-uv@v5
36+
with:
37+
enable-cache: true
38+
cache-dependency-glob: "uv.lock"
39+
40+
- name: Set up Python ${{ matrix.python-version }}
41+
run: uv python install ${{ matrix.python-version }}
42+
43+
- name: Install Dependencies
44+
run: uv sync --all-extras --locked
45+
46+
- name: Checkout a2a-tck
47+
uses: actions/checkout@v4
48+
with:
49+
repository: a2aproject/a2a-tck
50+
path: tck/a2a-tck
51+
ref: ${{ env.TCK_VERSION }}
52+
53+
- name: Install TCK dependencies
54+
run: |
55+
cd tck/a2a-tck
56+
pip install uv
57+
uv pip install -e .
58+
59+
- name: Start SUT
60+
run: |
61+
uv run tck/sut_agent.py &
62+
env:
63+
HTTP_PORT: 41241
64+
65+
- name: Wait for SUT to start
66+
run: |
67+
URL="${{ env.SUT_BASE_URL }}/.well-known/agent-card.json"
68+
EXPECTED_STATUS=200
69+
TIMEOUT=120
70+
RETRY_INTERVAL=2
71+
START_TIME=$(date +%s)
72+
73+
while true; do
74+
CURRENT_TIME=$(date +%s)
75+
ELAPSED_TIME=$((CURRENT_TIME - START_TIME))
76+
77+
if [ "$ELAPSED_TIME" -ge "$TIMEOUT" ]; then
78+
echo "❌ Timeout: Server did not respond with status $EXPECTED_STATUS within $TIMEOUT seconds."
79+
exit 1
80+
fi
81+
82+
HTTP_STATUS=$(curl --output /dev/null --silent --write-out "%{http_code}" "$URL") || true
83+
echo "STATUS: ${HTTP_STATUS}"
84+
85+
if [ "$HTTP_STATUS" -eq "$EXPECTED_STATUS" ]; then
86+
echo "✅ Server is up! Received status $HTTP_STATUS after $ELAPSED_TIME seconds."
87+
break;
88+
fi
89+
90+
echo "⏳ Server not ready (status: $HTTP_STATUS). Retrying in $RETRY_INTERVAL seconds..."
91+
sleep "$RETRY_INTERVAL"
92+
done
93+
94+
- name: Run TCK (mandatory)
95+
id: run-tck-mandatory
96+
timeout-minutes: 5
97+
run: |
98+
./run_tck.py --sut-url ${{ env.SUT_JSONRPC_URL }} --category mandatory --transports jsonrpc
99+
working-directory: tck/a2a-tck
100+
101+
- name: Run TCK (capabilities)
102+
id: run-tck-capabilities
103+
timeout-minutes: 5
104+
run: |
105+
./run_tck.py --sut-url ${{ env.SUT_JSONRPC_URL }} --category capabilities --transports jsonrpc
106+
working-directory: tck/a2a-tck
107+
108+
- name: Stop SUT
109+
if: always()
110+
run: |
111+
pkill -f sut_agent.py || true
112+
sleep 2

tck/sut_agent.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
2+
import asyncio
3+
import logging
4+
import os
5+
import uuid
6+
from datetime import datetime, timezone
7+
8+
from datetime import datetime, timezone
9+
10+
from fastapi import FastAPI
11+
from uvicorn import Config, Server
12+
13+
14+
from a2a.server.agent_execution.agent_executor import AgentExecutor
15+
from a2a.server.agent_execution.context import RequestContext
16+
from a2a.server.events.event_queue import EventQueue
17+
from a2a.server.events.in_memory_queue_manager import InMemoryQueueManager
18+
from a2a.server.request_handlers.default_request_handler import DefaultRequestHandler
19+
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
20+
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
21+
from a2a.types import (
22+
AgentCard,
23+
AgentCapabilities,
24+
AgentProvider,
25+
Message,
26+
TextPart,
27+
Task,
28+
TaskState,
29+
TaskStatus,
30+
TaskStatusUpdateEvent,
31+
)
32+
from a2a.auth.user import UnauthenticatedUser
33+
from a2a.server.tasks.inmemory_task_store import InMemoryTaskStore
34+
35+
# Configure logging
36+
logging.basicConfig(level=logging.INFO)
37+
logger = logging.getLogger("SUTAgent")
38+
39+
class SUTAgentExecutor(AgentExecutor):
40+
def __init__(self):
41+
self.running_tasks = set()
42+
self.last_context_id = None
43+
44+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
45+
api_task_id = context.task_id
46+
if api_task_id in self.running_tasks:
47+
self.running_tasks.remove(api_task_id)
48+
49+
status_update = TaskStatusUpdateEvent(
50+
task_id=api_task_id,
51+
context_id=self.last_context_id or str(uuid.uuid4()),
52+
status=TaskStatus(
53+
state=TaskState.canceled,
54+
timestamp=datetime.now(timezone.utc).isoformat(),
55+
),
56+
final=True,
57+
)
58+
await event_queue.enqueue_event(status_update)
59+
60+
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
61+
user_message = context.message
62+
task_id = context.task_id
63+
context_id = context.context_id
64+
self.last_context_id = context_id
65+
66+
self.running_tasks.add(task_id)
67+
68+
logger.info(
69+
f"[SUTAgentExecutor] Processing message {user_message.message_id} "
70+
f"for task {task_id} (context: {context_id})"
71+
)
72+
73+
working_status = TaskStatusUpdateEvent(
74+
task_id=task_id,
75+
context_id=context_id,
76+
status=TaskStatus(
77+
state=TaskState.working,
78+
message=Message(
79+
role="agent",
80+
message_id=str(uuid.uuid4()),
81+
parts=[TextPart(text="Processing your question")],
82+
task_id=task_id,
83+
context_id=context_id,
84+
),
85+
timestamp=datetime.now(timezone.utc).isoformat(),
86+
),
87+
final=False,
88+
)
89+
await event_queue.enqueue_event(working_status)
90+
91+
agent_reply_text = "Hello world!"
92+
await asyncio.sleep(3) # Simulate processing delay
93+
94+
if task_id not in self.running_tasks:
95+
logger.info(f"Task {task_id} was cancelled.")
96+
return
97+
98+
logger.info(f"[SUTAgentExecutor] Response: {agent_reply_text}")
99+
100+
agent_message = Message(
101+
role="agent",
102+
message_id=str(uuid.uuid4()),
103+
parts=[TextPart(text=agent_reply_text)],
104+
task_id=task_id,
105+
context_id=context_id,
106+
)
107+
108+
final_update = TaskStatusUpdateEvent(
109+
task_id=task_id,
110+
context_id=context_id,
111+
status=TaskStatus(
112+
state=TaskState.input_required,
113+
message=agent_message,
114+
timestamp=datetime.now(timezone.utc).isoformat(),
115+
),
116+
final=True,
117+
)
118+
await event_queue.enqueue_event(final_update)
119+
120+
121+
122+
async def main():
123+
HTTP_PORT = int(os.environ.get("HTTP_PORT", 41241))
124+
125+
# 1. Setup Executor and Handlers
126+
agent_executor = SUTAgentExecutor()
127+
task_store = InMemoryTaskStore()
128+
queue_manager = InMemoryQueueManager()
129+
130+
request_handler = DefaultRequestHandler(
131+
task_store=task_store,
132+
queue_manager=queue_manager,
133+
agent_executor=agent_executor,
134+
)
135+
136+
# 2. Create Agent Card (JSON-RPC only)
137+
sut_agent_card = AgentCard(
138+
name="SUT Agent",
139+
description="A sample agent to be used as SUT against tck tests.",
140+
url=f"http://localhost:{HTTP_PORT}/a2a/jsonrpc",
141+
provider=AgentProvider(
142+
organization="A2A Samples",
143+
url="https://example.com/a2a-samples",
144+
),
145+
version="1.0.0",
146+
protocol_version="0.3.0",
147+
capabilities=AgentCapabilities(
148+
streaming=True,
149+
push_notifications=False,
150+
state_transition_history=True,
151+
),
152+
default_input_modes=["text"],
153+
default_output_modes=["text", "task-status"],
154+
skills=[
155+
{
156+
"id": "sut_agent",
157+
"name": "SUT Agent",
158+
"description": "Simulate the general flow of a streaming agent.",
159+
"tags": ["sut"],
160+
"examples": ["hi", "hello world", "how are you", "goodbye"],
161+
"input_modes": ["text"],
162+
"output_modes": ["text", "task-status"],
163+
}
164+
],
165+
supports_authenticated_extended_card=False,
166+
preferred_transport="JSONRPC",
167+
additional_interfaces=[
168+
{"url": f"http://localhost:{HTTP_PORT}/a2a/jsonrpc", "transport": "JSONRPC"},
169+
],
170+
)
171+
172+
# 3. Setup HTTP App
173+
json_rpc_app = A2AFastAPIApplication(
174+
agent_card=sut_agent_card,
175+
http_handler=request_handler,
176+
)
177+
app = json_rpc_app.build(
178+
rpc_url="/a2a/jsonrpc",
179+
agent_card_url="/.well-known/agent-card.json"
180+
)
181+
182+
logger.info(f"Starting HTTP server on port {HTTP_PORT}...")
183+
config = Config(app, host="0.0.0.0", port=HTTP_PORT, log_level="info")
184+
server = Server(config)
185+
186+
await server.serve()
187+
188+
if __name__ == "__main__":
189+
asyncio.run(main())

0 commit comments

Comments
 (0)