#![cfg(feature = "grpc")]
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use a2a_protocol_client::error::ClientError;
use a2a_protocol_client::transport::grpc::GrpcTransport;
use a2a_protocol_client::{A2aClient, ClientBuilder};
use a2a_protocol_server::builder::RequestHandlerBuilder;
use a2a_protocol_server::dispatch::grpc::{GrpcConfig, GrpcDispatcher};
use a2a_protocol_server::executor::AgentExecutor;
use a2a_protocol_server::request_context::RequestContext;
use a2a_protocol_server::streaming::EventQueueWriter;
use a2a_protocol_types::agent_card::{AgentCapabilities, AgentCard, AgentInterface, AgentSkill};
use a2a_protocol_types::error::A2aResult;
use a2a_protocol_types::message::{Message, MessageId, MessageRole, Part, PartContent};
use a2a_protocol_types::params::{MessageSendParams, TaskQueryParams};
use a2a_protocol_types::push::TaskPushNotificationConfig;
use a2a_protocol_types::responses::SendMessageResponse;
use a2a_protocol_types::task::TaskState;
struct CompletingExecutor;
impl AgentExecutor for CompletingExecutor {
fn execute<'a>(
&'a self,
ctx: &'a RequestContext,
queue: &'a dyn EventQueueWriter,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
use a2a_protocol_types::artifact::Artifact;
use a2a_protocol_types::events::{
StreamResponse, TaskArtifactUpdateEvent, TaskStatusUpdateEvent,
};
use a2a_protocol_types::task::{ContextId, TaskStatus};
Box::pin(async move {
queue
.write(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: ctx.task_id.clone(),
context_id: ContextId::new(ctx.context_id.clone()),
status: TaskStatus::new(TaskState::Working),
metadata: None,
}))
.await?;
queue
.write(StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
task_id: ctx.task_id.clone(),
context_id: ContextId::new(ctx.context_id.clone()),
artifact: Artifact::new("e2e-artifact", vec![Part::text("done")]),
append: None,
last_chunk: Some(true),
metadata: None,
}))
.await?;
queue
.write(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: ctx.task_id.clone(),
context_id: ContextId::new(ctx.context_id.clone()),
status: TaskStatus::new(TaskState::Completed),
metadata: None,
}))
.await?;
Ok(())
})
}
}
fn agent_card() -> AgentCard {
AgentCard {
url: None,
name: "gRPC e2e Agent".into(),
description: "Canonical gRPC e2e test agent".into(),
version: "1.0.0".into(),
supported_interfaces: vec![AgentInterface {
url: "http://localhost:0".into(),
protocol_binding: "GRPC".into(),
protocol_version: "1.0".into(),
tenant: None,
}],
default_input_modes: vec!["text/plain".into()],
default_output_modes: vec!["text/plain".into()],
skills: vec![AgentSkill {
id: "noop".into(),
name: "Noop".into(),
description: "Does nothing".into(),
tags: vec!["test".into()],
examples: None,
input_modes: None,
output_modes: None,
security_requirements: None,
}],
capabilities: AgentCapabilities::none()
.with_streaming(true)
.with_push_notifications(true),
provider: None,
icon_url: None,
documentation_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
}
}
struct NoopPushSender;
impl a2a_protocol_server::push::PushSender for NoopPushSender {
fn send<'a>(
&'a self,
_url: &'a str,
_event: &'a a2a_protocol_types::events::StreamResponse,
_config: &'a TaskPushNotificationConfig,
) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
Box::pin(async { Ok(()) })
}
}
async fn start() -> A2aClient {
let handler = Arc::new(
RequestHandlerBuilder::new(CompletingExecutor)
.with_agent_card(agent_card())
.with_push_sender(NoopPushSender)
.build()
.expect("build handler"),
);
let dispatcher = GrpcDispatcher::new(handler, GrpcConfig::default());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = dispatcher.serve_with_listener(listener).expect("serve");
let endpoint = format!("http://{addr}");
let transport = GrpcTransport::connect(&endpoint).await.expect("connect");
ClientBuilder::new(&endpoint)
.with_custom_transport(transport)
.build()
.expect("build client")
}
fn user_message(text: &str) -> Message {
Message {
id: MessageId(format!("m-{text}")),
role: MessageRole::User,
parts: vec![Part {
content: PartContent::Text(text.into()),
metadata: None,
filename: None,
media_type: Some("text/plain".into()),
}],
task_id: None,
context_id: None,
reference_task_ids: None,
extensions: None,
metadata: None,
}
}
fn send_params(text: &str) -> MessageSendParams {
MessageSendParams {
tenant: None,
message: user_message(text),
configuration: None,
metadata: None,
}
}
async fn await_terminal(client: &A2aClient, id: &str) -> a2a_protocol_types::task::Task {
for _ in 0..50 {
let task = client
.get_task(TaskQueryParams {
tenant: None,
id: id.to_owned(),
history_length: None,
})
.await
.expect("get_task while polling");
if task.status.state.is_terminal() {
return task;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
panic!("task {id} did not reach a terminal state in time");
}
#[tokio::test]
async fn send_message_and_get_task_roundtrip() {
let client = start().await;
let resp = client
.send_message(send_params("hello"))
.await
.expect("send_message over canonical gRPC");
let task = match resp {
SendMessageResponse::Task(t) => t,
other => panic!("expected task, got {other:?}"),
};
assert!(!task.id.0.is_empty(), "server must assign a task id");
assert!(
!task.context_id.0.is_empty(),
"server must assign a context id"
);
let fetched = await_terminal(&client, &task.id.0).await;
assert_eq!(fetched.id, task.id);
assert_eq!(fetched.status.state, TaskState::Completed);
}
#[tokio::test]
async fn get_task_unknown_id_maps_to_task_not_found() {
let client = start().await;
let err = client
.get_task(TaskQueryParams {
tenant: None,
id: "no-such-task".into(),
history_length: None,
})
.await
.expect_err("unknown task must error");
match err {
ClientError::Protocol(a2a) => {
assert_eq!(a2a.code, a2a_protocol_types::ErrorCode::TaskNotFound);
}
other => panic!("expected Protocol(TaskNotFound), got {other:?}"),
}
}
#[tokio::test]
async fn cancel_completed_task_maps_to_not_cancelable() {
let client = start().await;
let resp = client
.send_message(send_params("to-cancel"))
.await
.expect("send_message");
let SendMessageResponse::Task(task) = resp else {
panic!("expected task response");
};
let task = await_terminal(&client, &task.id.0).await;
let err = client
.cancel_task(task.id.0)
.await
.expect_err("canceling a completed task must fail");
match err {
ClientError::Protocol(a2a) => {
assert_eq!(a2a.code, a2a_protocol_types::ErrorCode::TaskNotCancelable);
}
other => panic!("expected Protocol(TaskNotCancelable), got {other:?}"),
}
}
#[tokio::test]
async fn push_config_crud_roundtrip() {
let client = start().await;
let resp = client
.send_message(send_params("push"))
.await
.expect("send_message");
let SendMessageResponse::Task(task) = resp else {
panic!("expected task response");
};
let created = client
.set_push_config(TaskPushNotificationConfig {
tenant: None,
id: None,
task_id: Some(task.id.0.clone()),
url: "https://hooks.example.com/notify".into(),
token: Some("tok-1".into()),
authentication: None,
})
.await
.expect("create push config");
let config_id = created.id.clone().expect("server must assign a config id");
assert_eq!(created.task_id.as_deref(), Some(task.id.0.as_str()));
let fetched = client
.get_push_config(task.id.0.clone(), config_id.clone())
.await
.expect("get push config");
assert_eq!(fetched.url, "https://hooks.example.com/notify");
assert_eq!(fetched.token.as_deref(), Some("tok-1"));
let listed = client
.list_push_configs(a2a_protocol_types::params::ListPushConfigsParams {
tenant: None,
task_id: task.id.0.clone(),
page_size: None,
page_token: None,
})
.await
.expect("list push configs");
assert!(
listed
.configs
.iter()
.any(|c| c.id.as_deref() == Some(config_id.as_str())),
"created config must appear in the list"
);
client
.delete_push_config(task.id.0.clone(), config_id.clone())
.await
.expect("delete push config");
let err = client
.get_push_config(task.id.0.clone(), config_id)
.await
.expect_err("deleted config must be gone");
assert!(matches!(err, ClientError::Protocol(_)), "got {err:?}");
}
#[tokio::test]
async fn stream_message_delivers_typed_events_until_terminal() {
let client = start().await;
let mut stream = client
.stream_message(send_params("stream"))
.await
.expect("open canonical gRPC stream");
let mut events = Vec::new();
while let Some(item) = stream.next().await {
events.push(item.expect("stream event decodes"));
}
assert!(
!events.is_empty(),
"streaming send must deliver at least one event"
);
let terminal = events.iter().any(|e| match e {
a2a_protocol_types::events::StreamResponse::StatusUpdate(u) => u.status.state.is_terminal(),
a2a_protocol_types::events::StreamResponse::Task(t) => t.status.state.is_terminal(),
_ => false,
});
assert!(terminal, "stream must reach a terminal state: {events:?}");
}