Skip to main content

klieo_a2a/
handler.rs

1//! `A2aHandler` trait + `EchoHandler` test fixture.
2//!
3//! The trait has 11 methods — one per A2A v1.0 spec §9.4 op — each
4//! defaulted to return [`A2aError::MethodNotFound`]. Implementors
5//! override only the methods their agent supports; the dispatcher
6//! cleanly returns -32601 for everything else.
7
8use crate::auth::RequestContext;
9use crate::error::A2aError;
10use crate::server::TaskEventStream;
11use crate::types::{
12    AgentCard, CancelTaskParams, DeletePushNotificationConfigParams, GetExtendedAgentCardParams,
13    GetPushNotificationConfigParams, GetTaskParams, ListPushNotificationConfigsParams,
14    ListPushNotificationConfigsResult, ListTasksParams, ListTasksResult, PushNotificationConfig,
15    PushNotificationConfigParams, SendMessageParams, SendMessageResult, SubscribeToTaskParams,
16    Task,
17};
18use async_trait::async_trait;
19
20#[cfg(any(test, feature = "test-fixtures"))]
21use crate::types::{AgentCapabilities, Message, Role, TaskStatus};
22#[cfg(any(test, feature = "test-fixtures"))]
23use std::collections::HashMap;
24#[cfg(any(test, feature = "test-fixtures"))]
25use std::sync::Mutex;
26#[cfg(any(test, feature = "test-fixtures"))]
27use uuid::Uuid;
28
29/// Server-side handler implementing the 11 A2A v1.0 operations.
30///
31/// Default implementations return [`A2aError::MethodNotFound`]. Override
32/// only the methods your agent supports; `A2aServer` translates the
33/// error into a JSON-RPC -32601 response automatically.
34#[async_trait]
35pub trait A2aHandler: Send + Sync {
36    /// Spec §9.4.1. `ctx.caller` is the verified identity (or `None`
37    /// only when `AllowAnonymous` was wired). Implementations should
38    /// reject `None` for mutating methods.
39    async fn send_message(
40        &self,
41        _ctx: &RequestContext,
42        _params: SendMessageParams,
43    ) -> Result<SendMessageResult, A2aError> {
44        Err(A2aError::MethodNotFound("SendMessage".into()))
45    }
46    /// Spec §9.4.2. Streaming variant — v0.0.2. Return a
47    /// [`TaskEventStream`] to override the dispatcher's default
48    /// broadcast-derived stream; return [`A2aError::MethodNotFound`]
49    /// to fall back to it.
50    async fn send_streaming_message(
51        &self,
52        _ctx: &RequestContext,
53        _params: SendMessageParams,
54    ) -> Result<TaskEventStream, A2aError> {
55        Err(A2aError::MethodNotFound("SendStreamingMessage".into()))
56    }
57    /// Spec §9.4.3.
58    async fn get_task(
59        &self,
60        _ctx: &RequestContext,
61        _params: GetTaskParams,
62    ) -> Result<Task, A2aError> {
63        Err(A2aError::MethodNotFound("GetTask".into()))
64    }
65    /// Spec §9.4.4.
66    async fn list_tasks(
67        &self,
68        _ctx: &RequestContext,
69        _params: ListTasksParams,
70    ) -> Result<ListTasksResult, A2aError> {
71        Err(A2aError::MethodNotFound("ListTasks".into()))
72    }
73    /// Spec §9.4.5.
74    async fn cancel_task(
75        &self,
76        _ctx: &RequestContext,
77        _params: CancelTaskParams,
78    ) -> Result<Task, A2aError> {
79        Err(A2aError::MethodNotFound("CancelTask".into()))
80    }
81    /// Spec §9.4.6. v0.0.2. Return a [`TaskEventStream`] to override
82    /// the dispatcher's default broadcast-derived stream; return
83    /// [`A2aError::MethodNotFound`] to fall back to it.
84    async fn subscribe_to_task(
85        &self,
86        _ctx: &RequestContext,
87        _params: SubscribeToTaskParams,
88    ) -> Result<TaskEventStream, A2aError> {
89        Err(A2aError::MethodNotFound("SubscribeToTask".into()))
90    }
91    /// Spec §9.4.7. v0.0.2.
92    async fn create_task_push_notification_config(
93        &self,
94        _ctx: &RequestContext,
95        _params: PushNotificationConfigParams,
96    ) -> Result<PushNotificationConfig, A2aError> {
97        Err(A2aError::MethodNotFound(
98            "CreateTaskPushNotificationConfig".into(),
99        ))
100    }
101    /// Spec §9.4.8. v0.0.2.
102    async fn get_task_push_notification_config(
103        &self,
104        _ctx: &RequestContext,
105        _params: GetPushNotificationConfigParams,
106    ) -> Result<PushNotificationConfig, A2aError> {
107        Err(A2aError::MethodNotFound(
108            "GetTaskPushNotificationConfig".into(),
109        ))
110    }
111    /// Spec §9.4.9. v0.0.2.
112    async fn list_task_push_notification_configs(
113        &self,
114        _ctx: &RequestContext,
115        _params: ListPushNotificationConfigsParams,
116    ) -> Result<ListPushNotificationConfigsResult, A2aError> {
117        Err(A2aError::MethodNotFound(
118            "ListTaskPushNotificationConfigs".into(),
119        ))
120    }
121    /// Spec §9.4.10. v0.0.2.
122    async fn delete_task_push_notification_config(
123        &self,
124        _ctx: &RequestContext,
125        _params: DeletePushNotificationConfigParams,
126    ) -> Result<(), A2aError> {
127        Err(A2aError::MethodNotFound(
128            "DeleteTaskPushNotificationConfig".into(),
129        ))
130    }
131    /// Spec §9.4.11.
132    async fn get_extended_agent_card(
133        &self,
134        _ctx: &RequestContext,
135        _params: GetExtendedAgentCardParams,
136    ) -> Result<AgentCard, A2aError> {
137        Err(A2aError::MethodNotFound("GetExtendedAgentCard".into()))
138    }
139
140    /// The PUBLIC agent card served over
141    /// `GET /.well-known/agent-card.json` (A2A spec §5, IANA
142    /// registration in §12 of the spec's well-known-URI appendix).
143    ///
144    /// Deliberately a SEPARATE method from [`Self::get_extended_agent_card`]:
145    /// the well-known endpoint is unauthenticated BY PROTOCOL DESIGN (a
146    /// client fetches it before it knows which auth scheme to use), so
147    /// `ctx.caller` is always `None` here — implementations MUST NOT
148    /// return anything in this card that isn't safe to hand to an
149    /// anonymous caller. Richer, auth-gated detail belongs in
150    /// [`Self::get_extended_agent_card`] instead, which only runs after
151    /// the dispatcher's `Authenticator` has verified the caller.
152    async fn get_agent_card(&self, _ctx: &RequestContext) -> Result<AgentCard, A2aError> {
153        Err(A2aError::MethodNotFound("GetAgentCard".into()))
154    }
155
156    /// Returns true if all streaming invocations against this
157    /// handler are safe to re-execute from the original payload
158    /// — i.e. the result is deterministic OR duplicate execution
159    /// has no business effect.
160    ///
161    /// Default false (safe). Mark true ONLY when the handler is
162    /// provably stateless / read-only or returns the same result
163    /// for the same input.
164    ///
165    /// Cluster 0.24: when true, the follower replica detecting
166    /// an orphaned stream re-invokes from the cached payload
167    /// instead of writing a terminal "leader died" frame. See
168    /// ADR-024.
169    fn is_idempotent(&self) -> bool {
170        false
171    }
172}
173
174/// **TEST FIXTURE — DO NOT WIRE INTO PRODUCTION.**
175///
176/// `EchoHandler` is an unauthenticated, in-memory `A2aHandler` used by
177/// the crate's own integration tests, the conformance suite, and the
178/// `cargo klieo a2a-conformance` CLI. It accepts every inbound
179/// `SendMessage` regardless of caller identity, advertises empty
180/// `securitySchemes` on its agent card, and stores tasks in process
181/// memory only.
182///
183/// Wiring this handler into an `A2aServer` outside a test context is a
184/// security bug — any caller reachable on the transport can submit
185/// tasks that are recorded as `Completed`.
186///
187/// Available only when the `test-fixtures` Cargo feature is enabled
188/// (or in `cfg(test)` builds of `klieo-a2a` itself).
189#[cfg(any(test, feature = "test-fixtures"))]
190#[derive(Default)]
191pub struct EchoHandler {
192    tasks: Mutex<HashMap<String, Task>>,
193}
194
195#[cfg(any(test, feature = "test-fixtures"))]
196#[async_trait]
197impl A2aHandler for EchoHandler {
198    async fn send_message(
199        &self,
200        _ctx: &RequestContext,
201        params: SendMessageParams,
202    ) -> Result<SendMessageResult, A2aError> {
203        let task_id = format!("task-{}", Uuid::new_v4());
204        let context_id = params
205            .message
206            .contextId
207            .clone()
208            .unwrap_or_else(|| format!("ctx-{}", Uuid::new_v4()));
209        let response = Message {
210            messageId: format!("msg-{}", Uuid::new_v4()),
211            contextId: Some(context_id.clone()),
212            taskId: Some(task_id.clone()),
213            role: Role::Agent,
214            parts: params.message.parts.clone(),
215            metadata: None,
216            extensions: vec![],
217            referenceTaskIds: vec![],
218        };
219        let task = Task {
220            id: task_id.clone(),
221            contextId: context_id,
222            status: TaskStatus::Completed,
223            artifacts: vec![],
224            history: vec![params.message, response],
225            metadata: None,
226        };
227        self.tasks
228            .lock()
229            .map_err(|_| A2aError::Server("mutex poisoned".into()))?
230            .insert(task_id.clone(), task.clone());
231        Ok(SendMessageResult::Task(task))
232    }
233
234    async fn get_task(
235        &self,
236        _ctx: &RequestContext,
237        params: GetTaskParams,
238    ) -> Result<Task, A2aError> {
239        self.tasks
240            .lock()
241            .map_err(|_| A2aError::Server("mutex poisoned".into()))?
242            .get(&params.id)
243            .cloned()
244            .ok_or(A2aError::TaskNotFound(params.id))
245    }
246
247    async fn list_tasks(
248        &self,
249        _ctx: &RequestContext,
250        params: ListTasksParams,
251    ) -> Result<ListTasksResult, A2aError> {
252        let guard = self
253            .tasks
254            .lock()
255            .map_err(|_| A2aError::Server("mutex poisoned".into()))?;
256        let tasks: Vec<Task> = guard
257            .values()
258            .filter(|t| match &params.contextId {
259                Some(ctx) => &t.contextId == ctx,
260                None => true,
261            })
262            .cloned()
263            .collect();
264        Ok(ListTasksResult {
265            tasks,
266            nextCursor: None,
267        })
268    }
269
270    async fn cancel_task(
271        &self,
272        _ctx: &RequestContext,
273        params: CancelTaskParams,
274    ) -> Result<Task, A2aError> {
275        let mut guard = self
276            .tasks
277            .lock()
278            .map_err(|_| A2aError::Server("mutex poisoned".into()))?;
279        let task = guard
280            .get_mut(&params.id)
281            .ok_or_else(|| A2aError::TaskNotFound(params.id.clone()))?;
282        task.status = TaskStatus::Canceled;
283        Ok(task.clone())
284    }
285
286    async fn get_extended_agent_card(
287        &self,
288        _ctx: &RequestContext,
289        _params: GetExtendedAgentCardParams,
290    ) -> Result<AgentCard, A2aError> {
291        Ok(Self::static_card())
292    }
293
294    async fn get_agent_card(&self, _ctx: &RequestContext) -> Result<AgentCard, A2aError> {
295        Ok(Self::static_card())
296    }
297}
298
299#[cfg(any(test, feature = "test-fixtures"))]
300impl EchoHandler {
301    /// Shared card body for [`A2aHandler::get_extended_agent_card`] and
302    /// [`A2aHandler::get_agent_card`]. `EchoHandler` has no auth-gated
303    /// extra detail to withhold from anonymous callers, so both methods
304    /// return the identical, fully-public card.
305    fn static_card() -> AgentCard {
306        AgentCard {
307            id: "echo".into(),
308            name: "Echo Agent".into(),
309            description: Some("Echoes incoming messages as completed tasks.".into()),
310            provider: None,
311            capabilities: AgentCapabilities {
312                streaming: false,
313                pushNotifications: false,
314                stateTransitionHistory: false,
315            },
316            skills: vec![],
317            interfaces: vec![],
318            securitySchemes: serde_json::Map::new(),
319            security: vec![],
320            extensions: vec![],
321            signature: None,
322        }
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::envelope::A2aHeaders;
330    use crate::types::{
331        CancelTaskParams, GetExtendedAgentCardParams, GetTaskParams, Message, Part, Role,
332        SendMessageParams, TaskStatus,
333    };
334
335    struct AlwaysDefault;
336    #[async_trait::async_trait]
337    impl A2aHandler for AlwaysDefault {}
338
339    fn anon_ctx() -> RequestContext {
340        RequestContext::new(
341            A2aHeaders::decode_from(&klieo_core::Headers::new()),
342            Some(klieo_auth_common::Identity::anonymous()),
343        )
344    }
345
346    #[tokio::test]
347    async fn default_handler_returns_method_not_found() {
348        let h = AlwaysDefault;
349        let err = h
350            .send_message(
351                &anon_ctx(),
352                SendMessageParams {
353                    message: Message {
354                        messageId: "m".into(),
355                        contextId: None,
356                        taskId: None,
357                        role: Role::User,
358                        parts: vec![],
359                        metadata: None,
360                        extensions: vec![],
361                        referenceTaskIds: vec![],
362                    },
363                    configuration: None,
364                },
365            )
366            .await
367            .unwrap_err();
368        assert!(matches!(err, A2aError::MethodNotFound(_)));
369    }
370
371    #[tokio::test]
372    async fn echo_handler_send_message_returns_completed_task() {
373        let h = EchoHandler::default();
374        let msg = Message {
375            messageId: "m-1".into(),
376            contextId: Some("c-1".into()),
377            taskId: None,
378            role: Role::User,
379            parts: vec![Part::Text {
380                content: "hi".into(),
381                metadata: None,
382                mediaType: None,
383                filename: None,
384            }],
385            metadata: None,
386            extensions: vec![],
387            referenceTaskIds: vec![],
388        };
389        let result = h
390            .send_message(
391                &anon_ctx(),
392                SendMessageParams {
393                    message: msg,
394                    configuration: None,
395                },
396            )
397            .await
398            .unwrap();
399        match result {
400            crate::types::SendMessageResult::Task(t) => {
401                assert!(matches!(t.status, TaskStatus::Completed));
402                assert_eq!(t.history.len(), 2);
403            }
404            _ => panic!("expected Task"),
405        }
406    }
407
408    #[tokio::test]
409    async fn echo_handler_get_task_recovers_stored_task() {
410        let h = EchoHandler::default();
411        let msg = Message {
412            messageId: "m-2".into(),
413            contextId: Some("c-2".into()),
414            taskId: None,
415            role: Role::User,
416            parts: vec![],
417            metadata: None,
418            extensions: vec![],
419            referenceTaskIds: vec![],
420        };
421        let result = h
422            .send_message(
423                &anon_ctx(),
424                SendMessageParams {
425                    message: msg,
426                    configuration: None,
427                },
428            )
429            .await
430            .unwrap();
431        let task_id = match result {
432            crate::types::SendMessageResult::Task(t) => t.id,
433            _ => panic!(),
434        };
435        let recovered = h
436            .get_task(
437                &anon_ctx(),
438                GetTaskParams {
439                    id: task_id.clone(),
440                    historyLength: None,
441                },
442            )
443            .await
444            .unwrap();
445        assert_eq!(recovered.id, task_id);
446    }
447
448    #[tokio::test]
449    async fn echo_handler_cancel_task_marks_canceled() {
450        let h = EchoHandler::default();
451        let msg = Message {
452            messageId: "m-3".into(),
453            contextId: Some("c-3".into()),
454            taskId: None,
455            role: Role::User,
456            parts: vec![],
457            metadata: None,
458            extensions: vec![],
459            referenceTaskIds: vec![],
460        };
461        let task_id = match h
462            .send_message(
463                &anon_ctx(),
464                SendMessageParams {
465                    message: msg,
466                    configuration: None,
467                },
468            )
469            .await
470            .unwrap()
471        {
472            crate::types::SendMessageResult::Task(t) => t.id,
473            _ => panic!(),
474        };
475        let cancelled = h
476            .cancel_task(&anon_ctx(), CancelTaskParams { id: task_id })
477            .await
478            .unwrap();
479        assert!(matches!(cancelled.status, TaskStatus::Canceled));
480    }
481
482    #[test]
483    fn echo_handler_default_is_not_idempotent() {
484        let h = EchoHandler::default();
485        assert!(!h.is_idempotent());
486    }
487
488    #[test]
489    fn always_default_handler_is_not_idempotent() {
490        let h = AlwaysDefault;
491        assert!(!h.is_idempotent());
492    }
493
494    #[tokio::test]
495    async fn echo_handler_extended_agent_card_returns_static() {
496        let h = EchoHandler::default();
497        let card = h
498            .get_extended_agent_card(&anon_ctx(), GetExtendedAgentCardParams::default())
499            .await
500            .unwrap();
501        assert_eq!(card.id, "echo");
502    }
503
504    #[tokio::test]
505    async fn echo_handler_get_agent_card_returns_static() {
506        let h = EchoHandler::default();
507        let card = h.get_agent_card(&anon_ctx()).await.unwrap();
508        assert_eq!(card.id, "echo");
509    }
510
511    #[tokio::test]
512    async fn default_handler_get_agent_card_returns_method_not_found() {
513        let h = AlwaysDefault;
514        let err = h.get_agent_card(&anon_ctx()).await.unwrap_err();
515        assert!(matches!(err, A2aError::MethodNotFound(_)));
516    }
517}