1use std::sync::Arc;
4
5use a2a::*;
6use a2a_client::auth::AuthInterceptor;
7use a2a_client::{A2AClient, A2AClientFactory, BoxStream};
8use clap::{Args, Parser, Subcommand, ValueEnum};
9use futures::StreamExt;
10use reqwest::{Client, RequestBuilder};
11use serde::Serialize;
12use thiserror::Error;
13
14#[derive(Debug, Clone, Parser, PartialEq, Eq)]
15#[command(name = "a2acli", version, about = "Standalone A2A client CLI")]
16pub struct Cli {
17 #[arg(long, global = true, default_value = "http://localhost:3000")]
19 pub base_url: String,
20
21 #[arg(long, global = true, value_enum)]
23 pub binding: Option<Binding>,
24
25 #[arg(long, global = true, env = "A2A_BEARER_TOKEN")]
27 pub bearer_token: Option<String>,
28
29 #[arg(long = "header", global = true, value_parser = parse_header)]
31 pub headers: Vec<HeaderArg>,
32
33 #[arg(long, global = true)]
35 pub tenant: Option<String>,
36
37 #[arg(long, global = true)]
39 pub compact: bool,
40
41 #[command(subcommand)]
42 pub command: Command,
43}
44
45#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
46pub enum Command {
47 Card,
49 ExtendedCard,
51 Send(MessageCommand),
53 Stream(MessageCommand),
55 GetTask(TaskLookupCommand),
57 ListTasks(ListTasksCommand),
59 CancelTask(TaskIdCommand),
61 Subscribe(TaskIdCommand),
63 PushConfig {
65 #[command(subcommand)]
66 command: PushConfigCommand,
67 },
68}
69
70#[derive(Debug, Clone, Args, PartialEq, Eq)]
71pub struct MessageCommand {
72 pub text: String,
74
75 #[arg(long)]
77 pub context_id: Option<String>,
78
79 #[arg(long)]
81 pub task_id: Option<String>,
82
83 #[arg(long)]
85 pub history_length: Option<i32>,
86
87 #[arg(long = "accept-output")]
89 pub accepted_output_modes: Vec<String>,
90
91 #[arg(long)]
93 pub return_immediately: bool,
94}
95
96#[derive(Debug, Clone, Args, PartialEq, Eq)]
97pub struct TaskLookupCommand {
98 pub id: String,
100
101 #[arg(long)]
103 pub history_length: Option<i32>,
104}
105
106#[derive(Debug, Clone, Args, PartialEq, Eq)]
107pub struct ListTasksCommand {
108 #[arg(long)]
110 pub context_id: Option<String>,
111
112 #[arg(long, value_enum)]
114 pub status: Option<TaskStateArg>,
115
116 #[arg(long)]
118 pub page_size: Option<i32>,
119
120 #[arg(long)]
122 pub page_token: Option<String>,
123
124 #[arg(long)]
126 pub history_length: Option<i32>,
127
128 #[arg(long)]
130 pub include_artifacts: bool,
131}
132
133#[derive(Debug, Clone, Args, PartialEq, Eq)]
134pub struct TaskIdCommand {
135 pub id: String,
137}
138
139#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
140pub enum PushConfigCommand {
141 Create(CreatePushConfigCommand),
143 Get(PushConfigIdCommand),
145 List(ListPushConfigsCommand),
147 Delete(PushConfigIdCommand),
149}
150
151#[derive(Debug, Clone, Args, PartialEq, Eq)]
152pub struct CreatePushConfigCommand {
153 pub task_id: String,
155
156 pub url: String,
158
159 #[arg(long = "config-id")]
161 pub config_id: Option<String>,
162
163 #[arg(long)]
165 pub token: Option<String>,
166
167 #[arg(long = "auth-scheme")]
169 pub auth_scheme: Option<String>,
170
171 #[arg(long = "auth-credentials")]
173 pub auth_credentials: Option<String>,
174}
175
176#[derive(Debug, Clone, Args, PartialEq, Eq)]
177pub struct PushConfigIdCommand {
178 pub task_id: String,
180
181 pub id: String,
183}
184
185#[derive(Debug, Clone, Args, PartialEq, Eq)]
186pub struct ListPushConfigsCommand {
187 pub task_id: String,
189
190 #[arg(long)]
192 pub page_size: Option<i32>,
193
194 #[arg(long)]
196 pub page_token: Option<String>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub struct HeaderArg {
201 pub name: String,
202 pub value: String,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
206pub enum Binding {
207 Jsonrpc,
208 HttpJson,
209}
210
211impl Binding {
212 fn protocol(self) -> &'static str {
213 match self {
214 Binding::Jsonrpc => TRANSPORT_PROTOCOL_JSONRPC,
215 Binding::HttpJson => TRANSPORT_PROTOCOL_HTTP_JSON,
216 }
217 }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
221pub enum TaskStateArg {
222 Unspecified,
223 Submitted,
224 Working,
225 Completed,
226 Failed,
227 Canceled,
228 InputRequired,
229 Rejected,
230 AuthRequired,
231}
232
233impl From<TaskStateArg> for TaskState {
234 fn from(value: TaskStateArg) -> Self {
235 match value {
236 TaskStateArg::Unspecified => TaskState::Unspecified,
237 TaskStateArg::Submitted => TaskState::Submitted,
238 TaskStateArg::Working => TaskState::Working,
239 TaskStateArg::Completed => TaskState::Completed,
240 TaskStateArg::Failed => TaskState::Failed,
241 TaskStateArg::Canceled => TaskState::Canceled,
242 TaskStateArg::InputRequired => TaskState::InputRequired,
243 TaskStateArg::Rejected => TaskState::Rejected,
244 TaskStateArg::AuthRequired => TaskState::AuthRequired,
245 }
246 }
247}
248
249#[derive(Debug, Error)]
250pub enum CliError {
251 #[error(transparent)]
252 A2A(#[from] A2AError),
253 #[error("http request failed: {0}")]
254 Http(#[from] reqwest::Error),
255 #[error("failed to serialize output: {0}")]
256 Json(#[from] serde_json::Error),
257 #[error("invalid input: {0}")]
258 InvalidInput(String),
259}
260
261pub async fn run(cli: Cli) -> Result<(), CliError> {
262 match &cli.command {
263 Command::Card => {
264 let card = resolve_agent_card(&cli).await?;
265 print_json(&card, cli.compact)?;
266 }
267 Command::ExtendedCard => {
268 let client = resolve_client(&cli).await?;
269 let result = client
270 .get_extended_agent_card(&GetExtendedAgentCardRequest {
271 tenant: cli.tenant.clone(),
272 })
273 .await;
274 let card = finish_client_call(client, result).await?;
275 print_json(&card, cli.compact)?;
276 }
277 Command::Send(command) => {
278 let request = build_send_message_request(command, cli.tenant.clone());
279 let client = resolve_client(&cli).await?;
280 let result = client.send_message(&request).await;
281 let response = finish_client_call(client, result).await?;
282 print_json(&response, cli.compact)?;
283 }
284 Command::Stream(command) => {
285 let client = resolve_client(&cli).await?;
286 let request = build_send_message_request(command, cli.tenant.clone());
287 let stream = client.send_streaming_message(&request).await?;
288 consume_stream(client, stream, cli.compact).await?;
289 }
290 Command::GetTask(command) => {
291 let client = resolve_client(&cli).await?;
292 let result = client
293 .get_task(&GetTaskRequest {
294 id: command.id.clone(),
295 history_length: command.history_length,
296 tenant: cli.tenant.clone(),
297 })
298 .await;
299 let task = finish_client_call(client, result).await?;
300 print_json(&task, cli.compact)?;
301 }
302 Command::ListTasks(command) => {
303 let client = resolve_client(&cli).await?;
304 let result = client
305 .list_tasks(&ListTasksRequest {
306 context_id: command.context_id.clone(),
307 status: command.status.map(TaskState::from),
308 page_size: command.page_size,
309 page_token: command.page_token.clone(),
310 history_length: command.history_length,
311 status_timestamp_after: None,
312 include_artifacts: command.include_artifacts.then_some(true),
313 tenant: cli.tenant.clone(),
314 })
315 .await;
316 let response = finish_client_call(client, result).await?;
317 print_json(&response, cli.compact)?;
318 }
319 Command::CancelTask(command) => {
320 let client = resolve_client(&cli).await?;
321 let result = client
322 .cancel_task(&CancelTaskRequest {
323 id: command.id.clone(),
324 metadata: None,
325 tenant: cli.tenant.clone(),
326 })
327 .await;
328 let task = finish_client_call(client, result).await?;
329 print_json(&task, cli.compact)?;
330 }
331 Command::Subscribe(command) => {
332 let client = resolve_client(&cli).await?;
333 let stream = client
334 .subscribe_to_task(&SubscribeToTaskRequest {
335 id: command.id.clone(),
336 tenant: cli.tenant.clone(),
337 })
338 .await?;
339 consume_stream(client, stream, cli.compact).await?;
340 }
341 Command::PushConfig { command } => {
342 run_push_config_command(&cli, command).await?;
343 }
344 }
345
346 Ok(())
347}
348
349fn build_send_message_request(
350 command: &MessageCommand,
351 tenant: Option<String>,
352) -> SendMessageRequest {
353 let mut message = Message::new(Role::User, vec![Part::text(command.text.clone())]);
354 message.context_id = command.context_id.clone();
355 message.task_id = command.task_id.clone();
356
357 let configuration = if command.history_length.is_some()
358 || !command.accepted_output_modes.is_empty()
359 || command.return_immediately
360 {
361 Some(SendMessageConfiguration {
362 accepted_output_modes: (!command.accepted_output_modes.is_empty())
363 .then_some(command.accepted_output_modes.clone()),
364 task_push_notification_config: None,
365 history_length: command.history_length,
366 return_immediately: command.return_immediately.then_some(true),
367 })
368 } else {
369 None
370 };
371
372 SendMessageRequest {
373 message,
374 configuration,
375 metadata: None,
376 tenant,
377 }
378}
379
380fn build_push_notification_config(
381 command: &CreatePushConfigCommand,
382) -> Result<TaskPushNotificationConfig, CliError> {
383 if command.auth_credentials.is_some() && command.auth_scheme.is_none() {
384 return Err(CliError::InvalidInput(
385 "--auth-credentials requires --auth-scheme".to_string(),
386 ));
387 }
388
389 let authentication = command
390 .auth_scheme
391 .clone()
392 .map(|scheme| AuthenticationInfo {
393 scheme,
394 credentials: command.auth_credentials.clone(),
395 });
396
397 Ok(TaskPushNotificationConfig {
398 task_id: String::new(),
399 url: command.url.clone(),
400 id: command.config_id.clone(),
401 token: command.token.clone(),
402 authentication,
403 tenant: None,
404 })
405}
406
407async fn run_push_config_command(cli: &Cli, command: &PushConfigCommand) -> Result<(), CliError> {
408 match command {
409 PushConfigCommand::Create(command) => {
410 let client = resolve_client(cli).await?;
411 let mut config = build_push_notification_config(command)?;
412 config.task_id = command.task_id.clone();
413 config.tenant = cli.tenant.clone();
414 let result = client.create_push_config(&config).await;
415 let response = finish_client_call(client, result).await?;
416 print_json(&response, cli.compact)?;
417 }
418 PushConfigCommand::Get(command) => {
419 let client = resolve_client(cli).await?;
420 let result = client
421 .get_push_config(&GetTaskPushNotificationConfigRequest {
422 task_id: command.task_id.clone(),
423 id: command.id.clone(),
424 tenant: cli.tenant.clone(),
425 })
426 .await;
427 let response = finish_client_call(client, result).await?;
428 print_json(&response, cli.compact)?;
429 }
430 PushConfigCommand::List(command) => {
431 let client = resolve_client(cli).await?;
432 let result = client
433 .list_push_configs(&ListTaskPushNotificationConfigsRequest {
434 task_id: command.task_id.clone(),
435 page_size: command.page_size,
436 page_token: command.page_token.clone(),
437 tenant: cli.tenant.clone(),
438 })
439 .await;
440 let response = finish_client_call(client, result).await?;
441 print_json(&response, cli.compact)?;
442 }
443 PushConfigCommand::Delete(command) => {
444 let client = resolve_client(cli).await?;
445 let result = client
446 .delete_push_config(&DeleteTaskPushNotificationConfigRequest {
447 task_id: command.task_id.clone(),
448 id: command.id.clone(),
449 tenant: cli.tenant.clone(),
450 })
451 .await;
452 finish_client_call(client, result).await?;
453 print_json(
454 &serde_json::json!({
455 "deleted": true,
456 "taskId": command.task_id,
457 "id": command.id,
458 }),
459 cli.compact,
460 )?;
461 }
462 }
463
464 Ok(())
465}
466
467async fn resolve_client(cli: &Cli) -> Result<A2AClient<Box<dyn a2a_client::Transport>>, CliError> {
468 let card = resolve_agent_card(cli).await?;
469
470 let mut builder = A2AClientFactory::builder();
471 if let Some(binding) = cli.binding {
472 builder = builder.preferred_bindings(vec![binding.protocol().to_string()]);
473 }
474 if let Some(token) = &cli.bearer_token {
475 builder = builder.with_interceptor(Arc::new(AuthInterceptor::bearer(token.clone())));
476 }
477 for header in &cli.headers {
478 builder = builder.with_interceptor(Arc::new(AuthInterceptor::custom(
479 header.name.clone(),
480 header.value.clone(),
481 )));
482 }
483
484 let factory = builder.build();
485 Ok(factory.create_from_card(&card).await?)
486}
487
488async fn resolve_agent_card(cli: &Cli) -> Result<AgentCard, CliError> {
489 let url = format!(
490 "{}/.well-known/agent-card.json",
491 cli.base_url.trim_end_matches('/')
492 );
493 let client = Client::new();
494 let request = apply_request_auth(client.get(url), cli);
495 let response = request.send().await?.error_for_status()?;
496 Ok(response.json::<AgentCard>().await?)
497}
498
499fn apply_request_auth(mut request: RequestBuilder, cli: &Cli) -> RequestBuilder {
500 if let Some(token) = &cli.bearer_token {
501 request = request.bearer_auth(token);
502 }
503 for header in &cli.headers {
504 request = request.header(&header.name, &header.value);
505 }
506 request
507}
508
509fn print_json<T: Serialize>(value: &T, compact: bool) -> Result<(), CliError> {
510 if compact {
511 println!("{}", serde_json::to_string(value)?);
512 } else {
513 println!("{}", serde_json::to_string_pretty(value)?);
514 }
515 Ok(())
516}
517
518fn parse_header(input: &str) -> Result<HeaderArg, String> {
519 let (name, value) = input
520 .split_once(':')
521 .ok_or_else(|| "header must be in NAME:VALUE format".to_string())?;
522
523 let name = name.trim();
524 let value = value.trim();
525
526 if name.is_empty() {
527 return Err("header name cannot be empty".to_string());
528 }
529
530 Ok(HeaderArg {
531 name: name.to_string(),
532 value: value.to_string(),
533 })
534}
535
536async fn finish_client_call<T: a2a_client::Transport, V>(
537 client: A2AClient<T>,
538 result: Result<V, A2AError>,
539) -> Result<V, CliError> {
540 match result {
541 Ok(value) => {
542 client.destroy().await?;
543 Ok(value)
544 }
545 Err(error) => {
546 let _ = client.destroy().await;
547 Err(error.into())
548 }
549 }
550}
551
552async fn consume_stream<T: a2a_client::Transport, V: Serialize>(
553 client: A2AClient<T>,
554 mut stream: BoxStream<'static, Result<V, A2AError>>,
555 compact: bool,
556) -> Result<(), CliError> {
557 loop {
558 match stream.next().await {
559 Some(Ok(value)) => {
560 if let Err(error) = print_json(&value, compact) {
561 let _ = client.destroy().await;
562 return Err(error);
563 }
564 }
565 Some(Err(error)) => {
566 let _ = client.destroy().await;
567 return Err(error.into());
568 }
569 None => {
570 client.destroy().await?;
571 return Ok(());
572 }
573 }
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use a2a_client::{ServiceParams, Transport};
581 use a2a_server::jsonrpc::jsonrpc_router;
582 use a2a_server::rest::rest_router;
583 use a2a_server::{
584 RequestHandler, ServiceParams as HandlerServiceParams, WELL_KNOWN_AGENT_CARD_PATH,
585 };
586 use async_trait::async_trait;
587 use axum::routing::get;
588 use axum::{Json, Router};
589 use futures::stream;
590 use reqwest::header;
591 use serde::ser;
592 use std::collections::BTreeMap;
593 use std::sync::Mutex;
594 use tokio::net::TcpListener;
595
596 struct TestTransport {
597 destroy_error: Option<A2AError>,
598 }
599
600 #[async_trait]
601 impl Transport for TestTransport {
602 async fn send_message(
603 &self,
604 _params: &ServiceParams,
605 _req: &SendMessageRequest,
606 ) -> Result<SendMessageResponse, A2AError> {
607 Err(A2AError::unsupported_operation("unused"))
608 }
609
610 async fn send_streaming_message(
611 &self,
612 _params: &ServiceParams,
613 _req: &SendMessageRequest,
614 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
615 Err(A2AError::unsupported_operation("unused"))
616 }
617
618 async fn get_task(
619 &self,
620 _params: &ServiceParams,
621 _req: &GetTaskRequest,
622 ) -> Result<Task, A2AError> {
623 Err(A2AError::unsupported_operation("unused"))
624 }
625
626 async fn list_tasks(
627 &self,
628 _params: &ServiceParams,
629 _req: &ListTasksRequest,
630 ) -> Result<ListTasksResponse, A2AError> {
631 Err(A2AError::unsupported_operation("unused"))
632 }
633
634 async fn cancel_task(
635 &self,
636 _params: &ServiceParams,
637 _req: &CancelTaskRequest,
638 ) -> Result<Task, A2AError> {
639 Err(A2AError::unsupported_operation("unused"))
640 }
641
642 async fn subscribe_to_task(
643 &self,
644 _params: &ServiceParams,
645 _req: &SubscribeToTaskRequest,
646 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
647 Err(A2AError::unsupported_operation("unused"))
648 }
649
650 async fn create_push_config(
651 &self,
652 _params: &ServiceParams,
653 _req: &TaskPushNotificationConfig,
654 ) -> Result<TaskPushNotificationConfig, A2AError> {
655 Err(A2AError::unsupported_operation("unused"))
656 }
657
658 async fn get_push_config(
659 &self,
660 _params: &ServiceParams,
661 _req: &GetTaskPushNotificationConfigRequest,
662 ) -> Result<TaskPushNotificationConfig, A2AError> {
663 Err(A2AError::unsupported_operation("unused"))
664 }
665
666 async fn list_push_configs(
667 &self,
668 _params: &ServiceParams,
669 _req: &ListTaskPushNotificationConfigsRequest,
670 ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
671 Err(A2AError::unsupported_operation("unused"))
672 }
673
674 async fn delete_push_config(
675 &self,
676 _params: &ServiceParams,
677 _req: &DeleteTaskPushNotificationConfigRequest,
678 ) -> Result<(), A2AError> {
679 Err(A2AError::unsupported_operation("unused"))
680 }
681
682 async fn get_extended_agent_card(
683 &self,
684 _params: &ServiceParams,
685 _req: &GetExtendedAgentCardRequest,
686 ) -> Result<AgentCard, A2AError> {
687 Err(A2AError::unsupported_operation("unused"))
688 }
689
690 async fn destroy(&self) -> Result<(), A2AError> {
691 match &self.destroy_error {
692 Some(error) => Err(error.clone()),
693 None => Ok(()),
694 }
695 }
696 }
697
698 struct FailingSerialize;
699
700 impl Serialize for FailingSerialize {
701 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
702 where
703 S: serde::Serializer,
704 {
705 Err(ser::Error::custom("serialize failed"))
706 }
707 }
708
709 fn make_test_client(destroy_error: Option<A2AError>) -> A2AClient<TestTransport> {
710 A2AClient::new(TestTransport { destroy_error })
711 }
712
713 #[derive(Default)]
714 struct RunTestState {
715 tasks: Mutex<BTreeMap<String, Task>>,
716 push_configs: Mutex<BTreeMap<(String, String), TaskPushNotificationConfig>>,
717 }
718
719 struct RunTestHandler {
720 state: Arc<RunTestState>,
721 extended_card: AgentCard,
722 }
723
724 struct RunTestServer {
725 base_url: String,
726 handle: tokio::task::JoinHandle<()>,
727 }
728
729 impl Drop for RunTestServer {
730 fn drop(&mut self) {
731 self.handle.abort();
732 }
733 }
734
735 impl RunTestServer {
736 async fn spawn() -> Self {
737 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
738 let base_url = format!("http://{}", listener.local_addr().unwrap());
739 let state = Arc::new(RunTestState::default());
740
741 state.tasks.lock().unwrap().insert(
742 "task-1".to_string(),
743 make_fixture_task("task-1", "ctx-1", TaskState::Completed, "seeded result"),
744 );
745
746 let public_card = make_fixture_card(&base_url, "Fixture Agent");
747 let extended_card = make_fixture_card(&base_url, "Fixture Agent (extended)");
748 let handler = Arc::new(RunTestHandler {
749 state,
750 extended_card,
751 });
752
753 let card = public_card.clone();
754 let app = Router::new()
755 .route(
756 WELL_KNOWN_AGENT_CARD_PATH,
757 get(move || {
758 let card = card.clone();
759 async move { Json(card) }
760 }),
761 )
762 .nest("/jsonrpc", jsonrpc_router(handler.clone()))
763 .nest("/rest", rest_router(handler));
764
765 let handle = tokio::spawn(async move {
766 axum::serve(listener, app).await.unwrap();
767 });
768
769 RunTestServer { base_url, handle }
770 }
771 }
772
773 #[async_trait]
774 impl RequestHandler for RunTestHandler {
775 async fn send_message(
776 &self,
777 _params: &HandlerServiceParams,
778 req: SendMessageRequest,
779 ) -> Result<SendMessageResponse, A2AError> {
780 let task_id = req
781 .message
782 .task_id
783 .clone()
784 .unwrap_or_else(|| "task-send".to_string());
785 let context_id = req
786 .message
787 .context_id
788 .clone()
789 .unwrap_or_else(|| "ctx-send".to_string());
790 let text = req.message.text().unwrap_or_default();
791 if text == "send-error" {
792 return Err(A2AError::invalid_request("send failed"));
793 }
794
795 let task = make_fixture_task(
796 &task_id,
797 &context_id,
798 TaskState::Completed,
799 &format!("Echo: {text}"),
800 );
801 self.state
802 .tasks
803 .lock()
804 .unwrap()
805 .insert(task_id, task.clone());
806 Ok(SendMessageResponse::Task(task))
807 }
808
809 async fn send_streaming_message(
810 &self,
811 _params: &HandlerServiceParams,
812 req: SendMessageRequest,
813 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
814 let task_id = req
815 .message
816 .task_id
817 .clone()
818 .unwrap_or_else(|| "task-stream".to_string());
819 let context_id = req
820 .message
821 .context_id
822 .clone()
823 .unwrap_or_else(|| "ctx-stream".to_string());
824 let text = req.message.text().unwrap_or_default();
825 if text == "stream-error" {
826 return Ok(Box::pin(stream::once(async {
827 Err(A2AError::internal("stream failed"))
828 })));
829 }
830
831 let task = make_fixture_task(
832 &task_id,
833 &context_id,
834 TaskState::Completed,
835 &format!("Echo: {text}"),
836 );
837 self.state
838 .tasks
839 .lock()
840 .unwrap()
841 .insert(task_id.clone(), task.clone());
842
843 Ok(Box::pin(stream::iter(vec![
844 Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
845 task_id,
846 context_id,
847 status: TaskStatus {
848 state: TaskState::Working,
849 message: None,
850 timestamp: None,
851 },
852 metadata: None,
853 })),
854 Ok(StreamResponse::Task(task)),
855 ])))
856 }
857
858 async fn get_task(
859 &self,
860 _params: &HandlerServiceParams,
861 req: GetTaskRequest,
862 ) -> Result<Task, A2AError> {
863 self.state
864 .tasks
865 .lock()
866 .unwrap()
867 .get(&req.id)
868 .cloned()
869 .ok_or_else(|| A2AError::task_not_found(&req.id))
870 }
871
872 async fn list_tasks(
873 &self,
874 _params: &HandlerServiceParams,
875 req: ListTasksRequest,
876 ) -> Result<ListTasksResponse, A2AError> {
877 if req.context_id.as_deref() == Some("error") {
878 return Err(A2AError::invalid_params("list failed"));
879 }
880
881 let tasks = self
882 .state
883 .tasks
884 .lock()
885 .unwrap()
886 .values()
887 .filter(|task| {
888 req.context_id
889 .as_ref()
890 .map(|context_id| task.context_id == *context_id)
891 .unwrap_or(true)
892 })
893 .filter(|task| {
894 req.status
895 .as_ref()
896 .map(|status| task.status.state == *status)
897 .unwrap_or(true)
898 })
899 .cloned()
900 .collect();
901
902 Ok(ListTasksResponse {
903 tasks,
904 next_page_token: String::new(),
905 page_size: 0,
906 total_size: 0,
907 })
908 }
909
910 async fn cancel_task(
911 &self,
912 _params: &HandlerServiceParams,
913 req: CancelTaskRequest,
914 ) -> Result<Task, A2AError> {
915 let mut tasks = self.state.tasks.lock().unwrap();
916 let task = tasks
917 .get(&req.id)
918 .cloned()
919 .ok_or_else(|| A2AError::task_not_found(&req.id))?;
920 let canceled =
921 make_fixture_task(&task.id, &task.context_id, TaskState::Canceled, "canceled");
922 tasks.insert(req.id, canceled.clone());
923 Ok(canceled)
924 }
925
926 async fn subscribe_to_task(
927 &self,
928 _params: &HandlerServiceParams,
929 req: SubscribeToTaskRequest,
930 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
931 if req.id == "stream-error" {
932 return Ok(Box::pin(stream::once(async {
933 Err(A2AError::internal("stream failed"))
934 })));
935 }
936
937 let task = self
938 .state
939 .tasks
940 .lock()
941 .unwrap()
942 .get(&req.id)
943 .cloned()
944 .ok_or_else(|| A2AError::task_not_found(&req.id))?;
945
946 Ok(Box::pin(stream::iter(vec![
947 Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
948 task_id: req.id.clone(),
949 context_id: task.context_id.clone(),
950 status: TaskStatus {
951 state: TaskState::Working,
952 message: None,
953 timestamp: None,
954 },
955 metadata: None,
956 })),
957 Ok(StreamResponse::Task(task)),
958 ])))
959 }
960
961 async fn create_push_config(
962 &self,
963 _params: &HandlerServiceParams,
964 req: TaskPushNotificationConfig,
965 ) -> Result<TaskPushNotificationConfig, A2AError> {
966 if !self.state.tasks.lock().unwrap().contains_key(&req.task_id) {
967 return Err(A2AError::task_not_found(&req.task_id));
968 }
969
970 let config_id = req.id.clone().unwrap_or_else(|| "generated".to_string());
971 self.state
972 .push_configs
973 .lock()
974 .unwrap()
975 .insert((req.task_id.clone(), config_id), req.clone());
976 Ok(req)
977 }
978
979 async fn get_push_config(
980 &self,
981 _params: &HandlerServiceParams,
982 req: GetTaskPushNotificationConfigRequest,
983 ) -> Result<TaskPushNotificationConfig, A2AError> {
984 self.state
985 .push_configs
986 .lock()
987 .unwrap()
988 .get(&(req.task_id.clone(), req.id.clone()))
989 .cloned()
990 .ok_or_else(|| A2AError::task_not_found(&req.task_id))
991 }
992
993 async fn list_push_configs(
994 &self,
995 _params: &HandlerServiceParams,
996 req: ListTaskPushNotificationConfigsRequest,
997 ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
998 if req.task_id == "missing" {
999 return Err(A2AError::task_not_found(&req.task_id));
1000 }
1001
1002 let configs = self
1003 .state
1004 .push_configs
1005 .lock()
1006 .unwrap()
1007 .values()
1008 .filter(|config| config.task_id == req.task_id)
1009 .cloned()
1010 .collect();
1011
1012 Ok(ListTaskPushNotificationConfigsResponse {
1013 configs,
1014 next_page_token: None,
1015 })
1016 }
1017
1018 async fn delete_push_config(
1019 &self,
1020 _params: &HandlerServiceParams,
1021 req: DeleteTaskPushNotificationConfigRequest,
1022 ) -> Result<(), A2AError> {
1023 let deleted = self
1024 .state
1025 .push_configs
1026 .lock()
1027 .unwrap()
1028 .remove(&(req.task_id.clone(), req.id.clone()));
1029 if deleted.is_none() {
1030 return Err(A2AError::task_not_found(&req.task_id));
1031 }
1032 Ok(())
1033 }
1034
1035 async fn get_extended_agent_card(
1036 &self,
1037 _params: &HandlerServiceParams,
1038 req: GetExtendedAgentCardRequest,
1039 ) -> Result<AgentCard, A2AError> {
1040 if req.tenant.as_deref() == Some("error") {
1041 return Err(A2AError::unsupported_operation("extended card denied"));
1042 }
1043
1044 Ok(self.extended_card.clone())
1045 }
1046 }
1047
1048 fn make_fixture_card(base_url: &str, name: &str) -> AgentCard {
1049 AgentCard {
1050 name: name.to_string(),
1051 description: "CLI unit-test fixture".to_string(),
1052 version: VERSION.to_string(),
1053 supported_interfaces: vec![
1054 AgentInterface::new(format!("{base_url}/jsonrpc"), TRANSPORT_PROTOCOL_JSONRPC),
1055 AgentInterface::new(format!("{base_url}/rest"), TRANSPORT_PROTOCOL_HTTP_JSON),
1056 ],
1057 capabilities: AgentCapabilities {
1058 streaming: Some(true),
1059 push_notifications: Some(true),
1060 extensions: None,
1061 extended_agent_card: Some(true),
1062 },
1063 default_input_modes: vec!["text/plain".to_string()],
1064 default_output_modes: vec!["text/plain".to_string()],
1065 skills: vec![],
1066 provider: None,
1067 documentation_url: None,
1068 icon_url: None,
1069 security_schemes: None,
1070 security_requirements: None,
1071 signatures: None,
1072 }
1073 }
1074
1075 fn make_fixture_task(task_id: &str, context_id: &str, state: TaskState, text: &str) -> Task {
1076 Task {
1077 id: task_id.to_string(),
1078 context_id: context_id.to_string(),
1079 status: TaskStatus {
1080 state,
1081 message: Some(Message {
1082 message_id: format!("msg-{task_id}"),
1083 context_id: Some(context_id.to_string()),
1084 task_id: Some(task_id.to_string()),
1085 role: Role::Agent,
1086 parts: vec![Part::text(text)],
1087 metadata: None,
1088 extensions: None,
1089 reference_task_ids: None,
1090 }),
1091 timestamp: None,
1092 },
1093 artifacts: None,
1094 history: None,
1095 metadata: None,
1096 }
1097 }
1098
1099 fn parse_cli_with_base_url(base_url: &str, args: &[&str]) -> Cli {
1100 let mut argv = vec![
1101 "a2acli".to_string(),
1102 "--base-url".to_string(),
1103 base_url.to_string(),
1104 ];
1105 argv.extend(args.iter().map(|arg| (*arg).to_string()));
1106 Cli::try_parse_from(argv).unwrap()
1107 }
1108
1109 async fn unused_base_url() -> String {
1110 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1111 let addr = listener.local_addr().unwrap();
1112 drop(listener);
1113 format!("http://{addr}")
1114 }
1115
1116 fn assert_unsupported_operation<T>(result: Result<T, A2AError>) {
1117 match result {
1118 Ok(_) => panic!("expected unsupported operation error"),
1119 Err(err) => assert_eq!(err.code, a2a::error_code::UNSUPPORTED_OPERATION),
1120 }
1121 }
1122
1123 #[test]
1124 fn test_parse_header() {
1125 let header = parse_header("Authorization: Bearer token").unwrap();
1126 assert_eq!(header.name, "Authorization");
1127 assert_eq!(header.value, "Bearer token");
1128 }
1129
1130 #[test]
1131 fn test_parse_header_requires_separator() {
1132 let err = parse_header("Authorization").unwrap_err();
1133 assert_eq!(err, "header must be in NAME:VALUE format");
1134 }
1135
1136 #[test]
1137 fn test_parse_header_requires_name() {
1138 let err = parse_header(": value").unwrap_err();
1139 assert_eq!(err, "header name cannot be empty");
1140 }
1141
1142 #[test]
1143 fn test_build_send_message_request_populates_optional_fields() {
1144 let request = build_send_message_request(
1145 &MessageCommand {
1146 text: "hello".to_string(),
1147 context_id: Some("ctx-1".to_string()),
1148 task_id: Some("task-1".to_string()),
1149 history_length: Some(4),
1150 accepted_output_modes: vec!["text/plain".to_string()],
1151 return_immediately: true,
1152 },
1153 Some("tenant-1".to_string()),
1154 );
1155
1156 assert_eq!(request.message.text(), Some("hello"));
1157 assert_eq!(request.message.context_id.as_deref(), Some("ctx-1"));
1158 assert_eq!(request.message.task_id.as_deref(), Some("task-1"));
1159 assert_eq!(request.tenant.as_deref(), Some("tenant-1"));
1160 assert_eq!(
1161 request
1162 .configuration
1163 .as_ref()
1164 .and_then(|config| config.history_length),
1165 Some(4)
1166 );
1167 assert_eq!(
1168 request
1169 .configuration
1170 .as_ref()
1171 .and_then(|config| config.return_immediately),
1172 Some(true)
1173 );
1174 }
1175
1176 #[test]
1177 fn test_build_send_message_request_without_optional_fields() {
1178 let request = build_send_message_request(
1179 &MessageCommand {
1180 text: "hello".to_string(),
1181 context_id: None,
1182 task_id: None,
1183 history_length: None,
1184 accepted_output_modes: Vec::new(),
1185 return_immediately: false,
1186 },
1187 None,
1188 );
1189
1190 assert_eq!(request.message.text(), Some("hello"));
1191 assert!(request.configuration.is_none());
1192 assert!(request.tenant.is_none());
1193 }
1194
1195 #[test]
1196 fn test_cli_parse_send_command() {
1197 let cli = Cli::try_parse_from([
1198 "a2acli",
1199 "--binding",
1200 "jsonrpc",
1201 "--header",
1202 "X-Test:123",
1203 "send",
1204 "hello",
1205 "--history-length",
1206 "2",
1207 ])
1208 .unwrap();
1209
1210 assert_eq!(cli.binding, Some(Binding::Jsonrpc));
1211 assert_eq!(cli.headers.len(), 1);
1212 assert!(matches!(cli.command, Command::Send(_)));
1213 }
1214
1215 #[test]
1216 fn test_cli_parse_push_config_create_command() {
1217 let cli = Cli::try_parse_from([
1218 "a2acli",
1219 "push-config",
1220 "create",
1221 "task-1",
1222 "https://example.com/callback",
1223 "--config-id",
1224 "cfg-1",
1225 "--token",
1226 "tok-1",
1227 "--auth-scheme",
1228 "Bearer",
1229 "--auth-credentials",
1230 "secret",
1231 ])
1232 .unwrap();
1233
1234 match cli.command {
1235 Command::PushConfig {
1236 command: PushConfigCommand::Create(command),
1237 } => {
1238 assert_eq!(command.task_id, "task-1");
1239 assert_eq!(command.url, "https://example.com/callback");
1240 assert_eq!(command.config_id.as_deref(), Some("cfg-1"));
1241 assert_eq!(command.token.as_deref(), Some("tok-1"));
1242 assert_eq!(command.auth_scheme.as_deref(), Some("Bearer"));
1243 assert_eq!(command.auth_credentials.as_deref(), Some("secret"));
1244 }
1245 other => panic!("unexpected command: {other:?}"),
1246 }
1247 }
1248
1249 #[test]
1250 fn test_build_push_notification_config() {
1251 let config = build_push_notification_config(&CreatePushConfigCommand {
1252 task_id: "task-1".to_string(),
1253 url: "https://example.com/callback".to_string(),
1254 config_id: Some("cfg-1".to_string()),
1255 token: Some("tok-1".to_string()),
1256 auth_scheme: Some("Bearer".to_string()),
1257 auth_credentials: Some("secret".to_string()),
1258 })
1259 .unwrap();
1260
1261 assert_eq!(config.id.as_deref(), Some("cfg-1"));
1262 assert_eq!(config.token.as_deref(), Some("tok-1"));
1263 assert_eq!(
1264 config
1265 .authentication
1266 .as_ref()
1267 .map(|auth| auth.scheme.as_str()),
1268 Some("Bearer")
1269 );
1270 assert_eq!(
1271 config
1272 .authentication
1273 .as_ref()
1274 .and_then(|auth| auth.credentials.as_deref()),
1275 Some("secret")
1276 );
1277 }
1278
1279 #[test]
1280 fn test_build_push_notification_config_requires_auth_scheme() {
1281 let err = build_push_notification_config(&CreatePushConfigCommand {
1282 task_id: "task-1".to_string(),
1283 url: "https://example.com/callback".to_string(),
1284 config_id: None,
1285 token: None,
1286 auth_scheme: None,
1287 auth_credentials: Some("secret".to_string()),
1288 })
1289 .unwrap_err();
1290
1291 assert!(matches!(err, CliError::InvalidInput(_)));
1292 }
1293
1294 #[test]
1295 fn test_build_push_notification_config_without_authentication() {
1296 let config = build_push_notification_config(&CreatePushConfigCommand {
1297 task_id: "task-1".to_string(),
1298 url: "https://example.com/callback".to_string(),
1299 config_id: None,
1300 token: None,
1301 auth_scheme: None,
1302 auth_credentials: None,
1303 })
1304 .unwrap();
1305
1306 assert_eq!(config.url, "https://example.com/callback");
1307 assert!(config.authentication.is_none());
1308 }
1309
1310 #[test]
1311 fn test_binding_protocols() {
1312 assert_eq!(Binding::Jsonrpc.protocol(), TRANSPORT_PROTOCOL_JSONRPC);
1313 assert_eq!(Binding::HttpJson.protocol(), TRANSPORT_PROTOCOL_HTTP_JSON);
1314 }
1315
1316 #[test]
1317 fn test_apply_request_auth_builds_headers() {
1318 let cli = Cli::try_parse_from([
1319 "a2acli",
1320 "--bearer-token",
1321 "secret",
1322 "--header",
1323 "X-Test: 123",
1324 "card",
1325 ])
1326 .unwrap();
1327
1328 let request = apply_request_auth(Client::new().get("http://example.com"), &cli)
1329 .build()
1330 .unwrap();
1331
1332 assert_eq!(
1333 request.headers().get(header::AUTHORIZATION).unwrap(),
1334 "Bearer secret"
1335 );
1336 assert_eq!(request.headers().get("X-Test").unwrap(), "123");
1337 }
1338
1339 #[tokio::test]
1340 async fn test_finish_client_call_propagates_destroy_error() {
1341 let err = finish_client_call(
1342 make_test_client(Some(A2AError::internal("destroy failed"))),
1343 Ok(serde_json::json!({ "ok": true })),
1344 )
1345 .await
1346 .unwrap_err();
1347
1348 assert!(matches!(err, CliError::A2A(_)));
1349 }
1350
1351 #[tokio::test]
1352 async fn test_consume_stream_reports_json_error() {
1353 let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
1354 let err = consume_stream(make_test_client(None), stream, false)
1355 .await
1356 .unwrap_err();
1357
1358 assert!(matches!(err, CliError::Json(_)));
1359 }
1360
1361 #[tokio::test]
1362 async fn test_consume_stream_propagates_destroy_error_on_completion() {
1363 let stream = Box::pin(stream::empty::<Result<serde_json::Value, A2AError>>());
1364 let err = consume_stream(
1365 make_test_client(Some(A2AError::internal("destroy failed"))),
1366 stream,
1367 true,
1368 )
1369 .await
1370 .unwrap_err();
1371
1372 assert!(matches!(err, CliError::A2A(_)));
1373 }
1374
1375 #[tokio::test]
1376 async fn test_test_transport_methods_return_unused_errors() {
1377 let transport = TestTransport {
1378 destroy_error: None,
1379 };
1380 let params = ServiceParams::new();
1381
1382 assert_unsupported_operation(
1383 transport
1384 .send_message(
1385 ¶ms,
1386 &SendMessageRequest {
1387 message: Message::new(Role::User, vec![Part::text("hello")]),
1388 configuration: None,
1389 metadata: None,
1390 tenant: None,
1391 },
1392 )
1393 .await,
1394 );
1395 assert_unsupported_operation(
1396 transport
1397 .send_streaming_message(
1398 ¶ms,
1399 &SendMessageRequest {
1400 message: Message::new(Role::User, vec![Part::text("hello")]),
1401 configuration: None,
1402 metadata: None,
1403 tenant: None,
1404 },
1405 )
1406 .await,
1407 );
1408 assert_unsupported_operation(
1409 transport
1410 .get_task(
1411 ¶ms,
1412 &GetTaskRequest {
1413 id: "task-1".to_string(),
1414 history_length: None,
1415 tenant: None,
1416 },
1417 )
1418 .await,
1419 );
1420 assert_unsupported_operation(
1421 transport
1422 .list_tasks(
1423 ¶ms,
1424 &ListTasksRequest {
1425 context_id: None,
1426 status: None,
1427 page_size: None,
1428 page_token: None,
1429 history_length: None,
1430 status_timestamp_after: None,
1431 include_artifacts: None,
1432 tenant: None,
1433 },
1434 )
1435 .await,
1436 );
1437 assert_unsupported_operation(
1438 transport
1439 .cancel_task(
1440 ¶ms,
1441 &CancelTaskRequest {
1442 id: "task-1".to_string(),
1443 metadata: None,
1444 tenant: None,
1445 },
1446 )
1447 .await,
1448 );
1449 assert_unsupported_operation(
1450 transport
1451 .subscribe_to_task(
1452 ¶ms,
1453 &SubscribeToTaskRequest {
1454 id: "task-1".to_string(),
1455 tenant: None,
1456 },
1457 )
1458 .await,
1459 );
1460 assert_unsupported_operation(
1461 transport
1462 .create_push_config(
1463 ¶ms,
1464 &TaskPushNotificationConfig {
1465 task_id: "task-1".to_string(),
1466 url: "https://example.com/callback".to_string(),
1467 id: Some("cfg-1".to_string()),
1468 token: None,
1469 authentication: None,
1470 tenant: None,
1471 },
1472 )
1473 .await,
1474 );
1475 assert_unsupported_operation(
1476 transport
1477 .get_push_config(
1478 ¶ms,
1479 &GetTaskPushNotificationConfigRequest {
1480 task_id: "task-1".to_string(),
1481 id: "cfg-1".to_string(),
1482 tenant: None,
1483 },
1484 )
1485 .await,
1486 );
1487 assert_unsupported_operation(
1488 transport
1489 .list_push_configs(
1490 ¶ms,
1491 &ListTaskPushNotificationConfigsRequest {
1492 task_id: "task-1".to_string(),
1493 page_size: None,
1494 page_token: None,
1495 tenant: None,
1496 },
1497 )
1498 .await,
1499 );
1500 assert_unsupported_operation(
1501 transport
1502 .delete_push_config(
1503 ¶ms,
1504 &DeleteTaskPushNotificationConfigRequest {
1505 task_id: "task-1".to_string(),
1506 id: "cfg-1".to_string(),
1507 tenant: None,
1508 },
1509 )
1510 .await,
1511 );
1512 assert_unsupported_operation(
1513 transport
1514 .get_extended_agent_card(¶ms, &GetExtendedAgentCardRequest { tenant: None })
1515 .await,
1516 );
1517 }
1518
1519 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1520 async fn test_run_executes_all_commands_in_lib_tests() {
1521 let server = RunTestServer::spawn().await;
1522
1523 run(parse_cli_with_base_url(&server.base_url, &["card"]))
1524 .await
1525 .unwrap();
1526 run(parse_cli_with_base_url(
1527 &server.base_url,
1528 &["--compact", "extended-card"],
1529 ))
1530 .await
1531 .unwrap();
1532 run(parse_cli_with_base_url(
1533 &server.base_url,
1534 &[
1535 "--binding",
1536 "jsonrpc",
1537 "--bearer-token",
1538 "secret",
1539 "--header",
1540 "X-Test: 123",
1541 "send",
1542 "hello from unit test",
1543 "--task-id",
1544 "task-send",
1545 "--context-id",
1546 "ctx-send",
1547 ],
1548 ))
1549 .await
1550 .unwrap();
1551 run(parse_cli_with_base_url(
1552 &server.base_url,
1553 &[
1554 "--compact",
1555 "stream",
1556 "streaming request",
1557 "--task-id",
1558 "task-stream",
1559 "--context-id",
1560 "ctx-stream",
1561 ],
1562 ))
1563 .await
1564 .unwrap();
1565 run(parse_cli_with_base_url(
1566 &server.base_url,
1567 &["get-task", "task-send"],
1568 ))
1569 .await
1570 .unwrap();
1571 run(parse_cli_with_base_url(
1572 &server.base_url,
1573 &[
1574 "--compact",
1575 "list-tasks",
1576 "--context-id",
1577 "ctx-send",
1578 "--status",
1579 "completed",
1580 ],
1581 ))
1582 .await
1583 .unwrap();
1584 run(parse_cli_with_base_url(
1585 &server.base_url,
1586 &["cancel-task", "task-send"],
1587 ))
1588 .await
1589 .unwrap();
1590 run(parse_cli_with_base_url(
1591 &server.base_url,
1592 &["--compact", "subscribe", "task-stream"],
1593 ))
1594 .await
1595 .unwrap();
1596 run(parse_cli_with_base_url(
1597 &server.base_url,
1598 &[
1599 "push-config",
1600 "create",
1601 "task-1",
1602 "https://example.com/callback",
1603 "--config-id",
1604 "cfg-1",
1605 ],
1606 ))
1607 .await
1608 .unwrap();
1609 run(parse_cli_with_base_url(
1610 &server.base_url,
1611 &["--compact", "push-config", "get", "task-1", "cfg-1"],
1612 ))
1613 .await
1614 .unwrap();
1615 run(parse_cli_with_base_url(
1616 &server.base_url,
1617 &["--compact", "push-config", "list", "task-1"],
1618 ))
1619 .await
1620 .unwrap();
1621 run(parse_cli_with_base_url(
1622 &server.base_url,
1623 &["push-config", "delete", "task-1", "cfg-1"],
1624 ))
1625 .await
1626 .unwrap();
1627 }
1628
1629 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1630 async fn test_run_surfaces_errors_in_lib_tests() {
1631 let server = RunTestServer::spawn().await;
1632
1633 let err = run(parse_cli_with_base_url(
1634 &server.base_url,
1635 &["extended-card", "--tenant", "error"],
1636 ))
1637 .await
1638 .unwrap_err();
1639 assert!(matches!(
1640 err,
1641 CliError::A2A(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION
1642 ));
1643
1644 let err = run(parse_cli_with_base_url(
1645 &server.base_url,
1646 &["send", "send-error"],
1647 ))
1648 .await
1649 .unwrap_err();
1650 assert!(matches!(
1651 err,
1652 CliError::A2A(error) if error.code == a2a::error_code::INVALID_REQUEST
1653 ));
1654
1655 let err = run(parse_cli_with_base_url(
1656 &server.base_url,
1657 &["list-tasks", "--context-id", "error"],
1658 ))
1659 .await
1660 .unwrap_err();
1661 assert!(matches!(
1662 err,
1663 CliError::A2A(error) if error.code == a2a::error_code::INVALID_PARAMS
1664 ));
1665
1666 let err = run(parse_cli_with_base_url(
1667 &server.base_url,
1668 &["--compact", "stream", "stream-error"],
1669 ))
1670 .await
1671 .unwrap_err();
1672 assert!(matches!(
1673 err,
1674 CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
1675 ));
1676
1677 let err = run(parse_cli_with_base_url(
1678 &server.base_url,
1679 &["--compact", "subscribe", "stream-error"],
1680 ))
1681 .await
1682 .unwrap_err();
1683 assert!(matches!(
1684 err,
1685 CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
1686 ));
1687
1688 let err = run(parse_cli_with_base_url(
1689 &server.base_url,
1690 &[
1691 "push-config",
1692 "create",
1693 "missing",
1694 "https://example.com/callback",
1695 "--config-id",
1696 "cfg-missing",
1697 ],
1698 ))
1699 .await
1700 .unwrap_err();
1701 assert!(matches!(
1702 err,
1703 CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
1704 ));
1705
1706 let err = run(parse_cli_with_base_url(
1707 &server.base_url,
1708 &["push-config", "list", "missing"],
1709 ))
1710 .await
1711 .unwrap_err();
1712 assert!(matches!(
1713 err,
1714 CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
1715 ));
1716
1717 let base_url = unused_base_url().await;
1718 let err = run(parse_cli_with_base_url(&base_url, &["card"]))
1719 .await
1720 .unwrap_err();
1721 assert!(matches!(err, CliError::Http(_)));
1722 }
1723
1724 #[test]
1725 fn test_task_state_conversion() {
1726 let cases = [
1727 (TaskStateArg::Unspecified, TaskState::Unspecified),
1728 (TaskStateArg::Submitted, TaskState::Submitted),
1729 (TaskStateArg::Working, TaskState::Working),
1730 (TaskStateArg::Completed, TaskState::Completed),
1731 (TaskStateArg::Failed, TaskState::Failed),
1732 (TaskStateArg::Canceled, TaskState::Canceled),
1733 (TaskStateArg::InputRequired, TaskState::InputRequired),
1734 (TaskStateArg::Rejected, TaskState::Rejected),
1735 (TaskStateArg::AuthRequired, TaskState::AuthRequired),
1736 ];
1737
1738 for (input, expected) in cases {
1739 assert_eq!(TaskState::from(input), expected);
1740 }
1741 }
1742}