Files
agent-framework/python/packages/hosting-a2a/tests/hosting_a2a/test_channel.py
T
5534198142 Python: add agent-framework-hosting-a2a channel (#6306)
* feat(python): add agent-framework-hosting-a2a channel

Add a hosting channel that exposes the host target (agent or workflow)
as a peer agent over the Agent-to-Agent (A2A) protocol (JSON-RPC plus a
served agent card). Requests are handled by a host-routed
HostAgentExecutor that drives the host pipeline (ChannelContext.run/
run_stream) instead of wrapping the target directly, so sessions,
linking, and run/response hooks apply. Maps the A2A conversation/context
id to a ChannelSession isolation key and the caller to a ChannelIdentity;
streaming emits incremental task artifacts.

Includes tests, README, and workspace registration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address A2A hosting channel review feedback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-12 12:20:54 +02:00

310 lines
11 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for :class:`A2AChannel` and :class:`HostAgentExecutor`."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Awaitable
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from typing import Any
import pytest
import uvicorn
from a2a.server.events import EventQueue
from a2a.types import AgentCard, AgentInterface, Message, Part, Role, Task, TaskState
from agent_framework import AgentResponse, Content
from agent_framework import Message as AFMessage
from agent_framework_a2a import A2AAgent
from agent_framework_hosting import AgentFrameworkHost, ChannelContribution, ChannelRequest, HostedRunResult
from starlette.types import ASGIApp
from agent_framework_hosting_a2a import A2AChannel, HostAgentExecutor
# --------------------------------------------------------------------------- #
# Fakes #
# --------------------------------------------------------------------------- #
@dataclass
class _FakeResp:
text: str
messages: list[Message] = field(default_factory=list)
@dataclass
class _FakeUpdate:
text: str
contents: list[Content] = field(default_factory=list)
message_id: str | None = None
class _FakeStream:
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
self._final = _FakeResp(text="".join(chunks))
def __aiter__(self) -> AsyncIterator[_FakeUpdate]:
async def _gen() -> AsyncIterator[_FakeUpdate]:
for i, c in enumerate(self._chunks):
yield _FakeUpdate(text=c, contents=[Content.from_text(text=c)], message_id=f"m{i}")
return _gen()
async def get_final_response(self) -> _FakeResp:
return self._final
@dataclass
class _FakeTarget:
name: str = "Assistant"
description: str = "A helpful assistant."
class _FakeContext:
def __init__(
self,
*,
reply: str = "hello",
chunks: list[str] | None = None,
) -> None:
self.target = _FakeTarget()
self._reply = reply
self._chunks = chunks or [reply]
self.requests: list[ChannelRequest] = []
async def run(
self,
request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> HostedRunResult[Any]:
if run_hook is not None:
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
if isinstance(maybe_request, Awaitable):
request = await maybe_request
else:
request = maybe_request
self.requests.append(request)
msg = Message(role=Role.ROLE_AGENT, parts=[Part(text=self._reply)])
result = HostedRunResult(_FakeResp(text=self._reply, messages=[msg]))
if response_hook is not None:
maybe_result = response_hook(result, request=request, channel_name=channel_name or request.channel)
if isinstance(maybe_result, Awaitable):
return await maybe_result
return maybe_result
return result
async def run_stream(
self,
request: ChannelRequest,
*,
run_hook: Any | None = None,
protocol_request: Any | None = None,
stream_update_hook: Any | None = None,
response_hook: Any | None = None,
channel_name: str | None = None,
) -> _FakeStream:
if run_hook is not None:
maybe_request = run_hook(request, target=self.target, protocol_request=protocol_request)
if isinstance(maybe_request, Awaitable):
request = await maybe_request
else:
request = maybe_request
self.requests.append(request)
return _FakeStream(self._chunks)
class _RecordingEventQueue(EventQueue):
def __init__(self) -> None:
super().__init__()
self.events: list[Any] = []
async def enqueue_event(self, event: Any) -> None:
self.events.append(event)
await super().enqueue_event(event)
class _FakeRequestContext:
def __init__(self, *, context_id: str, text: str, current_task: Task | None = None) -> None:
self.context_id = context_id
self.task_id: str | None = None
self.message = Message(
message_id="msg-1",
context_id=context_id,
role=Role.ROLE_USER,
parts=[Part(text=text)],
)
self.current_task = current_task
self._text = text
def get_user_input(self) -> str:
return self._text
class _HostedAgent:
name = "HostedAssistant"
description = "A hosted test assistant."
async def run(self, messages: Any = None, *, stream: bool = False, **_kwargs: Any) -> AgentResponse[Any]:
text = messages.text if isinstance(messages, AFMessage) else str(messages)
return AgentResponse(messages=[AFMessage(role="assistant", contents=[Content.from_text(text=f"host: {text}")])])
@asynccontextmanager
async def _serve_app(app: ASGIApp, *, port: int) -> AsyncIterator[str]:
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="on")
server = uvicorn.Server(config)
task = asyncio.create_task(server.serve())
try:
for _ in range(100):
if server.started:
break
await asyncio.sleep(0.01)
else:
raise RuntimeError("Test A2A server did not start")
yield f"http://127.0.0.1:{port}"
finally:
server.should_exit = True
await task
def _status_states(events: list[Any]) -> list[int]:
states: list[int] = []
for event in events:
status = getattr(event, "status", None)
if status is not None and getattr(status, "state", None):
states.append(status.state)
return states
# --------------------------------------------------------------------------- #
# A2AChannel tests #
# --------------------------------------------------------------------------- #
def test_default_name_and_root_path() -> None:
channel = A2AChannel()
assert channel.name == "a2a"
assert channel.path == ""
def test_build_agent_card_defaults_from_target() -> None:
channel = A2AChannel(url="https://example.com/")
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
assert card.name == "Assistant"
assert card.description == "A helpful assistant."
assert card.capabilities.streaming is True
assert card.supported_interfaces[0].url == "https://example.com/"
def test_build_agent_card_accepts_supported_interfaces() -> None:
interfaces = [
AgentInterface(url="https://example.com/jsonrpc", protocol_binding="JSONRPC"),
AgentInterface(url="https://example.com/grpc", protocol_binding="GRPC"),
]
channel = A2AChannel(supported_interfaces=interfaces)
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
assert card.supported_interfaces == interfaces
def test_build_agent_card_override_wins() -> None:
custom = AgentCard(name="Custom", description="custom card", version="9.9.9")
channel = A2AChannel(agent_card=custom)
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
assert card.name == "Custom"
assert card.version == "9.9.9"
def test_contribute_returns_card_and_jsonrpc_routes() -> None:
channel = A2AChannel(url="https://example.com/")
contribution = channel.contribute(_FakeContext()) # type: ignore[arg-type]
assert isinstance(contribution, ChannelContribution)
paths = {getattr(r, "path", None) for r in contribution.routes}
assert "/.well-known/agent-card.json" in paths
assert any(p == "/" for p in paths)
# --------------------------------------------------------------------------- #
# HostAgentExecutor tests #
# --------------------------------------------------------------------------- #
async def test_execute_routes_through_host_and_completes() -> None:
ctx = _FakeContext(reply="hi back")
executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=False) # type: ignore[arg-type]
queue = _RecordingEventQueue()
request_context = _FakeRequestContext(context_id="conv-1", text="hello")
await executor.execute(request_context, queue) # type: ignore[arg-type]
# Routed through the host with the context id mapped onto the session.
assert len(ctx.requests) == 1
request = ctx.requests[0]
assert request.channel == "a2a"
assert request.input == "hello"
assert request.session is not None
assert request.session.isolation_key == "conv-1"
assert request.identity is not None
assert request.identity.native_id == "conv-1"
# Task progressed to a completed state.
assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events)
async def test_execute_streaming_emits_artifacts() -> None:
ctx = _FakeContext(chunks=["foo", "bar"])
executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=True) # type: ignore[arg-type]
queue = _RecordingEventQueue()
request_context = _FakeRequestContext(context_id="conv-2", text="hello")
await executor.execute(request_context, queue) # type: ignore[arg-type]
artifact_events = [e for e in queue.events if getattr(e, "artifact", None)]
assert artifact_events, "expected at least one artifact update event"
assert ctx.requests[0].stream is True
assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events)
async def test_execute_requires_context_id() -> None:
ctx = _FakeContext()
executor = HostAgentExecutor(ctx, channel_name="a2a") # type: ignore[arg-type]
queue = _RecordingEventQueue()
request_context = _FakeRequestContext(context_id="x", text="hello")
request_context.context_id = None # type: ignore[assignment]
with pytest.raises(ValueError, match="Context ID"):
await executor.execute(request_context, queue) # type: ignore[arg-type]
async def test_a2a_agent_can_call_hosted_channel(unused_tcp_port: int) -> None:
host = AgentFrameworkHost(target=_HostedAgent(), channels=[A2AChannel(streaming=False)])
async with (
_serve_app(host.app, port=unused_tcp_port) as base_url,
A2AAgent(
url=base_url,
timeout=5.0,
) as agent,
):
response = await agent.run("hello")
assert response.messages[0].text == "host: hello"
def test_contents_to_parts_conversion() -> None:
from agent_framework_hosting_a2a._executor import _contents_to_parts
contents = [
Content.from_text(text="hello"),
Content.from_uri(uri="https://x/y.png", media_type="image/png"),
Content.from_data(data=b"AAAA", media_type="image/png"),
]
parts = _contents_to_parts(contents)
assert parts[0].text == "hello"
assert parts[1].url == "https://x/y.png"
assert parts[2].raw == b"AAAA"