A2A Protocol

This commit is contained in:
2026-10-21 08:23:55 -03:00
parent 1942f78df8
commit e3bd48cf4b
32 changed files with 2141 additions and 12 deletions

View File

@@ -0,0 +1,258 @@
"""Real SDK/FastAPI tests: no mock task manager or custom JSON-RPC dispatcher."""
import asyncio
import json
import threading
import time
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
pytest.importorskip("a2a", reason="Install agent-framework[a2a] to run A2A tests")
from agent_framework.a2a.server import install_a2a, install_backend_a2a
from agent_framework.a2a.progress import report_progress
from agent_framework.global_supervisor.models import BackendDefinition
HEADERS = {'A2A-Version': '1.0'}
def send(client, method, params, legacy=False):
return client.post('/a2a', headers={'A2A-Version': '0.3' if legacy else '1.0'}, json={
'jsonrpc': '2.0', 'id': 'request', 'method': method, 'params': params,
})
def message_params(legacy=False, immediate=True, task_id=None):
message = {'messageId': 'm-' + str(time.monotonic_ns()), 'contextId': 'session',
'role': 'user' if legacy else 'ROLE_USER', 'parts': [{'text': 'Olá'}]}
if task_id:
message['taskId'] = task_id
if legacy:
message['kind'] = 'message'
message['parts'][0]['kind'] = 'text'
return {'message': message,
'configuration': {'blocking': not immediate} if legacy else {'returnImmediately': immediate}}
def task_result(response, legacy=False):
data = response.json()
assert 'error' not in data, data
return data['result'] if legacy else data['result']['task']
def get_task(client, task_id, legacy=False):
data = send(client, 'tasks/get' if legacy else 'GetTask', {'id': task_id}, legacy).json()
assert 'error' not in data, data
return data['result']
def wait_state(client, task_id, expected, legacy=False):
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
task = get_task(client, task_id, legacy)
if task['status']['state'] == expected:
return task
time.sleep(.005)
pytest.fail(f'Task did not reach {expected}: {task}')
def application(handler, **kwargs):
app = FastAPI()
registry = SimpleNamespace(list=lambda: [BackendDefinition(backend_id='native', url='http://native')])
install_a2a(app, handler, registry, 'http://testserver', **kwargs)
return app
@pytest.mark.parametrize('legacy', [False, True])
def test_submit_returns_before_processing_and_poll_after_disconnect(legacy):
release = threading.Event()
async def handler(request):
while not release.is_set():
await asyncio.sleep(.005)
return {'text': 'done', 'session_id': request['session_id']}
with TestClient(application(handler)) as client:
try:
first = task_result(send(client, 'message/send' if legacy else 'SendMessage', message_params(legacy), legacy), legacy)
assert not release.is_set()
assert first['status']['state'] in ({'submitted', 'working'} if legacy else {'TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'})
assert get_task(client, first['id'], legacy)['contextId'] == 'session'
release.set()
task = wait_state(client, first['id'], 'completed' if legacy else 'TASK_STATE_COMPLETED', legacy)
assert task['artifacts'][0]['parts'][0]['text'] == 'done'
finally:
release.set()
@pytest.mark.parametrize('legacy', [False, True])
def test_stream_contains_task_progress_artifact_and_terminal_state(legacy):
async def handler(request):
await report_progress('workflow.started', {'route': 'orders'})
await asyncio.sleep(.01)
await report_progress('workflow.completed', {'route': 'orders'})
return {'text': 'done'}
with TestClient(application(handler)) as client:
assert client.get('/.well-known/agent-card.json').json()['capabilities']['streaming']
response = send(client, 'message/stream' if legacy else 'SendStreamingMessage', message_params(legacy, immediate=False), legacy)
assert 'text/event-stream' in response.headers['content-type'], response.text
events = [json.loads(line[5:].strip()) for line in response.text.splitlines() if line.startswith('data:')]
assert events and all('error' not in e for e in events), events
text = json.dumps(events)
assert 'workflow.started' in text and 'workflow.completed' in text
assert 'done' in text
assert ('completed' if legacy else 'TASK_STATE_COMPLETED') in text
first = events[0]['result']
assert ('id' in first if legacy else 'task' in first), events
def test_cancel_stops_local_execution():
started, stopped = threading.Event(), threading.Event()
async def handler(request):
started.set()
try:
await asyncio.sleep(30)
finally:
stopped.set()
with TestClient(application(handler)) as client:
first = task_result(send(client, 'SendMessage', message_params()))
assert started.wait(1)
result = send(client, 'CancelTask', {'id': first['id']}).json()
assert 'error' not in result, result
assert result['result']['status']['state'] == 'TASK_STATE_CANCELED'
assert stopped.wait(1)
assert get_task(client, first['id'])['status']['state'] == 'TASK_STATE_CANCELED'
@pytest.mark.parametrize('failure', ['exception', 'timeout'])
def test_failure_is_persisted(failure):
async def handler(request):
if failure == 'exception':
raise RuntimeError('internal-sensitive-detail')
await asyncio.sleep(1)
with TestClient(application(handler, timeout_seconds=.02)) as client:
first = task_result(send(client, 'SendMessage', message_params()))
task = wait_state(client, first['id'], 'TASK_STATE_FAILED')
assert 'internal-sensitive-detail' not in json.dumps(task)
assert task['status']['message']['parts'][0]['text'] == ('Processing timed out' if failure == 'timeout' else 'Agent processing failed')
def test_input_required_resumes_same_task():
turns = []
async def handler(request):
turns.append(request)
return {'text': 'Confirmar?' if len(turns) == 1 else 'Concluído',
'metadata': {'a2a_task_state': 'input-required'} if len(turns) == 1 else {}}
with TestClient(application(handler)) as client:
first = task_result(send(client, 'SendMessage', message_params(immediate=False)))
assert first['status']['state'] == 'TASK_STATE_INPUT_REQUIRED'
second = task_result(send(client, 'SendMessage', message_params(immediate=False, task_id=first['id'])))
assert second['id'] == first['id']
assert second['status']['state'] == 'TASK_STATE_COMPLETED'
assert len(turns) == 2
# A completed task cannot execute a third business operation.
response = send(client, 'SendMessage', message_params(task_id=first['id'])).json()
assert 'error' in response
assert len(turns) == 2
def test_backend_installer_exposes_agent_skills():
seen = []
async def handler(request):
seen.append(request)
return {'text': 'native agent', 'session_id': request['session_id']}
profiles = SimpleNamespace(list_profiles=lambda: [SimpleNamespace(agent_id='orders', name='Orders', description='Order status')])
settings = SimpleNamespace(A2A_PUBLIC_URL='https://backend.example', A2A_SECURITY_SCHEMES={}, A2A_SECURITY=[],
A2A_TASK_MODE=True, A2A_TASK_TIMEOUT_SECONDS=10, APP_NAME='Framework backend')
app = FastAPI()
install_backend_a2a(app, handler, profiles, settings)
with TestClient(app) as client:
card = client.get('/.well-known/agent-card.json').json()
assert card['skills'][0]['id'] == 'orders'
assert card['supportedInterfaces'][0]['url'] == 'https://backend.example/a2a'
params = message_params(immediate=False)
params['message']['metadata'] = {'agent_id': 'orders', 'tenant_id': 't'}
final = task_result(send(client, 'SendMessage', params))
assert final['status']['state'] == 'TASK_STATE_COMPLETED'
assert seen[0]['agent_id'] == 'orders' and seen[0]['tenant_id'] == 't'
def test_tasks_are_scoped_to_authenticated_principal():
from agent_framework.security.middleware import AuthenticationMiddleware
from agent_framework.security.authentication import AuthenticationResult, AuthenticatedPrincipal
class TestProvider:
async def authenticate(self, request):
# Test-only identity provider, equivalent to verified middleware output.
return AuthenticationResult(True, AuthenticatedPrincipal(request.headers['x-test-principal'], 'test'))
async def handler(request):
return {'text': 'private result'}
app = application(handler)
app.add_middleware(AuthenticationMiddleware, provider=TestProvider())
with TestClient(app) as client:
client.headers['x-test-principal'] = 'alice'
first = task_result(send(client, 'SendMessage', message_params(immediate=False)))
assert get_task(client, first['id'])['artifacts'][0]['parts'][0]['text'] == 'private result'
client.headers['x-test-principal'] = 'bob'
for method in ['GetTask', 'CancelTask']:
result = send(client, method, {'id': first['id']}).json()
assert 'error' in result
assert 'private result' not in json.dumps(result)
listing = send(client, 'ListTasks', {}).json()
assert not listing['result'].get('tasks'), listing
@pytest.mark.parametrize('legacy', [False, True])
def test_gateway_to_framework_a2a_backend(monkeypatch, legacy):
import httpx
from agent_framework.global_supervisor.client import BackendClient
from agent_framework.global_supervisor.models import GlobalRouteDecision
backend_release = threading.Event()
async def backend_handler(request):
while not backend_release.is_set():
await asyncio.sleep(.005)
return {'text': 'backend complete', 'session_id': request['session_id']}
backend_app = application(backend_handler)
real_client = httpx.AsyncClient
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kw: real_client(transport=httpx.ASGITransport(app=backend_app), **kw))
backend_client = BackendClient(timeout_seconds=2)
definition = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a',
a2a_version='0.3' if legacy else '1.0', a2a_poll_interval_seconds=.005)
async def gateway_handler(request):
result = await backend_client.call_message(definition, request, GlobalRouteDecision(backend_id='x'))
return result.response
with TestClient(application(gateway_handler)) as client:
try:
first = task_result(send(client, 'SendMessage', message_params()))
assert first['status']['state'] in {'TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'}
assert not backend_release.is_set()
backend_release.set()
final = wait_state(client, first['id'], 'TASK_STATE_COMPLETED')
assert final['artifacts'][0]['parts'][0]['text'] == 'backend complete'
finally:
backend_release.set()
def test_template_progress_helper_preserves_native_event_contract():
# Exercise the actual lightweight helper without booting OCI/LLM dependencies.
import ast
from pathlib import Path
source = Path(__file__).resolve().parents[1] / 'templates/agent_template_backend/app/main.py'
module = ast.parse(source.read_text())
helper = next(n for n in module.body if isinstance(n, ast.AsyncFunctionDef) and n.name == '_emit_gateway_event')
native, a2a = [], []
async def native_emit(*args):
native.append(args)
async def progress(*args):
a2a.append(args)
namespace = {'sse_hub': SimpleNamespace(emit=native_emit)}
exec(compile(ast.Module(body=[helper], type_ignores=[]), str(source), 'exec'), namespace)
emit = namespace['_emit_gateway_event']
async def scenario():
await emit('session', 'flow.start', {'x': 1}, True, None)
await emit('session', 'workflow.started', {'x': 2}, False, progress)
await emit('session', 'message.responded', {'x': 3}, False, None)
asyncio.run(scenario())
assert native == [('session', 'flow.start', {'x': 1})]
assert a2a == [('workflow.started', {'x': 2})]