use crate::auth::RequestContext;
use crate::error::A2aError;
use crate::server::TaskEventStream;
use crate::types::{
AgentCard, CancelTaskParams, DeletePushNotificationConfigParams, GetExtendedAgentCardParams,
GetPushNotificationConfigParams, GetTaskParams, ListPushNotificationConfigsParams,
ListPushNotificationConfigsResult, ListTasksParams, ListTasksResult, PushNotificationConfig,
PushNotificationConfigParams, SendMessageParams, SendMessageResult, SubscribeToTaskParams,
Task,
};
use async_trait::async_trait;
#[cfg(any(test, feature = "test-fixtures"))]
use crate::types::{AgentCapabilities, Message, Role, TaskStatus};
#[cfg(any(test, feature = "test-fixtures"))]
use std::collections::HashMap;
#[cfg(any(test, feature = "test-fixtures"))]
use std::sync::Mutex;
#[cfg(any(test, feature = "test-fixtures"))]
use uuid::Uuid;
#[async_trait]
pub trait A2aHandler: Send + Sync {
async fn send_message(
&self,
_ctx: &RequestContext,
_params: SendMessageParams,
) -> Result<SendMessageResult, A2aError> {
Err(A2aError::MethodNotFound("SendMessage".into()))
}
async fn send_streaming_message(
&self,
_ctx: &RequestContext,
_params: SendMessageParams,
) -> Result<TaskEventStream, A2aError> {
Err(A2aError::MethodNotFound("SendStreamingMessage".into()))
}
async fn get_task(
&self,
_ctx: &RequestContext,
_params: GetTaskParams,
) -> Result<Task, A2aError> {
Err(A2aError::MethodNotFound("GetTask".into()))
}
async fn list_tasks(
&self,
_ctx: &RequestContext,
_params: ListTasksParams,
) -> Result<ListTasksResult, A2aError> {
Err(A2aError::MethodNotFound("ListTasks".into()))
}
async fn cancel_task(
&self,
_ctx: &RequestContext,
_params: CancelTaskParams,
) -> Result<Task, A2aError> {
Err(A2aError::MethodNotFound("CancelTask".into()))
}
async fn subscribe_to_task(
&self,
_ctx: &RequestContext,
_params: SubscribeToTaskParams,
) -> Result<TaskEventStream, A2aError> {
Err(A2aError::MethodNotFound("SubscribeToTask".into()))
}
async fn create_task_push_notification_config(
&self,
_ctx: &RequestContext,
_params: PushNotificationConfigParams,
) -> Result<PushNotificationConfig, A2aError> {
Err(A2aError::MethodNotFound(
"CreateTaskPushNotificationConfig".into(),
))
}
async fn get_task_push_notification_config(
&self,
_ctx: &RequestContext,
_params: GetPushNotificationConfigParams,
) -> Result<PushNotificationConfig, A2aError> {
Err(A2aError::MethodNotFound(
"GetTaskPushNotificationConfig".into(),
))
}
async fn list_task_push_notification_configs(
&self,
_ctx: &RequestContext,
_params: ListPushNotificationConfigsParams,
) -> Result<ListPushNotificationConfigsResult, A2aError> {
Err(A2aError::MethodNotFound(
"ListTaskPushNotificationConfigs".into(),
))
}
async fn delete_task_push_notification_config(
&self,
_ctx: &RequestContext,
_params: DeletePushNotificationConfigParams,
) -> Result<(), A2aError> {
Err(A2aError::MethodNotFound(
"DeleteTaskPushNotificationConfig".into(),
))
}
async fn get_extended_agent_card(
&self,
_ctx: &RequestContext,
_params: GetExtendedAgentCardParams,
) -> Result<AgentCard, A2aError> {
Err(A2aError::MethodNotFound("GetExtendedAgentCard".into()))
}
fn is_idempotent(&self) -> bool {
false
}
}
#[cfg(any(test, feature = "test-fixtures"))]
#[derive(Default)]
pub struct EchoHandler {
tasks: Mutex<HashMap<String, Task>>,
}
#[cfg(any(test, feature = "test-fixtures"))]
#[async_trait]
impl A2aHandler for EchoHandler {
async fn send_message(
&self,
_ctx: &RequestContext,
params: SendMessageParams,
) -> Result<SendMessageResult, A2aError> {
let task_id = format!("task-{}", Uuid::new_v4());
let context_id = params
.message
.contextId
.clone()
.unwrap_or_else(|| format!("ctx-{}", Uuid::new_v4()));
let response = Message {
messageId: format!("msg-{}", Uuid::new_v4()),
contextId: Some(context_id.clone()),
taskId: Some(task_id.clone()),
role: Role::Agent,
parts: params.message.parts.clone(),
metadata: None,
extensions: vec![],
referenceTaskIds: vec![],
};
let task = Task {
id: task_id.clone(),
contextId: context_id,
status: TaskStatus::Completed,
artifacts: vec![],
history: vec![params.message, response],
metadata: None,
};
self.tasks
.lock()
.map_err(|_| A2aError::Server("mutex poisoned".into()))?
.insert(task_id.clone(), task.clone());
Ok(SendMessageResult::Task(task))
}
async fn get_task(
&self,
_ctx: &RequestContext,
params: GetTaskParams,
) -> Result<Task, A2aError> {
self.tasks
.lock()
.map_err(|_| A2aError::Server("mutex poisoned".into()))?
.get(¶ms.id)
.cloned()
.ok_or(A2aError::TaskNotFound(params.id))
}
async fn list_tasks(
&self,
_ctx: &RequestContext,
params: ListTasksParams,
) -> Result<ListTasksResult, A2aError> {
let guard = self
.tasks
.lock()
.map_err(|_| A2aError::Server("mutex poisoned".into()))?;
let tasks: Vec<Task> = guard
.values()
.filter(|t| match ¶ms.contextId {
Some(ctx) => &t.contextId == ctx,
None => true,
})
.cloned()
.collect();
Ok(ListTasksResult {
tasks,
nextCursor: None,
})
}
async fn cancel_task(
&self,
_ctx: &RequestContext,
params: CancelTaskParams,
) -> Result<Task, A2aError> {
let mut guard = self
.tasks
.lock()
.map_err(|_| A2aError::Server("mutex poisoned".into()))?;
let task = guard
.get_mut(¶ms.id)
.ok_or_else(|| A2aError::TaskNotFound(params.id.clone()))?;
task.status = TaskStatus::Canceled;
Ok(task.clone())
}
async fn get_extended_agent_card(
&self,
_ctx: &RequestContext,
_params: GetExtendedAgentCardParams,
) -> Result<AgentCard, A2aError> {
Ok(AgentCard {
id: "echo".into(),
name: "Echo Agent".into(),
description: Some("Echoes incoming messages as completed tasks.".into()),
provider: None,
capabilities: AgentCapabilities {
streaming: false,
pushNotifications: false,
stateTransitionHistory: false,
},
skills: vec![],
interfaces: vec![],
securitySchemes: serde_json::Map::new(),
security: vec![],
extensions: vec![],
signature: None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::envelope::A2aHeaders;
use crate::types::{
CancelTaskParams, GetExtendedAgentCardParams, GetTaskParams, Message, Part, Role,
SendMessageParams, TaskStatus,
};
struct AlwaysDefault;
#[async_trait::async_trait]
impl A2aHandler for AlwaysDefault {}
fn anon_ctx() -> RequestContext {
RequestContext::new(
A2aHeaders::decode_from(&klieo_core::Headers::new()),
Some(klieo_auth_common::Identity::anonymous()),
)
}
#[tokio::test]
async fn default_handler_returns_method_not_found() {
let h = AlwaysDefault;
let err = h
.send_message(
&anon_ctx(),
SendMessageParams {
message: Message {
messageId: "m".into(),
contextId: None,
taskId: None,
role: Role::User,
parts: vec![],
metadata: None,
extensions: vec![],
referenceTaskIds: vec![],
},
configuration: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, A2aError::MethodNotFound(_)));
}
#[tokio::test]
async fn echo_handler_send_message_returns_completed_task() {
let h = EchoHandler::default();
let msg = Message {
messageId: "m-1".into(),
contextId: Some("c-1".into()),
taskId: None,
role: Role::User,
parts: vec![Part::Text {
content: "hi".into(),
metadata: None,
mediaType: None,
filename: None,
}],
metadata: None,
extensions: vec![],
referenceTaskIds: vec![],
};
let result = h
.send_message(
&anon_ctx(),
SendMessageParams {
message: msg,
configuration: None,
},
)
.await
.unwrap();
match result {
crate::types::SendMessageResult::Task(t) => {
assert!(matches!(t.status, TaskStatus::Completed));
assert_eq!(t.history.len(), 2);
}
_ => panic!("expected Task"),
}
}
#[tokio::test]
async fn echo_handler_get_task_recovers_stored_task() {
let h = EchoHandler::default();
let msg = Message {
messageId: "m-2".into(),
contextId: Some("c-2".into()),
taskId: None,
role: Role::User,
parts: vec![],
metadata: None,
extensions: vec![],
referenceTaskIds: vec![],
};
let result = h
.send_message(
&anon_ctx(),
SendMessageParams {
message: msg,
configuration: None,
},
)
.await
.unwrap();
let task_id = match result {
crate::types::SendMessageResult::Task(t) => t.id,
_ => panic!(),
};
let recovered = h
.get_task(
&anon_ctx(),
GetTaskParams {
id: task_id.clone(),
historyLength: None,
},
)
.await
.unwrap();
assert_eq!(recovered.id, task_id);
}
#[tokio::test]
async fn echo_handler_cancel_task_marks_canceled() {
let h = EchoHandler::default();
let msg = Message {
messageId: "m-3".into(),
contextId: Some("c-3".into()),
taskId: None,
role: Role::User,
parts: vec![],
metadata: None,
extensions: vec![],
referenceTaskIds: vec![],
};
let task_id = match h
.send_message(
&anon_ctx(),
SendMessageParams {
message: msg,
configuration: None,
},
)
.await
.unwrap()
{
crate::types::SendMessageResult::Task(t) => t.id,
_ => panic!(),
};
let cancelled = h
.cancel_task(&anon_ctx(), CancelTaskParams { id: task_id })
.await
.unwrap();
assert!(matches!(cancelled.status, TaskStatus::Canceled));
}
#[test]
fn echo_handler_default_is_not_idempotent() {
let h = EchoHandler::default();
assert!(!h.is_idempotent());
}
#[test]
fn always_default_handler_is_not_idempotent() {
let h = AlwaysDefault;
assert!(!h.is_idempotent());
}
#[tokio::test]
async fn echo_handler_extended_agent_card_returns_static() {
let h = EchoHandler::default();
let card = h
.get_extended_agent_card(&anon_ctx(), GetExtendedAgentCardParams::default())
.await
.unwrap();
assert_eq!(card.id, "echo");
}
}