mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-11-06 03:14:12 +00:00
A2A Protocol
This commit is contained in:
258
tests/test_a2a_async_tasks.py
Normal file
258
tests/test_a2a_async_tasks.py
Normal 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})]
|
||||
81
tests/test_a2a_http_streaming.py
Normal file
81
tests/test_a2a_http_streaming.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Verify SSE flush and disconnect/reconnect over a real local HTTP connection."""
|
||||
import asyncio
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
pytest.importorskip("a2a", reason="Install agent-framework[a2a] to run A2A tests")
|
||||
|
||||
from agent_framework.a2a.server import install_a2a
|
||||
from agent_framework.global_supervisor.models import BackendDefinition
|
||||
|
||||
|
||||
@pytest.mark.parametrize('legacy', [False, True])
|
||||
def test_sse_flush_disconnect_and_resubscribe(legacy):
|
||||
release = threading.Event()
|
||||
finished = threading.Event()
|
||||
async def handler(request):
|
||||
while not release.is_set():
|
||||
await asyncio.sleep(.005)
|
||||
finished.set()
|
||||
return {'text': 'stream complete'}
|
||||
app = FastAPI()
|
||||
registry = SimpleNamespace(list=lambda: [BackendDefinition(backend_id='x', url='http://native')])
|
||||
install_a2a(app, handler, registry, 'http://testserver')
|
||||
sock = socket.socket()
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
server = uvicorn.Server(uvicorn.Config(app, log_level='error', lifespan='off'))
|
||||
thread = threading.Thread(target=lambda: server.run(sockets=[sock]), daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
deadline = time.monotonic() + 3
|
||||
while not server.started and time.monotonic() < deadline:
|
||||
time.sleep(.005)
|
||||
assert server.started
|
||||
base_url = f'http://127.0.0.1:{sock.getsockname()[1]}'
|
||||
with httpx.Client(base_url=base_url, headers={'A2A-Version': '0.3' if legacy else '1.0'}, timeout=2, trust_env=False) as client:
|
||||
message = {'messageId': 'm', 'role': 'user' if legacy else 'ROLE_USER',
|
||||
'parts': [{'kind': 'text', 'text': 'run'}] if legacy else [{'text': 'run'}]}
|
||||
if legacy: message['kind'] = 'message'
|
||||
def envelope(method, params):
|
||||
return {'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params}
|
||||
with client.stream('POST', '/a2a', json=envelope('message/stream' if legacy else 'SendStreamingMessage', {'message': message})) as response:
|
||||
assert response.status_code == 200
|
||||
for line in response.iter_lines():
|
||||
if line.startswith('data:'):
|
||||
event = json.loads(line[5:].strip())
|
||||
first = event['result'] if legacy else event['result']['task']
|
||||
task_id = first['id']
|
||||
break
|
||||
else:
|
||||
pytest.fail('SSE did not deliver an initial Task')
|
||||
assert not release.is_set() and not finished.is_set()
|
||||
# Closing the first SSE connection must not cancel the agent task.
|
||||
current = client.post('/a2a', json=envelope('tasks/get' if legacy else 'GetTask', {'id': task_id})).json()
|
||||
assert current['result']['status']['state'] in ({'submitted', 'working'} if legacy else {'TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING'})
|
||||
with client.stream('POST', '/a2a', json=envelope('tasks/resubscribe' if legacy else 'SubscribeToTask', {'id': task_id})) as response:
|
||||
events = []
|
||||
for line in response.iter_lines():
|
||||
if not line.startswith('data:'):
|
||||
continue
|
||||
event = json.loads(line[5:].strip())
|
||||
assert 'error' not in event, event
|
||||
events.append(event)
|
||||
if len(events) == 1:
|
||||
release.set()
|
||||
encoded = json.dumps(events)
|
||||
assert 'stream complete' in encoded
|
||||
assert ('completed' if legacy else 'TASK_STATE_COMPLETED') in encoded
|
||||
assert finished.is_set()
|
||||
finally:
|
||||
release.set()
|
||||
server.should_exit = True
|
||||
thread.join(timeout=3)
|
||||
sock.close()
|
||||
assert not thread.is_alive()
|
||||
188
tests/test_a2a_interoperability.py
Normal file
188
tests/test_a2a_interoperability.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
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
|
||||
from agent_framework.global_supervisor.models import BackendDefinition, GlobalRouteDecision
|
||||
from agent_framework.global_supervisor.client import BackendClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def facade():
|
||||
seen = []
|
||||
async def handler(request):
|
||||
seen.append(request)
|
||||
return {'channel': 'web', 'session_id': request['session_id'], 'text': 'Olá', 'metadata': {}}
|
||||
app = FastAPI()
|
||||
registry = SimpleNamespace(list=lambda: [BackendDefinition(backend_id='native', url='http://native')])
|
||||
install_a2a(app, handler, registry, 'https://agents.example', task_mode=False)
|
||||
with TestClient(app) as client:
|
||||
yield client, seen
|
||||
|
||||
|
||||
@pytest.mark.parametrize('legacy', [False, True])
|
||||
def test_discovery_and_send(facade, legacy):
|
||||
client, seen = facade
|
||||
card = client.get('/.well-known/agent-card.json').json()
|
||||
assert card['supportedInterfaces'][0]['protocolVersion'] == '1.0'
|
||||
message = {'messageId': 'm', 'role': 'ROLE_USER', 'contextId': 'conversation',
|
||||
'parts': [{'text': 'Oi'}, {'data': {'invoice_id': 42, 'session_id': 'override'}}]}
|
||||
if legacy:
|
||||
message.update(kind='message', role='user')
|
||||
for part in message['parts']: part['kind'] = 'text' if 'text' in part else 'data'
|
||||
response = client.post('/a2a', headers={'A2A-Version': '0.3' if legacy else '1.0'}, json={
|
||||
'jsonrpc': '2.0', 'id': 'x', 'method': 'message/send' if legacy else 'SendMessage',
|
||||
'params': {'message': message},
|
||||
}).json()
|
||||
reply = response['result'] if legacy else response['result']['message']
|
||||
assert reply['parts'][0]['text'] == 'Olá', response
|
||||
assert reply['contextId'] == 'conversation'
|
||||
assert seen[0]['payload']['invoice_id'] == 42
|
||||
assert seen[0]['payload']['session_id'] == 'conversation'
|
||||
|
||||
|
||||
def test_errors(facade):
|
||||
client, _ = facade
|
||||
for method, params in [('Unknown', {}), ('GetTask', {'id': 'missing'})]:
|
||||
response = client.post('/a2a', headers={'A2A-Version': '1.0'}, json={
|
||||
'jsonrpc': '2.0', 'id': 1, 'method': method, 'params': params,
|
||||
}).json()
|
||||
assert 'error' in response
|
||||
|
||||
|
||||
def mock_http(monkeypatch, handler):
|
||||
real_client = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, 'AsyncClient', lambda **kw: real_client(transport=httpx.MockTransport(handler), **kw))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('protocol', ['native', '1.0', '0.3'])
|
||||
def test_backend_adapter(monkeypatch, protocol):
|
||||
seen = []
|
||||
def transport(request):
|
||||
body = json.loads(request.content)
|
||||
seen.append((request, body))
|
||||
if protocol == 'native': return httpx.Response(200, json={'text': 'OK', 'session_id': 's'})
|
||||
reply = {'messageId': 'r', 'role': 'ROLE_AGENT', 'contextId': 'remote', 'parts': [{'text': 'OK'}]}
|
||||
if protocol == '0.3':
|
||||
reply.update(kind='message', role='agent'); reply['parts'][0]['kind'] = 'text'
|
||||
return httpx.Response(200, json={'jsonrpc': '2.0', 'id': body['id'], 'result': reply if protocol == '0.3' else {'message': reply}})
|
||||
mock_http(monkeypatch, transport)
|
||||
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='native' if protocol == 'native' else 'a2a', a2a_version='0.3' if protocol == '0.3' else '1.0')
|
||||
response = asyncio.run(BackendClient().call_message(backend, {'payload': {'session_id': 's', 'message': 'Oi'}}, GlobalRouteDecision(backend_id='x')))
|
||||
assert response.response['text'] == 'OK'
|
||||
assert response.response['session_id'] == 's'
|
||||
if protocol == 'native':
|
||||
assert str(seen[0][0].url) == 'http://backend/gateway/message'
|
||||
assert seen[0][1]['payload']['selected_backend'] == 'x'
|
||||
else:
|
||||
assert seen[0][1]['method'] == ('message/send' if protocol == '0.3' else 'SendMessage')
|
||||
|
||||
|
||||
def test_task_polling(monkeypatch):
|
||||
methods = []
|
||||
def transport(request):
|
||||
body = json.loads(request.content); methods.append(body['method'])
|
||||
task = {'id': 'task', 'contextId': 'remote', 'status': {'state': 'TASK_STATE_WORKING' if len(methods) == 1 else 'TASK_STATE_COMPLETED'}}
|
||||
if len(methods) == 2: task['artifacts'] = [{'artifactId': 'a', 'parts': [{'text': 'done'}]}]
|
||||
return httpx.Response(200, json={'jsonrpc': '2.0', 'id': body['id'], 'result': {'task': task} if len(methods) == 1 else task})
|
||||
mock_http(monkeypatch, transport)
|
||||
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a', a2a_poll_interval_seconds=.001)
|
||||
response = asyncio.run(BackendClient().call_message(backend, {'payload': {'message': 'Oi'}}, GlobalRouteDecision(backend_id='x')))
|
||||
assert response.response['text'] == 'done'
|
||||
assert methods == ['SendMessage', 'GetTask']
|
||||
|
||||
|
||||
def test_rpc_failure(monkeypatch):
|
||||
def transport(request):
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(200, json={'jsonrpc': '2.0', 'id': body['id'], 'error': {'code': -32603, 'message': 'private'}})
|
||||
mock_http(monkeypatch, transport)
|
||||
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a')
|
||||
with pytest.raises(RuntimeError, match='-32603'):
|
||||
asyncio.run(BackendClient().call_message(backend, {'payload': {}}, GlobalRouteDecision(backend_id='x')))
|
||||
|
||||
|
||||
def test_continuation_and_tenant_isolation(monkeypatch):
|
||||
seen = []
|
||||
def transport(request):
|
||||
body = json.loads(request.content); seen.append(body['params']['message'])
|
||||
return httpx.Response(200, json={'jsonrpc': '2.0', 'id': body['id'], 'result': {'task': {
|
||||
'id': 'remote-task', 'contextId': 'remote-context', 'status': {'state': 'TASK_STATE_INPUT_REQUIRED',
|
||||
'message': {'messageId': 'a', 'role': 'ROLE_AGENT', 'parts': [{'text': 'Confirmar?'}]}}
|
||||
}}})
|
||||
mock_http(monkeypatch, transport)
|
||||
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a')
|
||||
async def scenario():
|
||||
client = BackendClient()
|
||||
for tenant in ['first', 'first', 'second']:
|
||||
await client.call_message(backend, {'tenant_id': tenant, 'payload': {'session_id': 'same', 'message': 'sim'}}, GlobalRouteDecision(backend_id='x'))
|
||||
asyncio.run(scenario())
|
||||
assert seen[1]['taskId'] == 'remote-task'
|
||||
assert seen[1]['contextId'] == 'remote-context'
|
||||
assert 'taskId' not in seen[2]
|
||||
assert seen[0]['contextId'] != seen[2]['contextId']
|
||||
|
||||
|
||||
def test_credentials_and_deadline(monkeypatch):
|
||||
monkeypatch.delenv('MISSING_A2A_TOKEN', raising=False)
|
||||
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a', a2a_headers_env={'Authorization': 'MISSING_A2A_TOKEN'})
|
||||
with pytest.raises(ValueError, match='MISSING_A2A_TOKEN'):
|
||||
asyncio.run(BackendClient().call_message(backend, {'payload': {}}, GlobalRouteDecision(backend_id='x')))
|
||||
async def transport(request):
|
||||
await asyncio.sleep(1)
|
||||
return httpx.Response(200)
|
||||
mock_http(monkeypatch, transport)
|
||||
backend.a2a_headers_env = {}
|
||||
with pytest.raises(TimeoutError):
|
||||
asyncio.run(BackendClient(timeout_seconds=.01).call_message(backend, {'payload': {}}, GlobalRouteDecision(backend_id='x')))
|
||||
|
||||
|
||||
def test_native_does_not_import_sdk():
|
||||
import subprocess
|
||||
import sys
|
||||
code = '''
|
||||
import sys
|
||||
class BlockA2A:
|
||||
def find_spec(self, fullname, *args):
|
||||
if fullname == 'a2a' or fullname.startswith('a2a.'):
|
||||
raise ImportError('A2A unavailable')
|
||||
sys.meta_path.insert(0, BlockA2A())
|
||||
from agent_framework.global_supervisor.client import BackendClient
|
||||
from agent_framework.global_supervisor.models import BackendDefinition
|
||||
assert BackendDefinition(backend_id='x', url='http://x').protocol == 'native'
|
||||
BackendClient()
|
||||
'''
|
||||
subprocess.run([sys.executable, '-c', code], check=True)
|
||||
|
||||
|
||||
def test_health_uses_card_and_credentials(monkeypatch):
|
||||
seen = []
|
||||
def transport(request):
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={'name': 'external'})
|
||||
mock_http(monkeypatch, transport)
|
||||
monkeypatch.setenv('A2A_TEST_AUTH', 'Bearer test')
|
||||
backend = BackendDefinition(backend_id='x', url='http://backend', protocol='a2a', a2a_headers_env={'Authorization':'A2A_TEST_AUTH'})
|
||||
result = asyncio.run(BackendClient().health(backend))
|
||||
assert result['ok']
|
||||
assert seen[0].url.path == '/.well-known/agent-card.json'
|
||||
assert seen[0].headers['Authorization'] == 'Bearer test'
|
||||
|
||||
|
||||
def test_a2a_backend_selection_is_forwarded_to_gateway(facade):
|
||||
client, seen = facade
|
||||
response = client.post('/a2a', headers={'A2A-Version': '1.0'}, json={
|
||||
'jsonrpc': '2.0', 'id': 'select', 'method': 'SendMessage', 'params': {
|
||||
'message': {'messageId': 'm', 'role': 'ROLE_USER', 'parts': [{'text': 'Olá'}],
|
||||
'metadata': {'backend_id': 'billing', 'agent_id': 'billing_agent'}},
|
||||
},
|
||||
}).json()
|
||||
assert 'result' in response, response
|
||||
assert seen[0]['backend_id'] == 'billing'
|
||||
assert seen[0]['agent_id'] == 'billing_agent'
|
||||
Reference in New Issue
Block a user