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