a2a_protocol_server/handler/lifecycle/
subscribe.rs1use std::collections::HashMap;
9use std::time::Instant;
10
11use a2a_protocol_types::params::TaskIdParams;
12use a2a_protocol_types::task::TaskId;
13
14use a2a_protocol_types::events::{StreamResponse, TaskStatusUpdateEvent};
15
16use crate::error::{ServerError, ServerResult};
17use crate::streaming::{InMemoryQueueReader, Reattached};
18
19use super::super::helpers::build_call_context;
20use super::super::RequestHandler;
21
22impl RequestHandler {
23 fn subscribe_reattach_hook(&self, task_id: TaskId) -> crate::streaming::ReattachFn {
46 let queues = self.event_queue_manager.clone();
47 let store = std::sync::Arc::clone(&self.task_store);
48 let interval = self.limits.subscribe_reattach_interval;
49 let max_idle = self.limits.subscribe_max_idle;
50
51 std::sync::Arc::new(move || {
52 let (queues, store, task_id) = (queues.clone(), store.clone(), task_id.clone());
53 Box::pin(async move {
54 let deadline = tokio::time::Instant::now() + max_idle;
55 loop {
56 match store.get(&task_id).await {
57 Ok(Some(t)) if t.status.state.is_terminal() => {
62 return Reattached::Final(StreamResponse::StatusUpdate(
63 TaskStatusUpdateEvent {
64 task_id: t.id.clone(),
65 context_id: t.context_id.clone(),
66 status: t.status,
67 metadata: None,
68 },
69 ));
70 }
71 Ok(None) => return Reattached::End,
73 Ok(Some(_)) => {}
74 Err(_e) => {
78 trace_warn!(
79 task_id = %task_id,
80 "subscribe reattach: task store read failed"
81 );
82 }
83 }
84
85 if let Some(rx) = queues.raw_subscribe(&task_id).await {
86 return Reattached::Channel(rx);
87 }
88
89 if tokio::time::Instant::now() >= deadline {
94 trace_warn!(
95 task_id = %task_id,
96 "subscribe reattach: task still non-terminal after the idle bound; \
97 ending the stream (client may resubscribe)"
98 );
99 return Reattached::End;
100 }
101 tokio::time::sleep(interval).await;
102 }
103 }) as std::pin::Pin<Box<dyn std::future::Future<Output = _> + Send>>
104 })
105 }
106
107 pub async fn on_resubscribe(
113 &self,
114 params: TaskIdParams,
115 headers: Option<&HashMap<String, String>>,
116 ) -> ServerResult<InMemoryQueueReader> {
117 let start = Instant::now();
118 trace_info!(method = "SubscribeToTask", task_id = %params.id, "handling resubscribe");
119 self.metrics.on_request("SubscribeToTask");
120
121 let tenant = self
122 .resolve_tenant("SubscribeToTask", headers, params.tenant.as_deref())
123 .await?;
124 let result: ServerResult<_> = crate::store::tenant::TenantContext::scope(
128 tenant,
129 Box::pin(async {
130 let call_ctx = build_call_context("SubscribeToTask", headers);
131 self.interceptors.run_before(&call_ctx).await?;
132 self.ensure_required_extensions(&call_ctx)?;
135
136 self.ensure_streaming_supported()?;
140
141 let task_id = TaskId::new(¶ms.id);
142
143 let task = self
145 .task_store
146 .get(&task_id)
147 .await?
148 .ok_or_else(|| ServerError::TaskNotFound(task_id.clone()))?;
149
150 if task.status.state.is_terminal() {
153 return Err(ServerError::UnsupportedOperation(format!(
154 "task {} is in terminal state '{}' and cannot be subscribed to",
155 task_id, task.status.state
156 )));
157 }
158
159 let snapshot = a2a_protocol_types::events::StreamResponse::Task(task);
162 let reader = self
163 .event_queue_manager
164 .subscribe_with_snapshot(&task_id, snapshot.clone())
165 .await
166 .unwrap_or_else(|| InMemoryQueueReader::snapshot_then_end(snapshot))
173 .with_reattach(self.subscribe_reattach_hook(task_id.clone()));
174
175 self.interceptors.run_after(&call_ctx).await?;
176 Ok(reader)
177 }),
178 )
179 .await;
180
181 let elapsed = start.elapsed();
182 match &result {
183 Ok(_) => {
184 self.metrics.on_response("SubscribeToTask");
185 self.metrics.on_latency("SubscribeToTask", elapsed);
186 }
187 Err(e) => {
188 self.metrics.on_error("SubscribeToTask", e.metric_label());
189 self.metrics.on_latency("SubscribeToTask", elapsed);
190 }
191 }
192 result
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use a2a_protocol_types::params::TaskIdParams;
199
200 use crate::agent_executor;
201 use crate::builder::RequestHandlerBuilder;
202 use crate::error::ServerError;
203
204 struct DummyExecutor;
205 agent_executor!(DummyExecutor, |_ctx, _queue| async { Ok(()) });
206
207 #[tokio::test]
208 async fn resubscribe_task_not_found_returns_error() {
209 let handler = RequestHandlerBuilder::new(DummyExecutor).build().unwrap();
210 let params = TaskIdParams {
211 tenant: None,
212 id: "nonexistent-task".to_owned(),
213 };
214 let result = handler.on_resubscribe(params, None).await;
215 assert!(
216 matches!(result, Err(ServerError::TaskNotFound(_))),
217 "expected TaskNotFound for missing task, got: {result:?}"
218 );
219 }
220
221 #[tokio::test]
222 async fn resubscribe_terminal_task_returns_unsupported_operation() {
223 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
225
226 let handler = RequestHandlerBuilder::new(DummyExecutor).build().unwrap();
227 let task = Task {
228 id: TaskId::new("t-resub-1"),
229 context_id: ContextId::new("ctx-1"),
230 status: TaskStatus::new(TaskState::Completed),
231 history: None,
232 artifacts: None,
233 metadata: None,
234 };
235 handler.task_store.save(&task).await.unwrap();
236
237 let params = TaskIdParams {
238 tenant: None,
239 id: "t-resub-1".to_owned(),
240 };
241 let result = handler.on_resubscribe(params, None).await;
242 assert!(
243 matches!(result, Err(ServerError::UnsupportedOperation(ref msg)) if msg.contains("terminal")),
244 "expected UnsupportedOperation for terminal task, got: {result:?}"
245 );
246 }
247
248 #[tokio::test]
259 #[allow(clippy::too_many_lines)] async fn resubscribe_nonterminal_no_queue_waits_for_the_terminal_state() {
261 use crate::streaming::event_queue::EventQueueReader as _;
262 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
263
264 let handler = std::sync::Arc::new(
265 RequestHandlerBuilder::new(DummyExecutor)
266 .with_handler_limits(
267 crate::handler::HandlerLimits::default()
268 .with_subscribe_reattach_interval(std::time::Duration::from_millis(10))
269 .with_subscribe_max_idle(std::time::Duration::from_secs(10)),
270 )
271 .build()
272 .unwrap(),
273 );
274 let mut task = Task {
275 id: TaskId::new("t-resub-nonterminal"),
276 context_id: ContextId::new("ctx-1"),
277 status: TaskStatus::new(TaskState::Working),
278 history: None,
279 artifacts: None,
280 metadata: None,
281 };
282 handler.task_store.save(&task).await.unwrap();
283
284 let params = TaskIdParams {
285 tenant: None,
286 id: "t-resub-nonterminal".to_owned(),
287 };
288 let mut reader = handler
289 .on_resubscribe(params, None)
290 .await
291 .expect("resubscribe to a queueless non-terminal task must serve a snapshot stream");
292
293 let first = reader
295 .read()
296 .await
297 .expect("stream must yield the snapshot")
298 .expect("snapshot must not be an error");
299 match first {
300 a2a_protocol_types::events::StreamResponse::Task(t) => {
301 assert_eq!(t.id.0.as_str(), "t-resub-nonterminal");
302 assert_eq!(t.status.state, TaskState::Working);
303 }
304 other => panic!("expected Task snapshot first, got: {other:?}"),
305 }
306
307 let still_open =
309 tokio::time::timeout(std::time::Duration::from_millis(150), reader.read()).await;
310 assert!(
311 still_open.is_err(),
312 "stream ended while the task was still Working — §3.1.6 requires it \
313 to run until a terminal state, got: {still_open:?}"
314 );
315
316 task.status = TaskStatus::new(TaskState::Completed);
321 handler.task_store.save(&task).await.unwrap();
322
323 let final_frame = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read())
324 .await
325 .expect("stream must report the terminal state promptly")
326 .expect("expected a final frame, got EOF")
327 .expect("final frame must not be an error");
328 match final_frame {
329 a2a_protocol_types::events::StreamResponse::StatusUpdate(u) => {
330 assert_eq!(u.status.state, TaskState::Completed);
331 assert_eq!(u.task_id.0.as_str(), "t-resub-nonterminal");
332 }
333 other => panic!("expected a terminal StatusUpdate, got: {other:?}"),
334 }
335
336 let ended = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read())
337 .await
338 .expect("stream must end after the terminal frame");
339 assert!(ended.is_none(), "expected clean EOF, got: {ended:?}");
340 }
341
342 #[tokio::test]
348 async fn resubscribe_gives_up_after_the_idle_bound() {
349 use crate::streaming::event_queue::EventQueueReader as _;
350 use a2a_protocol_types::task::{ContextId, Task, TaskId, TaskState, TaskStatus};
351
352 let handler = RequestHandlerBuilder::new(DummyExecutor)
353 .with_handler_limits(
354 crate::handler::HandlerLimits::default()
355 .with_subscribe_reattach_interval(std::time::Duration::from_millis(5))
356 .with_subscribe_max_idle(std::time::Duration::from_millis(50)),
357 )
358 .build()
359 .unwrap();
360 let task = Task {
361 id: TaskId::new("t-parked"),
362 context_id: ContextId::new("ctx-1"),
363 status: TaskStatus::new(TaskState::InputRequired),
364 history: None,
365 artifacts: None,
366 metadata: None,
367 };
368 handler.task_store.save(&task).await.unwrap();
369
370 let mut reader = handler
371 .on_resubscribe(
372 TaskIdParams {
373 tenant: None,
374 id: "t-parked".to_owned(),
375 },
376 None,
377 )
378 .await
379 .expect("resubscribe must succeed");
380 let _snapshot = reader.read().await.expect("snapshot");
381
382 let ended = tokio::time::timeout(std::time::Duration::from_secs(5), reader.read())
383 .await
384 .expect("the idle bound must end the stream rather than hang");
385 assert!(ended.is_none(), "expected clean EOF, got: {ended:?}");
386 }
387
388 #[tokio::test]
389 async fn resubscribe_success_returns_reader() {
390 use a2a_protocol_types::message::{Message, MessageId, MessageRole, Part};
394 use a2a_protocol_types::params::MessageSendParams;
395 use a2a_protocol_types::task::ContextId;
396
397 use crate::handler::SendMessageResult;
398
399 let handler = RequestHandlerBuilder::new(DummyExecutor).build().unwrap();
400
401 let params = MessageSendParams {
403 message: Message {
404 id: MessageId::new("msg-resub"),
405 role: MessageRole::User,
406 parts: vec![Part::text("hello")],
407 context_id: Some(ContextId::new("ctx-resub")),
408 task_id: None,
409 reference_task_ids: None,
410 extensions: None,
411 metadata: None,
412 },
413 configuration: None,
414 metadata: None,
415 tenant: None,
416 };
417
418 let result = handler.on_send_message(params, true, None).await;
419 assert!(matches!(result, Ok(SendMessageResult::Stream(_))));
420
421 let tasks = handler
423 .task_store
424 .list(&a2a_protocol_types::params::ListTasksParams::default())
425 .await
426 .unwrap();
427 assert!(!tasks.tasks.is_empty(), "should have at least one task");
428
429 let task_id = tasks.tasks[0].id.0.clone();
430
431 let sub_params = TaskIdParams {
433 tenant: None,
434 id: task_id,
435 };
436 let sub_result = handler.on_resubscribe(sub_params, None).await;
437 match &sub_result {
441 Ok(_) | Err(ServerError::Internal(_)) => {} Err(e) => panic!("unexpected error: {e:?}"),
443 }
444 }
445
446 #[tokio::test]
447 async fn resubscribe_with_tenant() {
448 let handler = RequestHandlerBuilder::new(DummyExecutor).build().unwrap();
450 let params = TaskIdParams {
451 tenant: Some("test-tenant".to_string()),
452 id: "nonexistent-task".to_owned(),
453 };
454 let result = handler.on_resubscribe(params, None).await;
455 assert!(result.is_err(), "resubscribe for missing task should fail");
456 }
457
458 #[tokio::test]
459 async fn resubscribe_with_headers() {
460 let handler = RequestHandlerBuilder::new(DummyExecutor).build().unwrap();
462 let params = TaskIdParams {
463 tenant: None,
464 id: "nonexistent-task".to_owned(),
465 };
466 let mut headers = std::collections::HashMap::new();
467 headers.insert("authorization".to_string(), "Bearer tok".to_string());
468 let result = handler.on_resubscribe(params, Some(&headers)).await;
469 assert!(result.is_err());
470 }
471
472 #[tokio::test]
473 async fn resubscribe_error_path_records_error_metrics() {
474 use crate::call_context::CallContext;
476 use crate::interceptor::ServerInterceptor;
477 use std::future::Future;
478 use std::pin::Pin;
479
480 struct FailInterceptor;
481 impl ServerInterceptor for FailInterceptor {
482 fn before<'a>(
483 &'a self,
484 _ctx: &'a CallContext,
485 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
486 {
487 Box::pin(async {
488 Err(a2a_protocol_types::error::A2aError::internal(
489 "forced failure",
490 ))
491 })
492 }
493 fn after<'a>(
494 &'a self,
495 _ctx: &'a CallContext,
496 ) -> Pin<Box<dyn Future<Output = a2a_protocol_types::error::A2aResult<()>> + Send + 'a>>
497 {
498 Box::pin(async { Ok(()) })
499 }
500 }
501
502 let handler = RequestHandlerBuilder::new(DummyExecutor)
503 .with_interceptor(FailInterceptor)
504 .build()
505 .unwrap();
506
507 let params = TaskIdParams {
508 tenant: None,
509 id: "t-resub-fail".to_owned(),
510 };
511 let result = handler.on_resubscribe(params, None).await;
512 assert!(
513 result.is_err(),
514 "resubscribe should fail when interceptor rejects"
515 );
516 }
517}