1use std::collections::{HashMap, HashSet};
4use std::ffi::OsString;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use a2a::*;
10use a2a_client::auth::AuthInterceptor;
11use a2a_client::{A2AClient, A2AClientFactory, BoxStream};
12use clap::parser::ValueSource;
13use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
14use futures::StreamExt;
15use reqwest::{Client, RequestBuilder};
16use serde::Serialize;
17use thiserror::Error;
18
19#[derive(Debug, Clone, Parser, PartialEq, Eq)]
20#[command(
21 name = "a2acli",
22 version,
23 about = "Standalone A2A client CLI",
24 after_help = "\
25Default behavior (SPEC.md §6.5):
26 - Transport: the agent card's first supported interface (--transport overrides)
27 - Task completion: wait until terminal/interrupted state (--async returns immediately)
28 - Output: human-readable text (-o/--output json switches to protocol JSON)
29 - Protocol version: highest shared 1.x, from the agent card (--a2a-version pins it)
30 - Transport security: TLS verification on (--insecure disables it, with a warning)"
31)]
32pub struct Cli {
33 #[arg(long, global = true)]
37 pub config: Option<String>,
38
39 #[arg(
43 short = 'a',
44 long = "agent-card",
45 global = true,
46 value_name = "REF",
47 env = "A2ACLI_AGENT_CARD"
48 )]
49 pub agent_card: Option<String>,
50
51 #[arg(
56 short = 'e',
57 long,
58 global = true,
59 value_name = "REF",
60 env = "A2ACLI_ENDPOINT"
61 )]
62 pub endpoint: Option<String>,
63
64 #[arg(
68 long,
69 global = true,
70 hide = true,
71 default_value = "http://localhost:3000",
72 env = "A2ACLI_BASE_URL"
73 )]
74 pub base_url: String,
75
76 #[arg(
81 long,
82 global = true,
83 value_enum,
84 env = "A2ACLI_TRANSPORT",
85 value_delimiter = ','
86 )]
87 pub transport: Vec<Binding>,
88
89 #[arg(long = "a2a-version", global = true, env = "A2ACLI_A2A_VERSION")]
95 pub a2a_version: Option<String>,
96
97 #[arg(long, global = true, env = "A2ACLI_BEARER")]
98 pub bearer: Option<String>,
99
100 #[arg(long = "api-key", global = true, env = "A2ACLI_API_KEY")]
104 pub api_key: Option<String>,
105
106 #[arg(long = "svc-param", global = true, value_parser = parse_header)]
111 pub svc_params: Vec<HeaderArg>,
112
113 #[arg(long, global = true, env = "A2ACLI_INSECURE")]
117 pub insecure: bool,
118
119 #[arg(long, global = true, env = "A2ACLI_DEBUG")]
124 pub debug: bool,
125
126 #[arg(long, global = true, env = "A2ACLI_TENANT")]
131 pub tenant: Option<String>,
132
133 #[arg(
137 short = 'o',
138 long,
139 global = true,
140 value_enum,
141 default_value_t = OutputFormat::Text,
142 env = "A2ACLI_OUTPUT"
143 )]
144 pub output: OutputFormat,
145
146 #[arg(long, global = true, env = "A2ACLI_COMPACT")]
151 pub compact: bool,
152
153 #[arg(
163 long = "async",
164 alias = "no-wait",
165 global = true,
166 conflicts_with = "wait",
167 env = "A2ACLI_ASYNC"
168 )]
169 pub async_mode: bool,
170
171 #[arg(
175 long,
176 global = true,
177 conflicts_with = "async_mode",
178 env = "A2ACLI_WAIT"
179 )]
180 pub wait: bool,
181
182 #[arg(long, global = true, value_parser = parse_duration, default_value = "2s", env = "A2ACLI_POLL_INTERVAL")]
184 pub poll_interval: Duration,
185
186 #[arg(long, global = true, value_parser = parse_duration, default_value = "30s", env = "A2ACLI_TIMEOUT")]
188 pub timeout: Duration,
189
190 #[command(subcommand)]
191 pub command: Command,
192}
193
194#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
195pub enum Command {
196 Card {
198 #[command(subcommand)]
199 command: CardCommand,
200 },
201 Config {
203 #[command(subcommand)]
204 command: ConfigCommand,
205 },
206 Send(MessageCommand),
208 Task {
210 #[command(subcommand)]
211 command: TaskCommand,
212 },
213}
214
215#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
216pub enum CardCommand {
217 Get(CardGetCommand),
219}
220
221#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
222pub enum ConfigCommand {
223 Show,
228}
229
230#[derive(Debug, Clone, Args, PartialEq, Eq)]
231pub struct CardGetCommand {
232 #[arg(long)]
234 pub extended: bool,
235}
236
237#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
238pub enum TaskCommand {
239 Get(TaskLookupCommand),
241 List(ListTasksCommand),
243 Cancel(TaskIdCommand),
245 Subscribe(TaskIdCommand),
247 PushConfig {
249 #[command(subcommand)]
250 command: PushConfigCommand,
251 },
252}
253
254#[derive(Debug, Clone, Args, PartialEq, Eq)]
255pub struct MessageCommand {
256 pub text: Option<String>,
260
261 #[arg(long = "text-part")]
264 pub text_parts: Vec<String>,
265
266 #[arg(long = "file-part")]
269 pub file_parts: Vec<String>,
270
271 #[arg(long = "data-part")]
274 pub data_parts: Vec<String>,
275
276 #[arg(long = "media-type")]
279 pub media_types: Vec<String>,
280
281 #[arg(long, env = "A2ACLI_CONTEXT_ID")]
283 pub context_id: Option<String>,
284
285 #[arg(long, env = "A2ACLI_TASK_ID")]
287 pub task_id: Option<String>,
288
289 #[arg(long)]
291 pub history_length: Option<i32>,
292
293 #[arg(long = "accept-output")]
295 pub accepted_output_modes: Vec<String>,
296
297 #[arg(long)]
299 pub return_immediately: bool,
300
301 #[arg(long)]
303 pub stream: bool,
304}
305
306#[derive(Debug, Clone, Args, PartialEq, Eq)]
307pub struct TaskLookupCommand {
308 pub id: String,
310
311 #[arg(long)]
313 pub history_length: Option<i32>,
314}
315
316#[derive(Debug, Clone, Args, PartialEq, Eq)]
317pub struct ListTasksCommand {
318 #[arg(long)]
320 pub context_id: Option<String>,
321
322 #[arg(long, value_enum)]
324 pub status: Option<TaskStateArg>,
325
326 #[arg(long)]
328 pub page_size: Option<i32>,
329
330 #[arg(long)]
332 pub page_token: Option<String>,
333
334 #[arg(long)]
336 pub history_length: Option<i32>,
337
338 #[arg(long)]
340 pub include_artifacts: bool,
341}
342
343#[derive(Debug, Clone, Args, PartialEq, Eq)]
344pub struct TaskIdCommand {
345 pub id: String,
347}
348
349#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
350pub enum PushConfigCommand {
351 Create(CreatePushConfigCommand),
353 Get(PushConfigIdCommand),
355 List(ListPushConfigsCommand),
357 Delete(PushConfigIdCommand),
359}
360
361#[derive(Debug, Clone, Args, PartialEq, Eq)]
362pub struct CreatePushConfigCommand {
363 pub task_id: String,
365
366 pub url: String,
368
369 #[arg(long = "config-id")]
371 pub config_id: Option<String>,
372
373 #[arg(long)]
375 pub token: Option<String>,
376
377 #[arg(long = "auth-scheme")]
379 pub auth_scheme: Option<String>,
380
381 #[arg(long = "auth-credentials")]
383 pub auth_credentials: Option<String>,
384}
385
386#[derive(Debug, Clone, Args, PartialEq, Eq)]
387pub struct PushConfigIdCommand {
388 pub task_id: String,
390
391 pub id: String,
393}
394
395#[derive(Debug, Clone, Args, PartialEq, Eq)]
396pub struct ListPushConfigsCommand {
397 pub task_id: String,
399
400 #[arg(long)]
402 pub page_size: Option<i32>,
403
404 #[arg(long)]
406 pub page_token: Option<String>,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct HeaderArg {
411 pub name: String,
412 pub value: String,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
419pub enum OutputFormat {
420 Text,
421 Json,
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
425pub enum Binding {
426 Jsonrpc,
427 #[value(name = "rest")]
430 Rest,
431}
432
433impl Binding {
434 fn protocol(self) -> &'static str {
435 match self {
436 Binding::Jsonrpc => TRANSPORT_PROTOCOL_JSONRPC,
437 Binding::Rest => TRANSPORT_PROTOCOL_HTTP_JSON,
438 }
439 }
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
443pub enum TaskStateArg {
444 Unspecified,
445 Submitted,
446 Working,
447 Completed,
448 Failed,
449 Canceled,
450 InputRequired,
451 Rejected,
452 AuthRequired,
453}
454
455impl From<TaskStateArg> for TaskState {
456 fn from(value: TaskStateArg) -> Self {
457 match value {
458 TaskStateArg::Unspecified => TaskState::Unspecified,
459 TaskStateArg::Submitted => TaskState::Submitted,
460 TaskStateArg::Working => TaskState::Working,
461 TaskStateArg::Completed => TaskState::Completed,
462 TaskStateArg::Failed => TaskState::Failed,
463 TaskStateArg::Canceled => TaskState::Canceled,
464 TaskStateArg::InputRequired => TaskState::InputRequired,
465 TaskStateArg::Rejected => TaskState::Rejected,
466 TaskStateArg::AuthRequired => TaskState::AuthRequired,
467 }
468 }
469}
470
471#[derive(Debug, Error)]
472pub enum CliError {
473 #[error(transparent)]
474 A2A(#[from] A2AError),
475 #[error("http request failed: {0}")]
476 Http(#[from] reqwest::Error),
477 #[error("failed to serialize output: {0}")]
478 Json(#[from] serde_json::Error),
479 #[error("invalid input: {0}")]
480 InvalidInput(String),
481 #[error("failed to read {path}: {source}")]
482 ReadFile {
483 path: String,
484 #[source]
485 source: std::io::Error,
486 },
487 #[error("timed out after {timeout:?} waiting for task {task_id} to settle")]
488 Timeout { task_id: String, timeout: Duration },
489 #[error("failed to read agent card file {path}: {source}")]
492 CardFile {
493 path: String,
494 #[source]
495 source: std::io::Error,
496 },
497 #[error("agent card file is not a valid agent card: {0}")]
500 CardInvalid(String),
501 #[error("{0}")]
506 Usage(String),
507}
508
509const WELL_KNOWN_AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
512
513#[derive(Debug, Clone, Serialize)]
516struct ErrorEnvelope {
517 error: ErrorDetail,
518}
519
520#[derive(Debug, Clone, Serialize)]
521struct ErrorDetail {
522 code: String,
523 message: String,
524 #[serde(skip_serializing_if = "Option::is_none")]
525 hint: Option<String>,
526 #[serde(rename = "a2aCode", skip_serializing_if = "Option::is_none")]
527 a2a_code: Option<i64>,
528}
529
530impl CliError {
531 pub fn exit_code(&self) -> i32 {
534 match self {
535 CliError::A2A(_) => 1,
536 CliError::Http(error) => http_error_exit_code(error),
537 CliError::Json(_) => 1,
538 CliError::InvalidInput(_) => 2,
539 CliError::ReadFile { .. } => 2,
540 CliError::Timeout { .. } => 5,
541 CliError::CardFile { .. } => 3,
545 CliError::CardInvalid(_) => 1,
546 CliError::Usage(_) => 2,
547 }
548 }
549
550 fn envelope(&self) -> ErrorEnvelope {
554 let (code, hint, a2a_code) = match self {
555 CliError::A2A(error) => (
556 error_reason(error.code).to_string(),
557 None,
558 Some(error.code as i64),
559 ),
560 CliError::Http(error) => http_error_code_and_hint(error),
561 CliError::Json(_) => ("A2ACLI_ERR_INTERNAL".to_string(), None, None),
562 CliError::InvalidInput(_) => ("A2ACLI_ERR_USAGE".to_string(), None, None),
563 CliError::ReadFile { .. } => (
564 "A2ACLI_ERR_USAGE".to_string(),
565 Some(
566 "check that the --file-part/--data-part path exists and is readable"
567 .to_string(),
568 ),
569 None,
570 ),
571 CliError::CardFile { .. } => (
572 "A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
573 Some("check that the --agent-card path exists and is readable".to_string()),
574 None,
575 ),
576 CliError::Usage(_) => (
577 "A2ACLI_ERR_USAGE".to_string(),
578 Some("run `a2acli --help`, or `a2acli <command> --help`".to_string()),
579 None,
580 ),
581 CliError::CardInvalid(_) => (
582 "A2ACLI_ERR_CARD_INVALID".to_string(),
583 Some("the agent card did not match the expected schema".to_string()),
584 None,
585 ),
586 CliError::Timeout { .. } => (
587 "A2ACLI_ERR_TIMEOUT".to_string(),
588 Some(
589 "increase --timeout/--poll-interval, or check the task later with `task get <id> --wait`"
590 .to_string(),
591 ),
592 None,
593 ),
594 };
595
596 ErrorEnvelope {
597 error: ErrorDetail {
598 code,
599 message: self.to_string(),
600 hint,
601 a2a_code,
602 },
603 }
604 }
605
606 pub fn report(&self) {
610 match serde_json::to_string(&self.envelope()) {
611 Ok(json) => eprintln!("{json}"),
612 Err(_) => eprintln!(
613 "{{\"error\":{{\"code\":\"A2ACLI_ERR_INTERNAL\",\"message\":{:?}}}}}",
614 self.to_string()
615 ),
616 }
617 }
618}
619
620fn http_error_code_and_hint(error: &reqwest::Error) -> (String, Option<String>, Option<i64>) {
626 if error.is_decode() {
627 return (
628 "A2ACLI_ERR_CARD_INVALID".to_string(),
629 Some("the agent card did not match the expected schema".to_string()),
630 None,
631 );
632 }
633
634 if let Some(status) = error.status() {
635 if status.as_u16() == 401 || status.as_u16() == 403 {
636 return (
637 "A2ACLI_ERR_AUTH_FAILED".to_string(),
638 Some("credentials were rejected fetching the agent card".to_string()),
639 None,
640 );
641 }
642 return (
643 "A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
644 Some("check --agent-card/--base-url resolves to a valid agent".to_string()),
645 None,
646 );
647 }
648
649 (
650 "A2ACLI_ERR_UNREACHABLE".to_string(),
651 Some("check --agent-card/--endpoint and network connectivity".to_string()),
652 None,
653 )
654}
655
656fn http_error_exit_code(error: &reqwest::Error) -> i32 {
657 if error.is_decode() {
658 return 1;
659 }
660 if let Some(status) = error.status() {
661 return if status.as_u16() == 401 || status.as_u16() == 403 {
662 4
663 } else {
664 3
665 };
666 }
667 3
668}
669
670pub async fn run_args<I, T>(args: I) -> Result<(), CliError>
676where
677 I: IntoIterator<Item = T>,
678 T: Into<OsString>,
679{
680 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
681 let dotenv_provenance = load_dotenv_config(&args);
684 let matches = match Cli::command().try_get_matches_from(args) {
689 Ok(matches) => matches,
690 Err(error) => return Err(clap_error_to_cli_error(error)),
691 };
692 let cli = Cli::from_arg_matches(&matches).expect("matches were produced by Cli::command()");
693 run(cli, &matches, &dotenv_provenance).await
694}
695
696fn clap_error_to_cli_error(error: clap::Error) -> CliError {
702 use clap::error::ErrorKind;
703
704 if matches!(
705 error.kind(),
706 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
707 ) {
708 error.exit();
713 }
714
715 CliError::Usage(flatten_clap_message(&error.render().to_string()))
723}
724
725fn flatten_clap_message(rendered: &str) -> String {
730 let first_block: Vec<&str> = rendered
734 .lines()
735 .map(str::trim)
736 .take_while(|line| !line.is_empty())
737 .collect();
738 let joined = first_block.join(" ");
739 let message = if joined.is_empty() {
740 rendered.trim()
741 } else {
742 joined.as_str()
743 };
744 message
745 .strip_prefix("error: ")
746 .unwrap_or(message)
747 .to_string()
748}
749
750pub async fn run(
751 cli: Cli,
752 matches: &ArgMatches,
753 dotenv_provenance: &HashMap<String, ConfigSource>,
754) -> Result<(), CliError> {
755 if cli.debug {
756 let _ = tracing_subscriber::fmt()
761 .with_writer(std::io::stderr)
762 .with_target(false)
763 .try_init();
764 }
765
766 validate_a2a_version(cli.a2a_version.as_deref())?;
769
770 match &cli.command {
771 Command::Card { command } => run_card_command(&cli, matches, command).await?,
772 Command::Config { command } => {
773 run_config_command(&cli, matches, dotenv_provenance, command)?
774 }
775 Command::Send(command) => {
776 let send_matches = matches
777 .subcommand_matches("send")
778 .expect("send subcommand matches present when cli.command is Command::Send");
779 let parts = resolve_message_parts(send_matches, command)?;
780 let ResolvedClient { client, tenant } = resolve_client(&cli, matches).await?;
781 let request = build_send_message_request(command, parts, tenant.clone());
782
783 if command.stream {
784 match client.send_streaming_message(&request).await {
785 Ok(stream) => {
786 consume_stream(client, stream, &cli).await?;
787 }
788 Err(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION => {
789 let response = send_and_maybe_wait(
796 client,
797 &request,
798 tenant,
799 cli.async_mode,
800 cli.poll_interval,
801 cli.timeout,
802 )
803 .await?;
804 response.warn_outcome();
805 print_output(&response, &cli)?;
806 }
807 Err(error) => {
808 let _ = client.destroy().await;
809 return Err(error.into());
810 }
811 }
812 } else {
813 let response = send_and_maybe_wait(
814 client,
815 &request,
816 tenant,
817 cli.async_mode,
818 cli.poll_interval,
819 cli.timeout,
820 )
821 .await?;
822 response.warn_outcome();
823 print_output(&response, &cli)?;
824 }
825 }
826 Command::Task { command } => run_task_command(&cli, matches, command).await?,
827 }
828
829 Ok(())
830}
831
832async fn send_and_maybe_wait<T: a2a_client::Transport>(
837 client: A2AClient<T>,
838 request: &SendMessageRequest,
839 tenant: Option<String>,
840 async_mode: bool,
841 poll_interval: Duration,
842 timeout: Duration,
843) -> Result<SendMessageResponse, CliError> {
844 let result = client.send_message(request).await;
845 let response = match result {
846 Ok(response) => response,
847 Err(error) => {
848 let _ = client.destroy().await;
849 return Err(error.into());
850 }
851 };
852
853 match response {
854 SendMessageResponse::Task(task) => {
855 let task =
856 settle_task(client, task, !async_mode, tenant, poll_interval, timeout).await?;
857 Ok(SendMessageResponse::Task(task))
858 }
859 SendMessageResponse::Message(message) => {
860 client.destroy().await?;
861 Ok(SendMessageResponse::Message(message))
862 }
863 }
864}
865
866async fn settle_task<T: a2a_client::Transport>(
870 client: A2AClient<T>,
871 task: Task,
872 wait: bool,
873 tenant: Option<String>,
874 poll_interval: Duration,
875 timeout: Duration,
876) -> Result<Task, CliError> {
877 let outcome = if wait && !is_settled(&task.status.state) {
878 wait_for_task_settled(&client, &task.id, tenant, poll_interval, timeout).await
879 } else {
880 Ok(task)
881 };
882
883 match outcome {
884 Ok(task) => {
885 client.destroy().await?;
886 Ok(task)
887 }
888 Err(error) => {
889 let _ = client.destroy().await;
890 Err(error)
891 }
892 }
893}
894
895async fn wait_for_task_settled<T: a2a_client::Transport>(
900 client: &A2AClient<T>,
901 task_id: &str,
902 tenant: Option<String>,
903 poll_interval: Duration,
904 timeout: Duration,
905) -> Result<Task, CliError> {
906 let deadline = tokio::time::Instant::now() + timeout;
907 loop {
908 let task = client
909 .get_task(&GetTaskRequest {
910 id: task_id.to_string(),
911 history_length: None,
912 tenant: tenant.clone(),
913 })
914 .await?;
915
916 if is_settled(&task.status.state) {
917 return Ok(task);
918 }
919
920 let now = tokio::time::Instant::now();
921 if now >= deadline {
922 return Err(CliError::Timeout {
923 task_id: task_id.to_string(),
924 timeout,
925 });
926 }
927
928 tokio::time::sleep(poll_interval.min(deadline.saturating_duration_since(now))).await;
929 }
930}
931
932fn task_outcome_note(state: &TaskState) -> Option<&'static str> {
942 match state {
943 TaskState::Failed => Some("the agent reported it FAILED"),
944 TaskState::Rejected => Some("the agent REJECTED it"),
945 TaskState::InputRequired => {
946 Some("it is paused at INPUT_REQUIRED and needs a reply to continue")
947 }
948 TaskState::AuthRequired => {
949 Some("it is paused at AUTH_REQUIRED and needs credentials to continue")
950 }
951 _ => None,
952 }
953}
954
955fn warn_task_outcome(task_id: &str, state: &TaskState) {
956 if let Some(note) = task_outcome_note(state) {
957 eprintln!("warning: task {task_id}: {note}");
958 }
959}
960
961trait TaskOutcome {
965 fn warn_outcome(&self);
966}
967
968impl TaskOutcome for Task {
969 fn warn_outcome(&self) {
970 warn_task_outcome(&self.id, &self.status.state);
971 }
972}
973
974impl TaskOutcome for SendMessageResponse {
975 fn warn_outcome(&self) {
976 match self {
977 SendMessageResponse::Task(task) => task.warn_outcome(),
978 SendMessageResponse::Message(_) => {}
981 }
982 }
983}
984
985impl TaskOutcome for StreamResponse {
986 fn warn_outcome(&self) {
987 match self {
988 StreamResponse::Task(task) => task.warn_outcome(),
989 StreamResponse::StatusUpdate(event) => {
990 warn_task_outcome(&event.task_id, &event.status.state);
991 }
992 StreamResponse::Message(_) | StreamResponse::ArtifactUpdate(_) => {}
993 }
994 }
995}
996
997fn is_settled(state: &TaskState) -> bool {
1000 state.is_terminal() || matches!(state, TaskState::InputRequired | TaskState::AuthRequired)
1001}
1002
1003async fn run_card_command(
1004 cli: &Cli,
1005 matches: &ArgMatches,
1006 command: &CardCommand,
1007) -> Result<(), CliError> {
1008 match command {
1009 CardCommand::Get(command) => {
1010 if command.extended {
1011 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1012 let result = client
1013 .get_extended_agent_card(&GetExtendedAgentCardRequest { tenant })
1014 .await;
1015 let card = finish_client_call(client, result).await?;
1016 print_output(&card, cli)?;
1017 } else {
1018 let card = resolve_agent_card(cli, matches).await?;
1019 print_output(&card, cli)?;
1020 }
1021 }
1022 }
1023
1024 Ok(())
1025}
1026
1027async fn run_task_command(
1028 cli: &Cli,
1029 matches: &ArgMatches,
1030 command: &TaskCommand,
1031) -> Result<(), CliError> {
1032 match command {
1033 TaskCommand::Get(command) => {
1034 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1035 let result = client
1036 .get_task(&GetTaskRequest {
1037 id: command.id.clone(),
1038 history_length: command.history_length,
1039 tenant: tenant.clone(),
1040 })
1041 .await;
1042 let task = match result {
1043 Ok(task) => task,
1044 Err(error) => {
1045 let _ = client.destroy().await;
1046 return Err(error.into());
1047 }
1048 };
1049 let task = settle_task(
1050 client,
1051 task,
1052 cli.wait,
1053 tenant,
1054 cli.poll_interval,
1055 cli.timeout,
1056 )
1057 .await?;
1058 task.warn_outcome();
1059 print_output(&task, cli)?;
1060 }
1061 TaskCommand::List(command) => {
1062 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1063 let result = client
1064 .list_tasks(&ListTasksRequest {
1065 context_id: command.context_id.clone(),
1066 status: command.status.map(TaskState::from),
1067 page_size: command.page_size,
1068 page_token: command.page_token.clone(),
1069 history_length: command.history_length,
1070 status_timestamp_after: None,
1071 include_artifacts: command.include_artifacts.then_some(true),
1072 tenant,
1073 })
1074 .await;
1075 let response = finish_client_call(client, result).await?;
1076 print_output(&response, cli)?;
1077 }
1078 TaskCommand::Cancel(command) => {
1079 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1080 let result = client
1081 .cancel_task(&CancelTaskRequest {
1082 id: command.id.clone(),
1083 metadata: None,
1084 tenant,
1085 })
1086 .await;
1087 let task = finish_client_call(client, result).await?;
1088 task.warn_outcome();
1089 print_output(&task, cli)?;
1090 }
1091 TaskCommand::Subscribe(command) => {
1092 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1093 let stream = client
1094 .subscribe_to_task(&SubscribeToTaskRequest {
1095 id: command.id.clone(),
1096 tenant,
1097 })
1098 .await?;
1099 consume_stream(client, stream, cli).await?;
1100 }
1101 TaskCommand::PushConfig { command } => {
1102 run_push_config_command(cli, matches, command).await?;
1103 }
1104 }
1105
1106 Ok(())
1107}
1108
1109fn resolve_message_parts(
1120 matches: &ArgMatches,
1121 command: &MessageCommand,
1122) -> Result<Vec<Part>, CliError> {
1123 enum Kind {
1124 Text,
1125 File,
1126 Data,
1127 }
1128 enum Token<'a> {
1129 Part(Kind, &'a str),
1130 MediaType(&'a str),
1131 }
1132
1133 let mut tokens: Vec<(usize, Token<'_>)> = Vec::new();
1134 if let Some(indices) = matches.indices_of("text_parts") {
1135 tokens.extend(
1136 indices
1137 .zip(command.text_parts.iter())
1138 .map(|(index, value)| (index, Token::Part(Kind::Text, value.as_str()))),
1139 );
1140 }
1141 if let Some(indices) = matches.indices_of("file_parts") {
1142 tokens.extend(
1143 indices
1144 .zip(command.file_parts.iter())
1145 .map(|(index, value)| (index, Token::Part(Kind::File, value.as_str()))),
1146 );
1147 }
1148 if let Some(indices) = matches.indices_of("data_parts") {
1149 tokens.extend(
1150 indices
1151 .zip(command.data_parts.iter())
1152 .map(|(index, value)| (index, Token::Part(Kind::Data, value.as_str()))),
1153 );
1154 }
1155
1156 if !command.media_types.is_empty() && tokens.is_empty() {
1157 return Err(CliError::InvalidInput(
1160 "--media-type must immediately follow a --text-part/--file-part/--data-part"
1161 .to_string(),
1162 ));
1163 }
1164
1165 if tokens.is_empty() {
1166 return match command.text.as_deref() {
1170 Some(text) => Ok(vec![Part::text(text)]),
1171 None => Err(CliError::InvalidInput(
1172 "message must have at least one part: pass text, or use \
1173 --text-part/--file-part/--data-part"
1174 .to_string(),
1175 )),
1176 };
1177 }
1178
1179 if command.text.is_some() {
1180 return Err(CliError::InvalidInput(
1181 "the positional message text cannot be combined with \
1182 --text-part/--file-part/--data-part; pass it as --text-part instead"
1183 .to_string(),
1184 ));
1185 }
1186
1187 if let Some(indices) = matches.indices_of("media_types") {
1188 tokens.extend(
1189 indices
1190 .zip(command.media_types.iter())
1191 .map(|(index, value)| (index, Token::MediaType(value.as_str()))),
1192 );
1193 }
1194 tokens.sort_by_key(|(index, _)| *index);
1195
1196 let mut parts: Vec<Part> = Vec::new();
1197 for (_, token) in tokens {
1198 match token {
1199 Token::Part(kind, value) => {
1200 let part = match kind {
1201 Kind::Text => Part::text(value),
1202 Kind::File => build_file_part(value)?,
1203 Kind::Data => build_data_part(value)?,
1204 };
1205 parts.push(part);
1206 }
1207 Token::MediaType(media_type) => match parts.last_mut() {
1208 Some(part) => part.media_type = Some(media_type.to_string()),
1209 None => {
1210 return Err(CliError::InvalidInput(
1211 "--media-type must immediately follow a --text-part/--file-part/--data-part"
1212 .to_string(),
1213 ));
1214 }
1215 },
1216 }
1217 }
1218
1219 Ok(parts)
1220}
1221
1222fn build_file_part(value: &str) -> Result<Part, CliError> {
1225 if value.contains("://") {
1226 return Ok(Part::url(value));
1227 }
1228
1229 let bytes = std::fs::read(value).map_err(|source| CliError::ReadFile {
1230 path: value.to_string(),
1231 source,
1232 })?;
1233 let mut part = Part::raw(bytes);
1234 part.filename = std::path::Path::new(value)
1235 .file_name()
1236 .map(|name| name.to_string_lossy().into_owned());
1237 Ok(part)
1238}
1239
1240fn means_not_a_readable_path(kind: std::io::ErrorKind) -> bool {
1254 matches!(
1255 kind,
1256 std::io::ErrorKind::NotFound
1257 | std::io::ErrorKind::InvalidFilename
1258 | std::io::ErrorKind::InvalidInput
1259 )
1260}
1261
1262fn build_data_part(value: &str) -> Result<Part, CliError> {
1263 if value == "-" {
1264 use std::io::Read;
1265 let mut buffer = String::new();
1266 std::io::stdin()
1267 .read_to_string(&mut buffer)
1268 .map_err(|source| CliError::ReadFile {
1269 path: "<stdin>".to_string(),
1270 source,
1271 })?;
1272 return parse_data_part_json(&buffer, "<stdin>");
1273 }
1274
1275 match std::fs::read_to_string(value) {
1276 Ok(text) => return parse_data_part_json(&text, value),
1277 Err(source) if !means_not_a_readable_path(source.kind()) => {
1278 return Err(CliError::ReadFile {
1282 path: value.to_string(),
1283 source,
1284 });
1285 }
1286 Err(_) => {} }
1288
1289 serde_json::from_str(value).map(Part::data).map_err(|_| {
1290 CliError::InvalidInput(format!(
1291 "--data-part must be a file path, \"-\" for stdin, or inline JSON: {value}"
1292 ))
1293 })
1294}
1295
1296fn parse_data_part_json(text: &str, source: &str) -> Result<Part, CliError> {
1301 serde_json::from_str(text).map(Part::data).map_err(|error| {
1302 CliError::InvalidInput(format!("--data-part: invalid JSON in {source}: {error}"))
1303 })
1304}
1305
1306fn parse_duration(input: &str) -> Result<Duration, String> {
1308 let trimmed = input.trim();
1309 let (magnitude, unit) = if let Some(value) = trimmed.strip_suffix("ms") {
1310 (value, "ms")
1311 } else if let Some(value) = trimmed.strip_suffix('s') {
1312 (value, "s")
1313 } else if let Some(value) = trimmed.strip_suffix('m') {
1314 (value, "m")
1315 } else {
1316 (trimmed, "s")
1317 };
1318
1319 let magnitude: f64 = magnitude
1320 .trim()
1321 .parse()
1322 .map_err(|_| format!("invalid duration: {input}"))?;
1323 if magnitude < 0.0 || !magnitude.is_finite() {
1324 return Err(format!("duration must be a non-negative number: {input}"));
1325 }
1326
1327 let seconds = match unit {
1328 "ms" => magnitude / 1000.0,
1329 "m" => magnitude * 60.0,
1330 _ => magnitude,
1331 };
1332 Ok(Duration::from_secs_f64(seconds))
1333}
1334
1335fn build_send_message_request(
1336 command: &MessageCommand,
1337 parts: Vec<Part>,
1338 tenant: Option<String>,
1339) -> SendMessageRequest {
1340 let mut message = Message::new(Role::User, parts);
1341 message.context_id = command.context_id.clone();
1342 message.task_id = command.task_id.clone();
1343
1344 let configuration = if command.history_length.is_some()
1345 || !command.accepted_output_modes.is_empty()
1346 || command.return_immediately
1347 {
1348 Some(SendMessageConfiguration {
1349 accepted_output_modes: (!command.accepted_output_modes.is_empty())
1350 .then_some(command.accepted_output_modes.clone()),
1351 task_push_notification_config: None,
1352 history_length: command.history_length,
1353 return_immediately: command.return_immediately.then_some(true),
1354 })
1355 } else {
1356 None
1357 };
1358
1359 SendMessageRequest {
1360 message,
1361 configuration,
1362 metadata: None,
1363 tenant,
1364 }
1365}
1366
1367fn build_push_notification_config(
1368 command: &CreatePushConfigCommand,
1369) -> Result<TaskPushNotificationConfig, CliError> {
1370 if command.auth_credentials.is_some() && command.auth_scheme.is_none() {
1371 return Err(CliError::InvalidInput(
1372 "--auth-credentials requires --auth-scheme".to_string(),
1373 ));
1374 }
1375
1376 let authentication = command
1377 .auth_scheme
1378 .clone()
1379 .map(|scheme| AuthenticationInfo {
1380 scheme,
1381 credentials: command.auth_credentials.clone(),
1382 });
1383
1384 Ok(TaskPushNotificationConfig {
1385 task_id: String::new(),
1386 url: command.url.clone(),
1387 id: command.config_id.clone(),
1388 token: command.token.clone(),
1389 authentication,
1390 tenant: None,
1391 })
1392}
1393
1394async fn run_push_config_command(
1395 cli: &Cli,
1396 matches: &ArgMatches,
1397 command: &PushConfigCommand,
1398) -> Result<(), CliError> {
1399 match command {
1400 PushConfigCommand::Create(command) => {
1401 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1402 let mut config = build_push_notification_config(command)?;
1403 config.task_id = command.task_id.clone();
1404 config.tenant = tenant;
1405 let result = client.create_push_config(&config).await;
1406 let response = finish_client_call(client, result).await?;
1407 print_output(&response, cli)?;
1408 }
1409 PushConfigCommand::Get(command) => {
1410 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1411 let result = client
1412 .get_push_config(&GetTaskPushNotificationConfigRequest {
1413 task_id: command.task_id.clone(),
1414 id: command.id.clone(),
1415 tenant,
1416 })
1417 .await;
1418 let response = finish_client_call(client, result).await?;
1419 print_output(&response, cli)?;
1420 }
1421 PushConfigCommand::List(command) => {
1422 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1423 let result = client
1424 .list_push_configs(&ListTaskPushNotificationConfigsRequest {
1425 task_id: command.task_id.clone(),
1426 page_size: command.page_size,
1427 page_token: command.page_token.clone(),
1428 tenant,
1429 })
1430 .await;
1431 let response = finish_client_call(client, result).await?;
1432 print_output(&response, cli)?;
1433 }
1434 PushConfigCommand::Delete(command) => {
1435 let ResolvedClient { client, tenant } = resolve_client(cli, matches).await?;
1436 let result = client
1437 .delete_push_config(&DeleteTaskPushNotificationConfigRequest {
1438 task_id: command.task_id.clone(),
1439 id: command.id.clone(),
1440 tenant,
1441 })
1442 .await;
1443 finish_client_call(client, result).await?;
1444 print_output(
1445 &PushConfigDeleted {
1446 deleted: true,
1447 task_id: command.task_id.clone(),
1448 id: command.id.clone(),
1449 },
1450 cli,
1451 )?;
1452 }
1453 }
1454
1455 Ok(())
1456}
1457
1458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1466pub enum ConfigSource {
1467 Flag,
1468 EnvVar,
1469 LocalFile,
1470 GlobalFile,
1471 Default,
1472}
1473
1474impl ConfigSource {
1475 fn label(self) -> &'static str {
1476 match self {
1477 ConfigSource::Flag => "flag",
1478 ConfigSource::EnvVar => "environment variable",
1479 ConfigSource::LocalFile => "local .env file",
1480 ConfigSource::GlobalFile => "global .env file",
1481 ConfigSource::Default => "built-in default",
1482 }
1483 }
1484}
1485
1486fn load_dotenv_config(args: &[OsString]) -> HashMap<String, ConfigSource> {
1496 let real_env_keys: HashSet<String> = std::env::vars_os()
1500 .filter_map(|(key, _)| key.into_string().ok())
1501 .filter(|key| key.starts_with("A2ACLI_"))
1502 .collect();
1503
1504 let mut provenance = HashMap::new();
1505
1506 if let Some(global_path) = global_config_path() {
1507 apply_dotenv_file(
1508 &global_path,
1509 ConfigSource::GlobalFile,
1510 &real_env_keys,
1511 &mut provenance,
1512 );
1513 }
1514
1515 let local_path = find_flag_value(args, "--config")
1516 .map(PathBuf::from)
1517 .or_else(find_local_dotenv);
1518 if let Some(local_path) = local_path {
1519 apply_dotenv_file(
1520 &local_path,
1521 ConfigSource::LocalFile,
1522 &real_env_keys,
1523 &mut provenance,
1524 );
1525 }
1526
1527 provenance
1528}
1529
1530fn find_flag_value(args: &[OsString], flag: &str) -> Option<String> {
1533 let prefix = format!("{flag}=");
1534 for (index, arg) in args.iter().enumerate() {
1535 let Some(arg) = arg.to_str() else { continue };
1536 if arg == flag {
1537 return args.get(index + 1)?.to_str().map(str::to_string);
1538 }
1539 if let Some(value) = arg.strip_prefix(&prefix) {
1540 return Some(value.to_string());
1541 }
1542 }
1543 None
1544}
1545
1546fn global_config_path() -> Option<PathBuf> {
1548 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
1549 return Some(PathBuf::from(xdg).join("a2a-cli").join(".env"));
1550 }
1551 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
1552 Some(
1553 PathBuf::from(home)
1554 .join(".config")
1555 .join("a2a-cli")
1556 .join(".env"),
1557 )
1558}
1559
1560fn find_local_dotenv() -> Option<PathBuf> {
1563 let mut dir = std::env::current_dir().ok()?;
1564 loop {
1565 let candidate = dir.join(".env");
1566 if candidate.is_file() {
1567 return Some(candidate);
1568 }
1569 if !dir.pop() {
1570 return None;
1571 }
1572 }
1573}
1574
1575fn apply_dotenv_file(
1576 path: &Path,
1577 source: ConfigSource,
1578 real_env_keys: &HashSet<String>,
1579 provenance: &mut HashMap<String, ConfigSource>,
1580) {
1581 let Ok(contents) = std::fs::read_to_string(path) else {
1582 return;
1583 };
1584 warn_if_world_readable(path);
1585
1586 for (key, value) in parse_dotenv(&contents) {
1587 if !key.starts_with("A2ACLI_") || real_env_keys.contains(&key) {
1588 continue;
1589 }
1590 unsafe {
1595 std::env::set_var(&key, &value);
1596 }
1597 provenance.insert(key, source);
1598 }
1599}
1600
1601#[cfg(unix)]
1605fn warn_if_world_readable(path: &Path) {
1606 use std::os::unix::fs::PermissionsExt;
1607 let Ok(metadata) = std::fs::metadata(path) else {
1608 return;
1609 };
1610 let mode = metadata.permissions().mode();
1611 if mode & 0o077 != 0 {
1612 eprintln!(
1613 "warning: {} is readable by other users (mode {:o}); it may contain credentials — \
1614 consider `chmod 600 {}`",
1615 path.display(),
1616 mode & 0o777,
1617 path.display()
1618 );
1619 }
1620}
1621
1622#[cfg(not(unix))]
1623fn warn_if_world_readable(_path: &Path) {}
1624
1625fn parse_dotenv(contents: &str) -> Vec<(String, String)> {
1629 contents
1630 .lines()
1631 .filter_map(|line| {
1632 let line = line.trim();
1633 if line.is_empty() || line.starts_with('#') {
1634 return None;
1635 }
1636 let line = line.strip_prefix("export ").unwrap_or(line);
1637 let (key, value) = line.split_once('=')?;
1638 let key = key.trim().to_string();
1639 let value = value.trim();
1640 let value = ["\"", "'"]
1641 .iter()
1642 .find_map(|quote| {
1643 value
1644 .strip_prefix(quote)
1645 .and_then(|rest| rest.strip_suffix(quote))
1646 })
1647 .unwrap_or(value);
1648 Some((key, value.to_string()))
1649 })
1650 .collect()
1651}
1652
1653#[derive(Debug, Clone, Serialize)]
1654struct ConfigSetting {
1655 name: String,
1656 value: String,
1657 source: String,
1658}
1659
1660#[derive(Debug, Clone, Serialize)]
1661struct ConfigSettings {
1662 settings: Vec<ConfigSetting>,
1663}
1664
1665impl TextRender for ConfigSettings {
1666 fn render_text(&self) -> String {
1667 let mut out = TextOutput::default();
1668 for setting in &self.settings {
1669 out.field(
1670 &setting.name,
1671 format!("{} (source: {})", setting.value, setting.source),
1672 );
1673 }
1674 out.finish()
1675 }
1676}
1677
1678fn redact_secret(value: &Option<String>) -> String {
1679 match value {
1680 Some(_) => "(set, redacted)".to_string(),
1681 None => "(not set)".to_string(),
1682 }
1683}
1684
1685fn run_config_command(
1686 cli: &Cli,
1687 matches: &ArgMatches,
1688 dotenv_provenance: &HashMap<String, ConfigSource>,
1689 command: &ConfigCommand,
1690) -> Result<(), CliError> {
1691 match command {
1692 ConfigCommand::Show => {
1693 let settings = effective_config_settings(cli, matches, dotenv_provenance);
1694 print_output(&settings, cli)?;
1695 }
1696 }
1697 Ok(())
1698}
1699
1700fn effective_config_settings(
1703 cli: &Cli,
1704 matches: &ArgMatches,
1705 dotenv_provenance: &HashMap<String, ConfigSource>,
1706) -> ConfigSettings {
1707 let mut settings = Vec::new();
1708 let mut add = |id: &str, name: &str, value: String, env_key: &str| {
1709 let source = match matches.value_source(id) {
1710 Some(ValueSource::CommandLine) => ConfigSource::Flag,
1711 Some(ValueSource::EnvVariable) => dotenv_provenance
1712 .get(env_key)
1713 .copied()
1714 .unwrap_or(ConfigSource::EnvVar),
1715 _ => ConfigSource::Default,
1716 };
1717 settings.push(ConfigSetting {
1718 name: name.to_string(),
1719 value,
1720 source: source.label().to_string(),
1721 });
1722 };
1723
1724 add(
1725 "agent_card",
1726 "agent-card",
1727 cli.agent_card
1728 .clone()
1729 .unwrap_or_else(|| "(not set)".to_string()),
1730 "A2ACLI_AGENT_CARD",
1731 );
1732 add(
1733 "endpoint",
1734 "endpoint",
1735 cli.endpoint
1736 .clone()
1737 .unwrap_or_else(|| "(not set)".to_string()),
1738 "A2ACLI_ENDPOINT",
1739 );
1740 add(
1741 "base_url",
1742 "base-url",
1743 cli.base_url.clone(),
1744 "A2ACLI_BASE_URL",
1745 );
1746 add(
1747 "transport",
1748 "transport",
1749 if cli.transport.is_empty() {
1750 "(agent card's own order)".to_string()
1751 } else {
1752 cli.transport
1753 .iter()
1754 .map(|binding| format!("{binding:?}").to_lowercase())
1755 .collect::<Vec<_>>()
1756 .join(",")
1757 },
1758 "A2ACLI_TRANSPORT",
1759 );
1760 add(
1761 "a2a_version",
1762 "a2a-version",
1763 cli.a2a_version.clone().unwrap_or_else(|| {
1764 format!("(negotiated from the agent card; a2acli supports {VERSION})")
1765 }),
1766 "A2ACLI_A2A_VERSION",
1767 );
1768 add(
1769 "bearer",
1770 "bearer",
1771 redact_secret(&cli.bearer),
1772 "A2ACLI_BEARER",
1773 );
1774 add(
1775 "api_key",
1776 "api-key",
1777 redact_secret(&cli.api_key),
1778 "A2ACLI_API_KEY",
1779 );
1780 add(
1781 "insecure",
1782 "insecure",
1783 cli.insecure.to_string(),
1784 "A2ACLI_INSECURE",
1785 );
1786 add("debug", "debug", cli.debug.to_string(), "A2ACLI_DEBUG");
1787 add(
1788 "tenant",
1789 "tenant",
1790 cli.tenant
1791 .clone()
1792 .unwrap_or_else(|| "(not set)".to_string()),
1793 "A2ACLI_TENANT",
1794 );
1795 add(
1796 "output",
1797 "output",
1798 format!("{:?}", cli.output).to_lowercase(),
1799 "A2ACLI_OUTPUT",
1800 );
1801 add(
1802 "compact",
1803 "compact",
1804 cli.compact.to_string(),
1805 "A2ACLI_COMPACT",
1806 );
1807 add(
1808 "async_mode",
1809 "async",
1810 cli.async_mode.to_string(),
1811 "A2ACLI_ASYNC",
1812 );
1813 add("wait", "wait", cli.wait.to_string(), "A2ACLI_WAIT");
1814 add(
1815 "poll_interval",
1816 "poll-interval",
1817 format!("{:?}", cli.poll_interval),
1818 "A2ACLI_POLL_INTERVAL",
1819 );
1820 add(
1821 "timeout",
1822 "timeout",
1823 format!("{:?}", cli.timeout),
1824 "A2ACLI_TIMEOUT",
1825 );
1826
1827 settings.push(ConfigSetting {
1831 name: "resolved-card".to_string(),
1832 value: match &cli.endpoint {
1833 Some(endpoint) => format!("(none; --endpoint {endpoint})"),
1834 None => {
1835 match normalize_card_reference(cli.agent_card.as_deref().unwrap_or(&cli.base_url)) {
1836 CardReference::File(path) => format!("file://{}", path.display()),
1837 CardReference::Url(url) => url,
1838 }
1839 }
1840 },
1841 source: "derived".to_string(),
1842 });
1843
1844 ConfigSettings { settings }
1845}
1846
1847struct ResolvedClient {
1852 client: A2AClient<Box<dyn a2a_client::Transport>>,
1853 tenant: Option<String>,
1854}
1855
1856fn parse_protocol_version(value: &str) -> Option<(u32, u32)> {
1860 let mut parts = value.trim().split('.');
1861 let major = parts.next()?.parse().ok()?;
1862 let minor = match parts.next() {
1863 Some(minor) => minor.parse().ok()?,
1864 None => 0,
1865 };
1866 Some((major, minor))
1867}
1868
1869fn supported_protocol_version() -> (u32, u32) {
1871 parse_protocol_version(VERSION).unwrap_or((1, 0))
1872}
1873
1874struct NegotiatedVersion {
1877 version: String,
1878 note: Option<String>,
1881}
1882
1883fn negotiate_a2a_version(
1889 explicit: Option<&str>,
1890 declared: &str,
1891 supported: (u32, u32),
1892) -> NegotiatedVersion {
1893 let supported_label = format!("{}.{}", supported.0, supported.1);
1897
1898 if let Some(requested) = explicit {
1899 let note = (parse_protocol_version(requested) != Some(supported)).then(|| {
1900 format!("signaling A2A-Version {requested} (a2acli supports {supported_label})")
1901 });
1902 return NegotiatedVersion {
1903 version: requested.to_string(),
1904 note,
1905 };
1906 }
1907
1908 let Some(declared_version) = parse_protocol_version(declared) else {
1909 return NegotiatedVersion {
1910 version: supported_label.clone(),
1911 note: Some(format!(
1912 "agent card declares an unparseable protocol version ({declared}); \
1913 signaling {supported_label}"
1914 )),
1915 };
1916 };
1917
1918 if declared_version.0 != 1 {
1921 return NegotiatedVersion {
1922 version: supported_label.clone(),
1923 note: Some(format!(
1924 "agent card declares protocol version {declared}, which is outside 1.x; \
1925 signaling {supported_label} rather than negotiating below 1.0"
1926 )),
1927 };
1928 }
1929
1930 if declared_version < supported {
1931 let version = format!("{}.{}", declared_version.0, declared_version.1);
1932 return NegotiatedVersion {
1933 note: Some(format!(
1934 "negotiated A2A-Version down to {version} (agent card declares {version}, \
1935 a2acli supports {supported_label})"
1936 )),
1937 version,
1938 };
1939 }
1940
1941 NegotiatedVersion {
1942 version: supported_label,
1943 note: None,
1944 }
1945}
1946
1947fn validate_a2a_version(value: Option<&str>) -> Result<(), CliError> {
1952 let Some(requested) = value else {
1953 return Ok(());
1954 };
1955 match parse_protocol_version(requested) {
1956 Some((1, _)) => Ok(()),
1957 Some(_) => Err(CliError::InvalidInput(format!(
1958 "--a2a-version must be 1.x, got {requested}: A2A reads an empty or pre-1.0 \
1959 version as 0.3, and a2acli never signals below 1.0"
1960 ))),
1961 None => Err(CliError::InvalidInput(format!(
1962 "--a2a-version must be a version like 1.0, got {requested}"
1963 ))),
1964 }
1965}
1966
1967struct VersionInterceptor {
1972 version: String,
1973}
1974
1975#[async_trait::async_trait]
1976impl a2a_client::middleware::CallInterceptor for VersionInterceptor {
1977 async fn before(
1978 &self,
1979 _method: &str,
1980 params: &mut a2a_client::ServiceParams,
1981 ) -> Result<(), A2AError> {
1982 params.insert(SVC_PARAM_VERSION.to_string(), vec![self.version.clone()]);
1983 Ok(())
1984 }
1985}
1986
1987#[derive(Debug, Clone, PartialEq, Eq)]
1991enum AgentSelection {
1992 Card(String),
1993 Endpoint { url: String, binding: Binding },
1994}
1995
1996fn resolve_agent_selection(cli: &Cli, matches: &ArgMatches) -> Result<AgentSelection, CliError> {
1997 match (&cli.agent_card, &cli.endpoint) {
1998 (Some(_), Some(_)) => Err(CliError::InvalidInput(
1999 "--endpoint and --agent-card are mutually exclusive: --endpoint names an interface \
2000 directly, so there is no card to resolve"
2001 .to_string(),
2002 )),
2003 (None, Some(url)) => match cli.transport.as_slice() {
2007 [binding] => Ok(AgentSelection::Endpoint {
2008 url: url.clone(),
2009 binding: *binding,
2010 }),
2011 transports => Err(CliError::InvalidInput(format!(
2012 "--endpoint requires exactly one --transport (got {}): there is no agent card \
2013 to declare the interface's protocol binding",
2014 transports.len()
2015 ))),
2016 },
2017 (Some(reference), None) => Ok(AgentSelection::Card(reference.clone())),
2018 (None, None) => Ok(AgentSelection::Card(base_url_card_reference(cli, matches))),
2019 }
2020}
2021
2022fn synthesized_endpoint_card(url: &str, binding: Binding) -> AgentCard {
2026 AgentCard {
2027 name: url.to_string(),
2028 description: "synthesized from --endpoint; no agent card was resolved".to_string(),
2029 version: VERSION.to_string(),
2030 supported_interfaces: vec![AgentInterface::new(url, binding.protocol())],
2031 capabilities: AgentCapabilities::default(),
2032 default_input_modes: vec![],
2033 default_output_modes: vec![],
2034 skills: vec![],
2035 provider: None,
2036 documentation_url: None,
2037 icon_url: None,
2038 security_schemes: None,
2039 security_requirements: None,
2040 signatures: None,
2041 }
2042}
2043
2044async fn resolve_client(cli: &Cli, matches: &ArgMatches) -> Result<ResolvedClient, CliError> {
2045 let card = resolve_agent_card(cli, matches).await?;
2046
2047 let mut builder = A2AClientFactory::builder();
2048 if !cli.transport.is_empty() {
2049 let preferred = cli
2050 .transport
2051 .iter()
2052 .map(|binding| binding.protocol().to_string())
2053 .collect();
2054 builder = builder.preferred_bindings(preferred);
2055 }
2056 if cli.insecure {
2057 let insecure = build_insecure_reqwest_client()?;
2058 builder = builder
2059 .register(Arc::new(a2a_client::jsonrpc::JsonRpcTransportFactory::new(
2060 Some(insecure.clone()),
2061 )))
2062 .register(Arc::new(a2a_client::rest::RestTransportFactory::new(Some(
2063 insecure,
2064 ))));
2065 }
2066 let mut interceptors: Vec<Arc<dyn a2a_client::middleware::CallInterceptor>> = Vec::new();
2071 if let Some(token) = &cli.bearer {
2072 interceptors.push(Arc::new(AuthInterceptor::bearer(token.clone())));
2073 }
2074 if let Some(api_key) = &cli.api_key {
2075 interceptors.push(Arc::new(AuthInterceptor::custom(
2076 "X-API-Key",
2077 api_key.clone(),
2078 )));
2079 }
2080 for param in &cli.svc_params {
2081 interceptors.push(Arc::new(AuthInterceptor::custom(
2082 param.name.clone(),
2083 param.value.clone(),
2084 )));
2085 }
2086 if cli.debug {
2087 interceptors.push(Arc::new(a2a_client::middleware::LoggingInterceptor));
2088 }
2089
2090 let factory = builder.build();
2091 let (client, interface) = factory.create_from_card_with_interface(&card).await?;
2092
2093 let negotiated = negotiate_a2a_version(
2094 cli.a2a_version.as_deref(),
2095 &interface.protocol_version,
2096 supported_protocol_version(),
2097 );
2098 if let Some(note) = &negotiated.note {
2101 eprintln!("warning: {note}");
2102 }
2103 interceptors.push(Arc::new(VersionInterceptor {
2104 version: negotiated.version,
2105 }));
2106
2107 let tenant = cli.tenant.clone();
2117 Ok(ResolvedClient {
2118 client: client.with_interceptors(interceptors),
2119 tenant,
2120 })
2121}
2122
2123#[derive(Debug, Clone, PartialEq, Eq)]
2127enum CardReference {
2128 File(PathBuf),
2129 Url(String),
2130}
2131
2132fn looks_like_file_path(reference: &str) -> bool {
2138 reference.starts_with('/')
2139 || reference.starts_with("./")
2140 || reference.starts_with("../")
2141 || reference.starts_with('\\')
2146 || reference.starts_with(".\\")
2147 || reference.starts_with("..\\")
2148 || has_windows_drive_prefix(reference)
2149 || std::fs::metadata(reference).is_ok()
2150}
2151
2152fn has_windows_drive_prefix(reference: &str) -> bool {
2158 let bytes = reference.as_bytes();
2159 bytes.len() >= 3
2160 && bytes[0].is_ascii_alphabetic()
2161 && bytes[1] == b':'
2162 && (bytes[2] == b'\\' || bytes[2] == b'/')
2163}
2164
2165fn is_loopback_host(host: &str) -> bool {
2170 if let Ok(address) = host.parse::<std::net::IpAddr>() {
2174 return address.is_loopback();
2175 }
2176
2177 let host = match host.strip_prefix('[') {
2178 Some(rest) => rest.split_once(']').map_or(rest, |(inside, _)| inside),
2180 None => host.split_once(':').map_or(host, |(head, _)| head),
2182 };
2183
2184 host == "localhost"
2185 || host
2186 .parse::<std::net::IpAddr>()
2187 .is_ok_and(|address| address.is_loopback())
2188}
2189
2190fn normalize_card_reference(reference: &str) -> CardReference {
2195 if let Some(rest) = reference.strip_prefix("file://") {
2196 let path = match rest.strip_prefix('/') {
2197 Some(without_slash) if has_windows_drive_prefix(without_slash) => without_slash,
2202 _ => rest,
2203 };
2204 return CardReference::File(PathBuf::from(path));
2205 }
2206
2207 if !reference.contains("://") {
2208 if looks_like_file_path(reference) {
2209 return CardReference::File(PathBuf::from(reference));
2210 }
2211 let scheme = if is_loopback_host(reference) {
2212 "http"
2213 } else {
2214 "https"
2215 };
2216 return CardReference::Url(append_well_known_path(&format!("{scheme}://{reference}")));
2217 }
2218
2219 CardReference::Url(append_well_known_path(reference))
2220}
2221
2222fn append_well_known_path(url: &str) -> String {
2225 let trimmed = url.trim_end_matches('/');
2226 let has_path = trimmed
2227 .split_once("://")
2228 .is_some_and(|(_, rest)| rest.contains('/'));
2229 if has_path {
2230 url.to_string()
2231 } else {
2232 format!("{trimmed}{WELL_KNOWN_AGENT_CARD_PATH}")
2233 }
2234}
2235
2236fn base_url_card_reference(cli: &Cli, matches: &ArgMatches) -> String {
2242 if matches!(
2243 matches.value_source("base_url"),
2244 Some(ValueSource::CommandLine)
2245 ) {
2246 eprintln!(
2247 "warning: --base-url is deprecated; use --agent-card, which also accepts a full \
2248 card URL or a local file path"
2249 );
2250 }
2251 cli.base_url.clone()
2252}
2253
2254async fn resolve_agent_card(cli: &Cli, matches: &ArgMatches) -> Result<AgentCard, CliError> {
2255 warn_if_insecure_with_credentials(cli);
2256
2257 let reference = match resolve_agent_selection(cli, matches)? {
2258 AgentSelection::Card(reference) => reference,
2259 AgentSelection::Endpoint { url, binding } => {
2260 return Ok(synthesized_endpoint_card(&url, binding));
2261 }
2262 };
2263
2264 match normalize_card_reference(&reference) {
2265 CardReference::File(path) => read_agent_card_file(&path),
2266 CardReference::Url(url) => {
2267 let client = if cli.insecure {
2268 build_insecure_reqwest_client()?
2269 } else {
2270 Client::new()
2271 };
2272 let request = apply_request_auth(client.get(url), cli);
2273 let response = request.send().await?.error_for_status()?;
2274 Ok(response.json::<AgentCard>().await?)
2275 }
2276 }
2277}
2278
2279fn read_agent_card_file(path: &Path) -> Result<AgentCard, CliError> {
2285 let text = std::fs::read_to_string(path).map_err(|source| CliError::CardFile {
2286 path: path.display().to_string(),
2287 source,
2288 })?;
2289 serde_json::from_str(&text)
2290 .map_err(|error| CliError::CardInvalid(format!("{}: {error}", path.display())))
2291}
2292
2293fn apply_request_auth(mut request: RequestBuilder, cli: &Cli) -> RequestBuilder {
2294 if let Some(token) = &cli.bearer {
2295 request = request.bearer_auth(token);
2296 }
2297 if let Some(api_key) = &cli.api_key {
2298 request = request.header("X-API-Key", api_key);
2299 }
2300 for param in &cli.svc_params {
2301 request = request.header(¶m.name, ¶m.value);
2302 }
2303 request
2304}
2305
2306#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
2308fn build_insecure_reqwest_client() -> Result<Client, CliError> {
2309 Client::builder()
2310 .danger_accept_invalid_certs(true)
2311 .build()
2312 .map_err(CliError::Http)
2313}
2314
2315#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
2318fn build_insecure_reqwest_client() -> Result<Client, CliError> {
2319 Client::builder().build().map_err(CliError::Http)
2320}
2321
2322fn warn_if_insecure_with_credentials(cli: &Cli) {
2326 if !cli.insecure {
2327 return;
2328 }
2329 let sends_credentials = cli.bearer.is_some() || cli.api_key.is_some();
2330 if sends_credentials {
2331 eprintln!(
2332 "warning: --insecure disables TLS certificate verification, and a credential \
2333 (--bearer/--api-key) is being sent over this connection — use only against a \
2334 trusted development endpoint"
2335 );
2336 } else {
2337 eprintln!(
2338 "warning: --insecure disables TLS certificate verification — use only against a \
2339 trusted development endpoint"
2340 );
2341 }
2342}
2343
2344trait TextRender {
2350 fn render_text(&self) -> String;
2351}
2352
2353#[derive(Default)]
2356struct TextOutput {
2357 lines: Vec<String>,
2358}
2359
2360impl TextOutput {
2361 fn field(&mut self, label: &str, value: impl std::fmt::Display) -> &mut Self {
2362 self.lines.push(format!("{label}: {value}"));
2363 self
2364 }
2365
2366 fn raw(&mut self, line: impl Into<String>) -> &mut Self {
2367 self.lines.push(line.into());
2368 self
2369 }
2370
2371 fn block(&mut self, label: &str, content: &str) -> &mut Self {
2374 self.lines.push(format!("{label}:"));
2375 self.lines
2376 .extend(content.lines().map(|line| line.to_string()));
2377 self.lines.push(String::new());
2378 self
2379 }
2380
2381 fn finish(self) -> String {
2382 let mut text = self.lines.join("\n");
2383 while text.ends_with('\n') {
2384 text.pop();
2385 }
2386 text
2387 }
2388}
2389
2390fn task_state_label(state: &TaskState) -> &'static str {
2393 match state {
2394 TaskState::Unspecified => "UNSPECIFIED",
2395 TaskState::Submitted => "SUBMITTED",
2396 TaskState::Working => "WORKING",
2397 TaskState::Completed => "COMPLETED",
2398 TaskState::Failed => "FAILED",
2399 TaskState::Canceled => "CANCELED",
2400 TaskState::InputRequired => "INPUT_REQUIRED",
2401 TaskState::Rejected => "REJECTED",
2402 TaskState::AuthRequired => "AUTH_REQUIRED",
2403 }
2404}
2405
2406fn render_part(out: &mut TextOutput, part: &Part) {
2410 match &part.content {
2411 PartContent::Text(text) => {
2412 out.block("Text", text);
2413 }
2414 PartContent::Data(value) => {
2415 let pretty = serde_json::to_string_pretty(value)
2416 .unwrap_or_else(|_| serde_json::Value::Null.to_string());
2417 out.block("Data", &pretty);
2418 }
2419 PartContent::Raw(bytes) => {
2420 let name = part.filename.as_deref().unwrap_or("(unnamed)");
2421 match &part.media_type {
2422 Some(media_type) => out.field(
2423 "File",
2424 format!("{name} ({media_type}, {} bytes)", bytes.len()),
2425 ),
2426 None => out.field("File", format!("{name} ({} bytes)", bytes.len())),
2427 };
2428 }
2429 PartContent::Url(url) => {
2430 match &part.media_type {
2431 Some(media_type) => out.field("File", format!("{url} ({media_type})")),
2432 None => out.field("File", url),
2433 };
2434 }
2435 }
2436}
2437
2438fn render_message_parts(out: &mut TextOutput, message: &Message) {
2439 if message.parts.is_empty() {
2440 out.field("Parts", "(none)");
2441 return;
2442 }
2443 for part in &message.parts {
2444 render_part(out, part);
2445 }
2446}
2447
2448impl TextRender for AgentCard {
2449 fn render_text(&self) -> String {
2450 let mut out = TextOutput::default();
2451 out.field("Name", &self.name)
2452 .field("Description", &self.description)
2453 .field("Version", &self.version)
2454 .field("Streaming", self.capabilities.streaming.unwrap_or(false))
2455 .field(
2456 "Push Notifications",
2457 self.capabilities.push_notifications.unwrap_or(false),
2458 )
2459 .field(
2460 "Extended Card",
2461 self.capabilities.extended_agent_card.unwrap_or(false),
2462 );
2463
2464 let interfaces = if self.supported_interfaces.is_empty() {
2465 " (none)".to_string()
2466 } else {
2467 self.supported_interfaces
2468 .iter()
2469 .map(|interface| {
2470 format!(
2471 " - {} {} (v{})",
2472 interface.protocol_binding, interface.url, interface.protocol_version
2473 )
2474 })
2475 .collect::<Vec<_>>()
2476 .join("\n")
2477 };
2478 out.block("Interfaces", &interfaces);
2479 out.field("Skills", self.skills.len());
2480 out.finish()
2481 }
2482}
2483
2484impl TextRender for Task {
2485 fn render_text(&self) -> String {
2486 let mut out = TextOutput::default();
2487 out.field("Task ID", &self.id)
2488 .field("Context ID", &self.context_id)
2489 .field("State", task_state_label(&self.status.state));
2490
2491 if let Some(message) = &self.status.message {
2492 render_message_parts(&mut out, message);
2493 }
2494
2495 if let Some(artifacts) = &self.artifacts {
2496 for artifact in artifacts {
2497 let label = artifact.name.as_deref().unwrap_or(&artifact.artifact_id);
2498 out.raw(format!("Artifact: {label}"));
2499 for part in &artifact.parts {
2500 render_part(&mut out, part);
2501 }
2502 }
2503 }
2504
2505 if matches!(
2506 self.status.state,
2507 TaskState::InputRequired | TaskState::AuthRequired
2508 ) {
2509 out.field(
2510 "Resume with",
2511 format!("a2acli send --task-id {} \"<reply>\"", self.id),
2512 );
2513 }
2514
2515 out.finish()
2516 }
2517}
2518
2519impl TextRender for Message {
2520 fn render_text(&self) -> String {
2521 let mut out = TextOutput::default();
2522 if let Some(context_id) = &self.context_id {
2523 out.field("Context ID", context_id);
2524 }
2525 if let Some(task_id) = &self.task_id {
2526 out.field("Task ID", task_id);
2527 }
2528 render_message_parts(&mut out, self);
2529 out.finish()
2530 }
2531}
2532
2533impl TextRender for SendMessageResponse {
2534 fn render_text(&self) -> String {
2535 match self {
2536 SendMessageResponse::Task(task) => task.render_text(),
2537 SendMessageResponse::Message(message) => message.render_text(),
2538 }
2539 }
2540}
2541
2542impl TextRender for ListTasksResponse {
2543 fn render_text(&self) -> String {
2544 let mut out = TextOutput::default();
2545 out.field("Total", self.total_size)
2546 .field("Page Size", self.page_size);
2547 if !self.next_page_token.is_empty() {
2548 out.field("Next Page Token", &self.next_page_token);
2549 }
2550 if self.tasks.is_empty() {
2551 out.field("Tasks", "(none)");
2552 } else {
2553 for task in &self.tasks {
2554 out.block("Task", &task.render_text());
2555 }
2556 }
2557 out.finish()
2558 }
2559}
2560
2561impl TextRender for TaskPushNotificationConfig {
2562 fn render_text(&self) -> String {
2563 let mut out = TextOutput::default();
2564 if let Some(id) = &self.id {
2565 out.field("Config ID", id);
2566 }
2567 out.field("Task ID", &self.task_id).field("URL", &self.url);
2568 if let Some(token) = &self.token {
2569 out.field("Token", token);
2570 }
2571 if let Some(auth) = &self.authentication {
2572 out.field("Auth Scheme", &auth.scheme);
2573 }
2574 out.finish()
2575 }
2576}
2577
2578impl TextRender for ListTaskPushNotificationConfigsResponse {
2579 fn render_text(&self) -> String {
2580 let mut out = TextOutput::default();
2581 if let Some(token) = &self.next_page_token {
2582 out.field("Next Page Token", token);
2583 }
2584 if self.configs.is_empty() {
2585 out.field("Configs", "(none)");
2586 } else {
2587 for config in &self.configs {
2588 out.block("Push Config", &config.render_text());
2589 }
2590 }
2591 out.finish()
2592 }
2593}
2594
2595#[derive(Debug, Clone, Serialize)]
2599#[serde(rename_all = "camelCase")]
2600struct PushConfigDeleted {
2601 deleted: bool,
2602 task_id: String,
2603 id: String,
2604}
2605
2606impl TextRender for PushConfigDeleted {
2607 fn render_text(&self) -> String {
2608 let mut out = TextOutput::default();
2609 out.field("Deleted", self.deleted)
2610 .field("Task ID", &self.task_id)
2611 .field("Config ID", &self.id);
2612 out.finish()
2613 }
2614}
2615
2616impl TextRender for StreamResponse {
2617 fn render_text(&self) -> String {
2618 match self {
2619 StreamResponse::Task(task) => task.render_text(),
2620 StreamResponse::Message(message) => message.render_text(),
2621 StreamResponse::StatusUpdate(event) => {
2622 let mut out = TextOutput::default();
2623 out.field("Task ID", &event.task_id)
2624 .field("Context ID", &event.context_id)
2625 .field("State", task_state_label(&event.status.state));
2626 if matches!(
2627 event.status.state,
2628 TaskState::InputRequired | TaskState::AuthRequired
2629 ) {
2630 out.field(
2631 "Resume with",
2632 format!("a2acli send --task-id {} \"<reply>\"", event.task_id),
2633 );
2634 }
2635 out.finish()
2636 }
2637 StreamResponse::ArtifactUpdate(event) => {
2638 let mut out = TextOutput::default();
2639 out.field("Task ID", &event.task_id)
2640 .field("Context ID", &event.context_id);
2641 let label = event
2642 .artifact
2643 .name
2644 .as_deref()
2645 .unwrap_or(&event.artifact.artifact_id);
2646 out.raw(format!("Artifact: {label}"));
2647 for part in &event.artifact.parts {
2648 render_part(&mut out, part);
2649 }
2650 out.finish()
2651 }
2652 }
2653 }
2654}
2655
2656fn print_output<T: Serialize + TextRender>(value: &T, cli: &Cli) -> Result<(), CliError> {
2660 match cli.output {
2661 OutputFormat::Text => {
2662 println!("{}", value.render_text());
2663 Ok(())
2664 }
2665 OutputFormat::Json => print_json(value, cli.compact),
2666 }
2667}
2668
2669fn print_json<T: Serialize>(value: &T, compact: bool) -> Result<(), CliError> {
2670 if compact {
2671 println!("{}", serde_json::to_string(value)?);
2672 } else {
2673 println!("{}", serde_json::to_string_pretty(value)?);
2674 }
2675 Ok(())
2676}
2677
2678fn parse_header(input: &str) -> Result<HeaderArg, String> {
2679 let (name, value) = input
2680 .split_once(':')
2681 .ok_or_else(|| "header must be in NAME:VALUE format".to_string())?;
2682
2683 let name = name.trim();
2684 let value = value.trim();
2685
2686 if name.is_empty() {
2687 return Err("header name cannot be empty".to_string());
2688 }
2689
2690 Ok(HeaderArg {
2691 name: name.to_string(),
2692 value: value.to_string(),
2693 })
2694}
2695
2696async fn finish_client_call<T: a2a_client::Transport, V>(
2697 client: A2AClient<T>,
2698 result: Result<V, A2AError>,
2699) -> Result<V, CliError> {
2700 match result {
2701 Ok(value) => {
2702 client.destroy().await?;
2703 Ok(value)
2704 }
2705 Err(error) => {
2706 let _ = client.destroy().await;
2707 Err(error.into())
2708 }
2709 }
2710}
2711
2712async fn consume_stream<T: a2a_client::Transport, V: Serialize + TextRender + TaskOutcome>(
2718 client: A2AClient<T>,
2719 mut stream: BoxStream<'static, Result<V, A2AError>>,
2720 cli: &Cli,
2721) -> Result<(), CliError> {
2722 loop {
2723 match stream.next().await {
2724 Some(Ok(value)) => {
2725 value.warn_outcome();
2726 let printed = match cli.output {
2727 OutputFormat::Text => {
2728 println!("{}", value.render_text());
2729 Ok(())
2730 }
2731 OutputFormat::Json => serde_json::to_string(&value)
2732 .map(|line| println!("{line}"))
2733 .map_err(CliError::from),
2734 };
2735 if let Err(error) = printed {
2736 let _ = client.destroy().await;
2737 return Err(error);
2738 }
2739 }
2740 Some(Err(error)) => {
2741 let _ = client.destroy().await;
2742 return Err(error.into());
2743 }
2744 None => {
2745 client.destroy().await?;
2746 return Ok(());
2747 }
2748 }
2749 }
2750}
2751
2752#[cfg(test)]
2753mod tests {
2754 use super::*;
2755 use a2a_client::{ServiceParams, Transport};
2756 use a2a_server::jsonrpc::jsonrpc_router;
2757 use a2a_server::rest::rest_router;
2758 use a2a_server::{
2759 RequestHandler, ServiceParams as HandlerServiceParams, WELL_KNOWN_AGENT_CARD_PATH,
2760 };
2761 use async_trait::async_trait;
2762 use axum::routing::get;
2763 use axum::{Json, Router};
2764 use futures::stream;
2765 use reqwest::header;
2766 use serde::ser;
2767 use std::collections::BTreeMap;
2768 use std::sync::Mutex;
2769 use tokio::net::TcpListener;
2770
2771 struct TestTransport {
2772 destroy_error: Option<A2AError>,
2773 }
2774
2775 #[async_trait]
2776 impl Transport for TestTransport {
2777 async fn send_message(
2778 &self,
2779 _params: &ServiceParams,
2780 _req: &SendMessageRequest,
2781 ) -> Result<SendMessageResponse, A2AError> {
2782 Err(A2AError::unsupported_operation("unused"))
2783 }
2784
2785 async fn send_streaming_message(
2786 &self,
2787 _params: &ServiceParams,
2788 _req: &SendMessageRequest,
2789 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
2790 Err(A2AError::unsupported_operation("unused"))
2791 }
2792
2793 async fn get_task(
2794 &self,
2795 _params: &ServiceParams,
2796 _req: &GetTaskRequest,
2797 ) -> Result<Task, A2AError> {
2798 Err(A2AError::unsupported_operation("unused"))
2799 }
2800
2801 async fn list_tasks(
2802 &self,
2803 _params: &ServiceParams,
2804 _req: &ListTasksRequest,
2805 ) -> Result<ListTasksResponse, A2AError> {
2806 Err(A2AError::unsupported_operation("unused"))
2807 }
2808
2809 async fn cancel_task(
2810 &self,
2811 _params: &ServiceParams,
2812 _req: &CancelTaskRequest,
2813 ) -> Result<Task, A2AError> {
2814 Err(A2AError::unsupported_operation("unused"))
2815 }
2816
2817 async fn subscribe_to_task(
2818 &self,
2819 _params: &ServiceParams,
2820 _req: &SubscribeToTaskRequest,
2821 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
2822 Err(A2AError::unsupported_operation("unused"))
2823 }
2824
2825 async fn create_push_config(
2826 &self,
2827 _params: &ServiceParams,
2828 _req: &TaskPushNotificationConfig,
2829 ) -> Result<TaskPushNotificationConfig, A2AError> {
2830 Err(A2AError::unsupported_operation("unused"))
2831 }
2832
2833 async fn get_push_config(
2834 &self,
2835 _params: &ServiceParams,
2836 _req: &GetTaskPushNotificationConfigRequest,
2837 ) -> Result<TaskPushNotificationConfig, A2AError> {
2838 Err(A2AError::unsupported_operation("unused"))
2839 }
2840
2841 async fn list_push_configs(
2842 &self,
2843 _params: &ServiceParams,
2844 _req: &ListTaskPushNotificationConfigsRequest,
2845 ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
2846 Err(A2AError::unsupported_operation("unused"))
2847 }
2848
2849 async fn delete_push_config(
2850 &self,
2851 _params: &ServiceParams,
2852 _req: &DeleteTaskPushNotificationConfigRequest,
2853 ) -> Result<(), A2AError> {
2854 Err(A2AError::unsupported_operation("unused"))
2855 }
2856
2857 async fn get_extended_agent_card(
2858 &self,
2859 _params: &ServiceParams,
2860 _req: &GetExtendedAgentCardRequest,
2861 ) -> Result<AgentCard, A2AError> {
2862 Err(A2AError::unsupported_operation("unused"))
2863 }
2864
2865 async fn destroy(&self) -> Result<(), A2AError> {
2866 match &self.destroy_error {
2867 Some(error) => Err(error.clone()),
2868 None => Ok(()),
2869 }
2870 }
2871 }
2872
2873 struct FailingSerialize;
2874
2875 impl Serialize for FailingSerialize {
2876 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
2877 where
2878 S: serde::Serializer,
2879 {
2880 Err(ser::Error::custom("serialize failed"))
2881 }
2882 }
2883
2884 impl TextRender for FailingSerialize {
2885 fn render_text(&self) -> String {
2886 String::new()
2887 }
2888 }
2889
2890 impl TaskOutcome for FailingSerialize {
2891 fn warn_outcome(&self) {}
2892 }
2893
2894 fn make_test_client(destroy_error: Option<A2AError>) -> A2AClient<TestTransport> {
2895 A2AClient::new(TestTransport { destroy_error })
2896 }
2897
2898 #[derive(Default)]
2899 struct RunTestState {
2900 tasks: Mutex<BTreeMap<String, Task>>,
2901 push_configs: Mutex<BTreeMap<(String, String), TaskPushNotificationConfig>>,
2902 }
2903
2904 struct RunTestHandler {
2905 state: Arc<RunTestState>,
2906 extended_card: AgentCard,
2907 }
2908
2909 struct RunTestServer {
2910 base_url: String,
2911 handle: tokio::task::JoinHandle<()>,
2912 }
2913
2914 impl Drop for RunTestServer {
2915 fn drop(&mut self) {
2916 self.handle.abort();
2917 }
2918 }
2919
2920 impl RunTestServer {
2921 async fn spawn() -> Self {
2922 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2923 let base_url = format!("http://{}", listener.local_addr().unwrap());
2924 let state = Arc::new(RunTestState::default());
2925
2926 state.tasks.lock().unwrap().insert(
2927 "task-1".to_string(),
2928 make_fixture_task("task-1", "ctx-1", TaskState::Completed, "seeded result"),
2929 );
2930
2931 let public_card = make_fixture_card(&base_url, "Fixture Agent");
2932 let extended_card = make_fixture_card(&base_url, "Fixture Agent (extended)");
2933 let handler = Arc::new(RunTestHandler {
2934 state,
2935 extended_card,
2936 });
2937
2938 let card = public_card.clone();
2939 let app = Router::new()
2940 .route(
2941 WELL_KNOWN_AGENT_CARD_PATH,
2942 get(move || {
2943 let card = card.clone();
2944 async move { Json(card) }
2945 }),
2946 )
2947 .nest("/jsonrpc", jsonrpc_router(handler.clone()))
2948 .nest("/rest", rest_router(handler));
2949
2950 let handle = tokio::spawn(async move {
2951 axum::serve(listener, app).await.unwrap();
2952 });
2953
2954 RunTestServer { base_url, handle }
2955 }
2956 }
2957
2958 #[async_trait]
2959 impl RequestHandler for RunTestHandler {
2960 async fn send_message(
2961 &self,
2962 _params: &HandlerServiceParams,
2963 req: SendMessageRequest,
2964 ) -> Result<SendMessageResponse, A2AError> {
2965 let task_id = req
2966 .message
2967 .task_id
2968 .clone()
2969 .unwrap_or_else(|| "task-send".to_string());
2970 let context_id = req
2971 .message
2972 .context_id
2973 .clone()
2974 .unwrap_or_else(|| "ctx-send".to_string());
2975 let text = req.message.text().unwrap_or_default();
2976 if text == "send-error" {
2977 return Err(A2AError::invalid_request("send failed"));
2978 }
2979
2980 let task = make_fixture_task(
2981 &task_id,
2982 &context_id,
2983 TaskState::Completed,
2984 &format!("Echo: {text}"),
2985 );
2986 self.state
2987 .tasks
2988 .lock()
2989 .unwrap()
2990 .insert(task_id, task.clone());
2991 Ok(SendMessageResponse::Task(task))
2992 }
2993
2994 async fn send_streaming_message(
2995 &self,
2996 _params: &HandlerServiceParams,
2997 req: SendMessageRequest,
2998 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
2999 let task_id = req
3000 .message
3001 .task_id
3002 .clone()
3003 .unwrap_or_else(|| "task-stream".to_string());
3004 let context_id = req
3005 .message
3006 .context_id
3007 .clone()
3008 .unwrap_or_else(|| "ctx-stream".to_string());
3009 let text = req.message.text().unwrap_or_default();
3010 if text == "stream-error" {
3011 return Ok(Box::pin(stream::once(async {
3012 Err(A2AError::internal("stream failed"))
3013 })));
3014 }
3015
3016 let task = make_fixture_task(
3017 &task_id,
3018 &context_id,
3019 TaskState::Completed,
3020 &format!("Echo: {text}"),
3021 );
3022 self.state
3023 .tasks
3024 .lock()
3025 .unwrap()
3026 .insert(task_id.clone(), task.clone());
3027
3028 Ok(Box::pin(stream::iter(vec![
3029 Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
3030 task_id,
3031 context_id,
3032 status: TaskStatus {
3033 state: TaskState::Working,
3034 message: None,
3035 timestamp: None,
3036 },
3037 metadata: None,
3038 })),
3039 Ok(StreamResponse::Task(task)),
3040 ])))
3041 }
3042
3043 async fn get_task(
3044 &self,
3045 _params: &HandlerServiceParams,
3046 req: GetTaskRequest,
3047 ) -> Result<Task, A2AError> {
3048 self.state
3049 .tasks
3050 .lock()
3051 .unwrap()
3052 .get(&req.id)
3053 .cloned()
3054 .ok_or_else(|| A2AError::task_not_found(&req.id))
3055 }
3056
3057 async fn list_tasks(
3058 &self,
3059 _params: &HandlerServiceParams,
3060 req: ListTasksRequest,
3061 ) -> Result<ListTasksResponse, A2AError> {
3062 if req.context_id.as_deref() == Some("error") {
3063 return Err(A2AError::invalid_params("list failed"));
3064 }
3065
3066 let tasks = self
3067 .state
3068 .tasks
3069 .lock()
3070 .unwrap()
3071 .values()
3072 .filter(|task| {
3073 req.context_id
3074 .as_ref()
3075 .map(|context_id| task.context_id == *context_id)
3076 .unwrap_or(true)
3077 })
3078 .filter(|task| {
3079 req.status
3080 .as_ref()
3081 .map(|status| task.status.state == *status)
3082 .unwrap_or(true)
3083 })
3084 .cloned()
3085 .collect();
3086
3087 Ok(ListTasksResponse {
3088 tasks,
3089 next_page_token: String::new(),
3090 page_size: 0,
3091 total_size: 0,
3092 })
3093 }
3094
3095 async fn cancel_task(
3096 &self,
3097 _params: &HandlerServiceParams,
3098 req: CancelTaskRequest,
3099 ) -> Result<Task, A2AError> {
3100 let mut tasks = self.state.tasks.lock().unwrap();
3101 let task = tasks
3102 .get(&req.id)
3103 .cloned()
3104 .ok_or_else(|| A2AError::task_not_found(&req.id))?;
3105 let canceled =
3106 make_fixture_task(&task.id, &task.context_id, TaskState::Canceled, "canceled");
3107 tasks.insert(req.id, canceled.clone());
3108 Ok(canceled)
3109 }
3110
3111 async fn subscribe_to_task(
3112 &self,
3113 _params: &HandlerServiceParams,
3114 req: SubscribeToTaskRequest,
3115 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3116 if req.id == "stream-error" {
3117 return Ok(Box::pin(stream::once(async {
3118 Err(A2AError::internal("stream failed"))
3119 })));
3120 }
3121
3122 let task = self
3123 .state
3124 .tasks
3125 .lock()
3126 .unwrap()
3127 .get(&req.id)
3128 .cloned()
3129 .ok_or_else(|| A2AError::task_not_found(&req.id))?;
3130
3131 Ok(Box::pin(stream::iter(vec![
3132 Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
3133 task_id: req.id.clone(),
3134 context_id: task.context_id.clone(),
3135 status: TaskStatus {
3136 state: TaskState::Working,
3137 message: None,
3138 timestamp: None,
3139 },
3140 metadata: None,
3141 })),
3142 Ok(StreamResponse::Task(task)),
3143 ])))
3144 }
3145
3146 async fn create_push_config(
3147 &self,
3148 _params: &HandlerServiceParams,
3149 req: TaskPushNotificationConfig,
3150 ) -> Result<TaskPushNotificationConfig, A2AError> {
3151 if !self.state.tasks.lock().unwrap().contains_key(&req.task_id) {
3152 return Err(A2AError::task_not_found(&req.task_id));
3153 }
3154
3155 let config_id = req.id.clone().unwrap_or_else(|| "generated".to_string());
3156 self.state
3157 .push_configs
3158 .lock()
3159 .unwrap()
3160 .insert((req.task_id.clone(), config_id), req.clone());
3161 Ok(req)
3162 }
3163
3164 async fn get_push_config(
3165 &self,
3166 _params: &HandlerServiceParams,
3167 req: GetTaskPushNotificationConfigRequest,
3168 ) -> Result<TaskPushNotificationConfig, A2AError> {
3169 self.state
3170 .push_configs
3171 .lock()
3172 .unwrap()
3173 .get(&(req.task_id.clone(), req.id.clone()))
3174 .cloned()
3175 .ok_or_else(|| A2AError::task_not_found(&req.task_id))
3176 }
3177
3178 async fn list_push_configs(
3179 &self,
3180 _params: &HandlerServiceParams,
3181 req: ListTaskPushNotificationConfigsRequest,
3182 ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
3183 if req.task_id == "missing" {
3184 return Err(A2AError::task_not_found(&req.task_id));
3185 }
3186
3187 let configs = self
3188 .state
3189 .push_configs
3190 .lock()
3191 .unwrap()
3192 .values()
3193 .filter(|config| config.task_id == req.task_id)
3194 .cloned()
3195 .collect();
3196
3197 Ok(ListTaskPushNotificationConfigsResponse {
3198 configs,
3199 next_page_token: None,
3200 })
3201 }
3202
3203 async fn delete_push_config(
3204 &self,
3205 _params: &HandlerServiceParams,
3206 req: DeleteTaskPushNotificationConfigRequest,
3207 ) -> Result<(), A2AError> {
3208 let deleted = self
3209 .state
3210 .push_configs
3211 .lock()
3212 .unwrap()
3213 .remove(&(req.task_id.clone(), req.id.clone()));
3214 if deleted.is_none() {
3215 return Err(A2AError::task_not_found(&req.task_id));
3216 }
3217 Ok(())
3218 }
3219
3220 async fn get_extended_agent_card(
3221 &self,
3222 _params: &HandlerServiceParams,
3223 req: GetExtendedAgentCardRequest,
3224 ) -> Result<AgentCard, A2AError> {
3225 if req.tenant.as_deref() == Some("error") {
3226 return Err(A2AError::unsupported_operation("extended card denied"));
3227 }
3228
3229 Ok(self.extended_card.clone())
3230 }
3231 }
3232
3233 fn make_fixture_card(base_url: &str, name: &str) -> AgentCard {
3234 AgentCard {
3235 name: name.to_string(),
3236 description: "CLI unit-test fixture".to_string(),
3237 version: VERSION.to_string(),
3238 supported_interfaces: vec![
3239 AgentInterface::new(format!("{base_url}/jsonrpc"), TRANSPORT_PROTOCOL_JSONRPC),
3240 AgentInterface::new(format!("{base_url}/rest"), TRANSPORT_PROTOCOL_HTTP_JSON),
3241 ],
3242 capabilities: AgentCapabilities {
3243 streaming: Some(true),
3244 push_notifications: Some(true),
3245 extensions: None,
3246 extended_agent_card: Some(true),
3247 },
3248 default_input_modes: vec!["text/plain".to_string()],
3249 default_output_modes: vec!["text/plain".to_string()],
3250 skills: vec![],
3251 provider: None,
3252 documentation_url: None,
3253 icon_url: None,
3254 security_schemes: None,
3255 security_requirements: None,
3256 signatures: None,
3257 }
3258 }
3259
3260 fn make_fixture_task(task_id: &str, context_id: &str, state: TaskState, text: &str) -> Task {
3261 Task {
3262 id: task_id.to_string(),
3263 context_id: context_id.to_string(),
3264 status: TaskStatus {
3265 state,
3266 message: Some(Message {
3267 message_id: format!("msg-{task_id}"),
3268 context_id: Some(context_id.to_string()),
3269 task_id: Some(task_id.to_string()),
3270 role: Role::Agent,
3271 parts: vec![Part::text(text)],
3272 metadata: None,
3273 extensions: None,
3274 reference_task_ids: None,
3275 }),
3276 timestamp: None,
3277 },
3278 artifacts: None,
3279 history: None,
3280 metadata: None,
3281 }
3282 }
3283
3284 fn build_args(base_url: &str, args: &[&str]) -> Vec<String> {
3285 let mut argv = vec![
3286 "a2acli".to_string(),
3287 "--agent-card".to_string(),
3288 base_url.to_string(),
3289 ];
3290 argv.extend(args.iter().map(|arg| (*arg).to_string()));
3291 argv
3292 }
3293
3294 async fn run_test_cli(base_url: &str, args: &[&str]) -> Result<(), CliError> {
3298 run_args(build_args(base_url, args)).await
3299 }
3300
3301 async fn unused_base_url() -> String {
3302 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3303 let addr = listener.local_addr().unwrap();
3304 drop(listener);
3305 format!("http://{addr}")
3306 }
3307
3308 fn assert_unsupported_operation<T>(result: Result<T, A2AError>) {
3309 match result {
3310 Ok(_) => panic!("expected unsupported operation error"),
3311 Err(err) => assert_eq!(err.code, a2a::error_code::UNSUPPORTED_OPERATION),
3312 }
3313 }
3314
3315 #[test]
3316 fn test_parse_header() {
3317 let header = parse_header("Authorization: Bearer token").unwrap();
3318 assert_eq!(header.name, "Authorization");
3319 assert_eq!(header.value, "Bearer token");
3320 }
3321
3322 #[test]
3323 fn test_parse_header_requires_separator() {
3324 let err = parse_header("Authorization").unwrap_err();
3325 assert_eq!(err, "header must be in NAME:VALUE format");
3326 }
3327
3328 #[test]
3329 fn test_parse_header_requires_name() {
3330 let err = parse_header(": value").unwrap_err();
3331 assert_eq!(err, "header name cannot be empty");
3332 }
3333
3334 #[test]
3335 fn test_build_send_message_request_populates_optional_fields() {
3336 let request = build_send_message_request(
3337 &MessageCommand {
3338 text: Some("hello".to_string()),
3339 text_parts: Vec::new(),
3340 file_parts: Vec::new(),
3341 data_parts: Vec::new(),
3342 media_types: Vec::new(),
3343 context_id: Some("ctx-1".to_string()),
3344 task_id: Some("task-1".to_string()),
3345 history_length: Some(4),
3346 accepted_output_modes: vec!["text/plain".to_string()],
3347 return_immediately: true,
3348 stream: false,
3349 },
3350 vec![Part::text("hello")],
3351 Some("tenant-1".to_string()),
3352 );
3353
3354 assert_eq!(request.message.text(), Some("hello"));
3355 assert_eq!(request.message.context_id.as_deref(), Some("ctx-1"));
3356 assert_eq!(request.message.task_id.as_deref(), Some("task-1"));
3357 assert_eq!(request.tenant.as_deref(), Some("tenant-1"));
3358 assert_eq!(
3359 request
3360 .configuration
3361 .as_ref()
3362 .and_then(|config| config.history_length),
3363 Some(4)
3364 );
3365 assert_eq!(
3366 request
3367 .configuration
3368 .as_ref()
3369 .and_then(|config| config.return_immediately),
3370 Some(true)
3371 );
3372 }
3373
3374 #[test]
3375 fn test_build_send_message_request_without_optional_fields() {
3376 let request = build_send_message_request(
3377 &MessageCommand {
3378 text: Some("hello".to_string()),
3379 text_parts: Vec::new(),
3380 file_parts: Vec::new(),
3381 data_parts: Vec::new(),
3382 media_types: Vec::new(),
3383 context_id: None,
3384 task_id: None,
3385 history_length: None,
3386 accepted_output_modes: Vec::new(),
3387 return_immediately: false,
3388 stream: false,
3389 },
3390 vec![Part::text("hello")],
3391 None,
3392 );
3393
3394 assert_eq!(request.message.text(), Some("hello"));
3395 assert!(request.configuration.is_none());
3396 assert!(request.tenant.is_none());
3397 }
3398
3399 #[test]
3400 fn test_cli_parse_send_command() {
3401 let cli = Cli::try_parse_from([
3402 "a2acli",
3403 "--transport",
3404 "jsonrpc",
3405 "--svc-param",
3406 "X-Test:123",
3407 "send",
3408 "hello",
3409 "--history-length",
3410 "2",
3411 ])
3412 .unwrap();
3413
3414 assert_eq!(cli.transport, vec![Binding::Jsonrpc]);
3415 assert_eq!(cli.svc_params.len(), 1);
3416 assert!(matches!(cli.command, Command::Send(_)));
3417 }
3418
3419 #[test]
3420 fn test_cli_parse_push_config_create_command() {
3421 let cli = Cli::try_parse_from([
3422 "a2acli",
3423 "task",
3424 "push-config",
3425 "create",
3426 "task-1",
3427 "https://example.com/callback",
3428 "--config-id",
3429 "cfg-1",
3430 "--token",
3431 "tok-1",
3432 "--auth-scheme",
3433 "Bearer",
3434 "--auth-credentials",
3435 "secret",
3436 ])
3437 .unwrap();
3438
3439 match cli.command {
3440 Command::Task {
3441 command:
3442 TaskCommand::PushConfig {
3443 command: PushConfigCommand::Create(command),
3444 },
3445 } => {
3446 assert_eq!(command.task_id, "task-1");
3447 assert_eq!(command.url, "https://example.com/callback");
3448 assert_eq!(command.config_id.as_deref(), Some("cfg-1"));
3449 assert_eq!(command.token.as_deref(), Some("tok-1"));
3450 assert_eq!(command.auth_scheme.as_deref(), Some("Bearer"));
3451 assert_eq!(command.auth_credentials.as_deref(), Some("secret"));
3452 }
3453 other => panic!("unexpected command: {other:?}"),
3454 }
3455 }
3456
3457 #[test]
3458 fn test_build_push_notification_config() {
3459 let config = build_push_notification_config(&CreatePushConfigCommand {
3460 task_id: "task-1".to_string(),
3461 url: "https://example.com/callback".to_string(),
3462 config_id: Some("cfg-1".to_string()),
3463 token: Some("tok-1".to_string()),
3464 auth_scheme: Some("Bearer".to_string()),
3465 auth_credentials: Some("secret".to_string()),
3466 })
3467 .unwrap();
3468
3469 assert_eq!(config.id.as_deref(), Some("cfg-1"));
3470 assert_eq!(config.token.as_deref(), Some("tok-1"));
3471 assert_eq!(
3472 config
3473 .authentication
3474 .as_ref()
3475 .map(|auth| auth.scheme.as_str()),
3476 Some("Bearer")
3477 );
3478 assert_eq!(
3479 config
3480 .authentication
3481 .as_ref()
3482 .and_then(|auth| auth.credentials.as_deref()),
3483 Some("secret")
3484 );
3485 }
3486
3487 #[test]
3488 fn test_build_push_notification_config_requires_auth_scheme() {
3489 let err = build_push_notification_config(&CreatePushConfigCommand {
3490 task_id: "task-1".to_string(),
3491 url: "https://example.com/callback".to_string(),
3492 config_id: None,
3493 token: None,
3494 auth_scheme: None,
3495 auth_credentials: Some("secret".to_string()),
3496 })
3497 .unwrap_err();
3498
3499 assert!(matches!(err, CliError::InvalidInput(_)));
3500 }
3501
3502 #[test]
3503 fn test_build_push_notification_config_without_authentication() {
3504 let config = build_push_notification_config(&CreatePushConfigCommand {
3505 task_id: "task-1".to_string(),
3506 url: "https://example.com/callback".to_string(),
3507 config_id: None,
3508 token: None,
3509 auth_scheme: None,
3510 auth_credentials: None,
3511 })
3512 .unwrap();
3513
3514 assert_eq!(config.url, "https://example.com/callback");
3515 assert!(config.authentication.is_none());
3516 }
3517
3518 #[test]
3519 fn test_binding_protocols() {
3520 assert_eq!(Binding::Jsonrpc.protocol(), TRANSPORT_PROTOCOL_JSONRPC);
3521 assert_eq!(Binding::Rest.protocol(), TRANSPORT_PROTOCOL_HTTP_JSON);
3522 }
3523
3524 #[test]
3525 fn test_apply_request_auth_builds_headers() {
3526 let cli = Cli::try_parse_from([
3527 "a2acli",
3528 "--bearer",
3529 "secret",
3530 "--api-key",
3531 "key-123",
3532 "--svc-param",
3533 "X-Test: 123",
3534 "card",
3535 "get",
3536 ])
3537 .unwrap();
3538
3539 let request = apply_request_auth(Client::new().get("http://example.com"), &cli)
3540 .build()
3541 .unwrap();
3542
3543 assert_eq!(
3544 request.headers().get(header::AUTHORIZATION).unwrap(),
3545 "Bearer secret"
3546 );
3547 assert_eq!(request.headers().get("X-API-Key").unwrap(), "key-123");
3548 assert_eq!(request.headers().get("X-Test").unwrap(), "123");
3549 }
3550
3551 #[tokio::test]
3552 async fn test_finish_client_call_propagates_destroy_error() {
3553 let err = finish_client_call(
3554 make_test_client(Some(A2AError::internal("destroy failed"))),
3555 Ok(serde_json::json!({ "ok": true })),
3556 )
3557 .await
3558 .unwrap_err();
3559
3560 assert!(matches!(err, CliError::A2A(_)));
3561 }
3562
3563 fn json_cli() -> Cli {
3564 parse_with_matches(&["--output", "json", "task", "get", "unused"]).0
3565 }
3566
3567 #[tokio::test]
3568 async fn test_consume_stream_reports_json_error() {
3569 let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
3570 let err = consume_stream(make_test_client(None), stream, &json_cli())
3571 .await
3572 .unwrap_err();
3573
3574 assert!(matches!(err, CliError::Json(_)));
3575 }
3576
3577 #[tokio::test]
3578 async fn test_consume_stream_propagates_destroy_error_on_completion() {
3579 let stream = Box::pin(stream::empty::<Result<Task, A2AError>>());
3580 let err = consume_stream(
3581 make_test_client(Some(A2AError::internal("destroy failed"))),
3582 stream,
3583 &json_cli(),
3584 )
3585 .await
3586 .unwrap_err();
3587
3588 assert!(matches!(err, CliError::A2A(_)));
3589 }
3590
3591 #[tokio::test]
3592 async fn test_test_transport_methods_return_unused_errors() {
3593 let transport = TestTransport {
3594 destroy_error: None,
3595 };
3596 let params = ServiceParams::new();
3597
3598 assert_unsupported_operation(
3599 transport
3600 .send_message(
3601 ¶ms,
3602 &SendMessageRequest {
3603 message: Message::new(Role::User, vec![Part::text("hello")]),
3604 configuration: None,
3605 metadata: None,
3606 tenant: None,
3607 },
3608 )
3609 .await,
3610 );
3611 assert_unsupported_operation(
3612 transport
3613 .send_streaming_message(
3614 ¶ms,
3615 &SendMessageRequest {
3616 message: Message::new(Role::User, vec![Part::text("hello")]),
3617 configuration: None,
3618 metadata: None,
3619 tenant: None,
3620 },
3621 )
3622 .await,
3623 );
3624 assert_unsupported_operation(
3625 transport
3626 .get_task(
3627 ¶ms,
3628 &GetTaskRequest {
3629 id: "task-1".to_string(),
3630 history_length: None,
3631 tenant: None,
3632 },
3633 )
3634 .await,
3635 );
3636 assert_unsupported_operation(
3637 transport
3638 .list_tasks(
3639 ¶ms,
3640 &ListTasksRequest {
3641 context_id: None,
3642 status: None,
3643 page_size: None,
3644 page_token: None,
3645 history_length: None,
3646 status_timestamp_after: None,
3647 include_artifacts: None,
3648 tenant: None,
3649 },
3650 )
3651 .await,
3652 );
3653 assert_unsupported_operation(
3654 transport
3655 .cancel_task(
3656 ¶ms,
3657 &CancelTaskRequest {
3658 id: "task-1".to_string(),
3659 metadata: None,
3660 tenant: None,
3661 },
3662 )
3663 .await,
3664 );
3665 assert_unsupported_operation(
3666 transport
3667 .subscribe_to_task(
3668 ¶ms,
3669 &SubscribeToTaskRequest {
3670 id: "task-1".to_string(),
3671 tenant: None,
3672 },
3673 )
3674 .await,
3675 );
3676 assert_unsupported_operation(
3677 transport
3678 .create_push_config(
3679 ¶ms,
3680 &TaskPushNotificationConfig {
3681 task_id: "task-1".to_string(),
3682 url: "https://example.com/callback".to_string(),
3683 id: Some("cfg-1".to_string()),
3684 token: None,
3685 authentication: None,
3686 tenant: None,
3687 },
3688 )
3689 .await,
3690 );
3691 assert_unsupported_operation(
3692 transport
3693 .get_push_config(
3694 ¶ms,
3695 &GetTaskPushNotificationConfigRequest {
3696 task_id: "task-1".to_string(),
3697 id: "cfg-1".to_string(),
3698 tenant: None,
3699 },
3700 )
3701 .await,
3702 );
3703 assert_unsupported_operation(
3704 transport
3705 .list_push_configs(
3706 ¶ms,
3707 &ListTaskPushNotificationConfigsRequest {
3708 task_id: "task-1".to_string(),
3709 page_size: None,
3710 page_token: None,
3711 tenant: None,
3712 },
3713 )
3714 .await,
3715 );
3716 assert_unsupported_operation(
3717 transport
3718 .delete_push_config(
3719 ¶ms,
3720 &DeleteTaskPushNotificationConfigRequest {
3721 task_id: "task-1".to_string(),
3722 id: "cfg-1".to_string(),
3723 tenant: None,
3724 },
3725 )
3726 .await,
3727 );
3728 assert_unsupported_operation(
3729 transport
3730 .get_extended_agent_card(¶ms, &GetExtendedAgentCardRequest { tenant: None })
3731 .await,
3732 );
3733 }
3734
3735 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3736 async fn test_run_executes_all_commands_in_lib_tests() {
3737 let server = RunTestServer::spawn().await;
3738
3739 run_test_cli(&server.base_url, &["card", "get"])
3740 .await
3741 .unwrap();
3742 run_test_cli(
3743 &server.base_url,
3744 &["--compact", "card", "get", "--extended"],
3745 )
3746 .await
3747 .unwrap();
3748 run_test_cli(
3749 &server.base_url,
3750 &[
3751 "--transport",
3752 "jsonrpc",
3753 "--bearer",
3754 "secret",
3755 "--svc-param",
3756 "X-Test: 123",
3757 "send",
3758 "hello from unit test",
3759 "--task-id",
3760 "task-send",
3761 "--context-id",
3762 "ctx-send",
3763 ],
3764 )
3765 .await
3766 .unwrap();
3767 run_test_cli(
3768 &server.base_url,
3769 &[
3770 "--compact",
3771 "send",
3772 "streaming request",
3773 "--stream",
3774 "--task-id",
3775 "task-stream",
3776 "--context-id",
3777 "ctx-stream",
3778 ],
3779 )
3780 .await
3781 .unwrap();
3782 run_test_cli(&server.base_url, &["task", "get", "task-send"])
3783 .await
3784 .unwrap();
3785 run_test_cli(
3786 &server.base_url,
3787 &[
3788 "--compact",
3789 "task",
3790 "list",
3791 "--context-id",
3792 "ctx-send",
3793 "--status",
3794 "completed",
3795 ],
3796 )
3797 .await
3798 .unwrap();
3799 run_test_cli(&server.base_url, &["task", "cancel", "task-send"])
3800 .await
3801 .unwrap();
3802 run_test_cli(
3803 &server.base_url,
3804 &["--compact", "task", "subscribe", "task-stream"],
3805 )
3806 .await
3807 .unwrap();
3808 run_test_cli(
3809 &server.base_url,
3810 &[
3811 "task",
3812 "push-config",
3813 "create",
3814 "task-1",
3815 "https://example.com/callback",
3816 "--config-id",
3817 "cfg-1",
3818 ],
3819 )
3820 .await
3821 .unwrap();
3822 run_test_cli(
3823 &server.base_url,
3824 &["--compact", "task", "push-config", "get", "task-1", "cfg-1"],
3825 )
3826 .await
3827 .unwrap();
3828 run_test_cli(
3829 &server.base_url,
3830 &["--compact", "task", "push-config", "list", "task-1"],
3831 )
3832 .await
3833 .unwrap();
3834 run_test_cli(
3835 &server.base_url,
3836 &["task", "push-config", "delete", "task-1", "cfg-1"],
3837 )
3838 .await
3839 .unwrap();
3840 }
3841
3842 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3843 async fn test_run_surfaces_errors_in_lib_tests() {
3844 let server = RunTestServer::spawn().await;
3845
3846 let err = run_test_cli(
3847 &server.base_url,
3848 &["card", "get", "--extended", "--tenant", "error"],
3849 )
3850 .await
3851 .unwrap_err();
3852 assert!(matches!(
3853 err,
3854 CliError::A2A(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION
3855 ));
3856
3857 let err = run_test_cli(&server.base_url, &["send", "send-error"])
3858 .await
3859 .unwrap_err();
3860 assert!(matches!(
3861 err,
3862 CliError::A2A(error) if error.code == a2a::error_code::INVALID_REQUEST
3863 ));
3864
3865 let err = run_test_cli(&server.base_url, &["task", "list", "--context-id", "error"])
3866 .await
3867 .unwrap_err();
3868 assert!(matches!(
3869 err,
3870 CliError::A2A(error) if error.code == a2a::error_code::INVALID_PARAMS
3871 ));
3872
3873 let err = run_test_cli(
3874 &server.base_url,
3875 &["--compact", "send", "stream-error", "--stream"],
3876 )
3877 .await
3878 .unwrap_err();
3879 assert!(matches!(
3880 err,
3881 CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
3882 ));
3883
3884 let err = run_test_cli(
3885 &server.base_url,
3886 &["--compact", "task", "subscribe", "stream-error"],
3887 )
3888 .await
3889 .unwrap_err();
3890 assert!(matches!(
3891 err,
3892 CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
3893 ));
3894
3895 let err = run_test_cli(
3896 &server.base_url,
3897 &[
3898 "task",
3899 "push-config",
3900 "create",
3901 "missing",
3902 "https://example.com/callback",
3903 "--config-id",
3904 "cfg-missing",
3905 ],
3906 )
3907 .await
3908 .unwrap_err();
3909 assert!(matches!(
3910 err,
3911 CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
3912 ));
3913
3914 let err = run_test_cli(
3915 &server.base_url,
3916 &["task", "push-config", "list", "missing"],
3917 )
3918 .await
3919 .unwrap_err();
3920 assert!(matches!(
3921 err,
3922 CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
3923 ));
3924
3925 let base_url = unused_base_url().await;
3926 let err = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
3927 assert!(matches!(err, CliError::Http(_)));
3928 }
3929
3930 #[test]
3931 fn test_task_state_conversion() {
3932 let cases = [
3933 (TaskStateArg::Unspecified, TaskState::Unspecified),
3934 (TaskStateArg::Submitted, TaskState::Submitted),
3935 (TaskStateArg::Working, TaskState::Working),
3936 (TaskStateArg::Completed, TaskState::Completed),
3937 (TaskStateArg::Failed, TaskState::Failed),
3938 (TaskStateArg::Canceled, TaskState::Canceled),
3939 (TaskStateArg::InputRequired, TaskState::InputRequired),
3940 (TaskStateArg::Rejected, TaskState::Rejected),
3941 (TaskStateArg::AuthRequired, TaskState::AuthRequired),
3942 ];
3943
3944 for (input, expected) in cases {
3945 assert_eq!(TaskState::from(input), expected);
3946 }
3947 }
3948
3949 #[test]
3950 fn test_means_not_a_readable_path_classification() {
3951 use std::io::ErrorKind;
3952
3953 for kind in [
3958 ErrorKind::NotFound,
3959 ErrorKind::InvalidFilename,
3960 ErrorKind::InvalidInput,
3961 ] {
3962 assert!(
3963 means_not_a_readable_path(kind),
3964 "{kind:?} should fall through to inline-JSON parsing"
3965 );
3966 }
3967
3968 for kind in [
3970 ErrorKind::PermissionDenied,
3971 ErrorKind::IsADirectory,
3972 ErrorKind::InvalidData,
3973 ] {
3974 assert!(
3975 !means_not_a_readable_path(kind),
3976 "{kind:?} should be surfaced as a read error"
3977 );
3978 }
3979 }
3980
3981 #[test]
3982 fn test_parse_duration_variants() {
3983 assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
3984 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3985 assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
3986 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3987 assert_eq!(parse_duration("1.5s").unwrap(), Duration::from_millis(1500));
3988 }
3989
3990 #[test]
3991 fn test_parse_duration_rejects_invalid_input() {
3992 assert!(parse_duration("banana").is_err());
3993 assert!(parse_duration("-1s").is_err());
3994 }
3995
3996 #[test]
3997 fn test_is_settled_classifies_task_states() {
3998 let settled = [
3999 TaskState::Completed,
4000 TaskState::Failed,
4001 TaskState::Canceled,
4002 TaskState::Rejected,
4003 TaskState::InputRequired,
4004 TaskState::AuthRequired,
4005 ];
4006 for state in settled {
4007 assert!(is_settled(&state), "{state:?} should be settled");
4008 }
4009
4010 let unsettled = [
4011 TaskState::Unspecified,
4012 TaskState::Submitted,
4013 TaskState::Working,
4014 ];
4015 for state in unsettled {
4016 assert!(!is_settled(&state), "{state:?} should not be settled");
4017 }
4018 }
4019
4020 fn parse_with_matches(args: &[&str]) -> (Cli, ArgMatches) {
4021 let mut argv = vec!["a2acli".to_string()];
4022 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4023 let matches = Cli::command().try_get_matches_from(argv).unwrap();
4024 let cli = Cli::from_arg_matches(&matches).unwrap();
4025 (cli, matches)
4026 }
4027
4028 fn send_command(cli: &Cli) -> &MessageCommand {
4029 match &cli.command {
4030 Command::Send(command) => command,
4031 other => panic!("expected Command::Send, got {other:?}"),
4032 }
4033 }
4034
4035 #[test]
4036 fn test_resolve_message_parts_preserves_interleaved_order_and_media_type() {
4037 let (cli, matches) = parse_with_matches(&[
4038 "send",
4039 "--text-part",
4040 "hello",
4041 "--file-part",
4042 "https://example.com/doc.pdf",
4043 "--media-type",
4044 "application/pdf",
4045 "--data-part",
4046 r#"{"k":1}"#,
4047 ]);
4048 let send_matches = matches.subcommand_matches("send").unwrap();
4049 let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
4050
4051 assert_eq!(parts.len(), 3);
4052 assert_eq!(parts[0].as_text(), Some("hello"));
4053 assert!(parts[0].media_type.is_none());
4054 assert!(matches!(
4055 &parts[1].content,
4056 PartContent::Url(url) if url == "https://example.com/doc.pdf"
4057 ));
4058 assert_eq!(parts[1].media_type.as_deref(), Some("application/pdf"));
4059 assert!(matches!(&parts[2].content, PartContent::Data(_)));
4060 assert!(parts[2].media_type.is_none());
4061 }
4062
4063 #[test]
4064 fn test_resolve_message_parts_positional_text_is_shorthand() {
4065 let (cli, matches) = parse_with_matches(&["send", "hello"]);
4066 let send_matches = matches.subcommand_matches("send").unwrap();
4067 let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
4068
4069 assert_eq!(parts.len(), 1);
4070 assert_eq!(parts[0].as_text(), Some("hello"));
4071 }
4072
4073 #[test]
4074 fn test_resolve_message_parts_rejects_media_type_without_preceding_part() {
4075 let (cli, matches) =
4076 parse_with_matches(&["send", "--media-type", "text/plain", "--text-part", "hi"]);
4077 let send_matches = matches.subcommand_matches("send").unwrap();
4078 let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
4079 assert!(matches!(err, CliError::InvalidInput(_)));
4080 }
4081
4082 #[test]
4083 fn test_resolve_message_parts_rejects_positional_text_with_part_flags() {
4084 let (cli, matches) = parse_with_matches(&["send", "hello", "--text-part", "world"]);
4085 let send_matches = matches.subcommand_matches("send").unwrap();
4086 let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
4087 assert!(matches!(err, CliError::InvalidInput(_)));
4088 }
4089
4090 #[test]
4091 fn test_cli_error_exit_codes_and_envelope_codes() {
4092 let a2a = CliError::A2A(A2AError::task_not_found("t-1"));
4093 assert_eq!(a2a.exit_code(), 1);
4094 let envelope = a2a.envelope();
4095 assert_eq!(envelope.error.code, "TASK_NOT_FOUND");
4096 assert_eq!(envelope.error.a2a_code, Some(-32001));
4097
4098 let usage = CliError::InvalidInput("bad flag".to_string());
4099 assert_eq!(usage.exit_code(), 2);
4100 assert_eq!(usage.envelope().error.code, "A2ACLI_ERR_USAGE");
4101 assert!(usage.envelope().error.a2a_code.is_none());
4102
4103 let read_file = CliError::ReadFile {
4104 path: "missing.bin".to_string(),
4105 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
4106 };
4107 assert_eq!(read_file.exit_code(), 2);
4108 assert_eq!(read_file.envelope().error.code, "A2ACLI_ERR_USAGE");
4109
4110 let timeout = CliError::Timeout {
4111 task_id: "t-2".to_string(),
4112 timeout: Duration::from_secs(30),
4113 };
4114 assert_eq!(timeout.exit_code(), 5);
4115 assert_eq!(timeout.envelope().error.code, "A2ACLI_ERR_TIMEOUT");
4116
4117 let json = CliError::Json(serde_json::from_str::<serde_json::Value>("{").unwrap_err());
4118 assert_eq!(json.exit_code(), 1);
4119 assert_eq!(json.envelope().error.code, "A2ACLI_ERR_INTERNAL");
4120 }
4121
4122 #[test]
4123 fn test_cli_error_report_prints_one_json_line_to_stderr() {
4124 let error = CliError::A2A(A2AError::unsupported_operation("nope"));
4128 let json = serde_json::to_string(&error.envelope()).unwrap();
4129 assert!(json.contains("\"code\":\"UNSUPPORTED_OPERATION\""));
4130 assert!(!json.contains('\n'));
4131 }
4132
4133 #[test]
4134 fn test_agent_card_render_text() {
4135 let card = AgentCard {
4136 name: "Fixture".to_string(),
4137 description: "desc".to_string(),
4138 version: VERSION.to_string(),
4139 supported_interfaces: vec![AgentInterface::new(
4140 "http://host/jsonrpc",
4141 TRANSPORT_PROTOCOL_JSONRPC,
4142 )],
4143 capabilities: AgentCapabilities {
4144 streaming: Some(true),
4145 push_notifications: Some(false),
4146 extensions: None,
4147 extended_agent_card: None,
4148 },
4149 default_input_modes: vec![],
4150 default_output_modes: vec![],
4151 skills: vec![],
4152 provider: None,
4153 documentation_url: None,
4154 icon_url: None,
4155 security_schemes: None,
4156 security_requirements: None,
4157 signatures: None,
4158 };
4159
4160 let text = card.render_text();
4161 assert!(text.contains("Name: Fixture"));
4162 assert!(text.contains("Streaming: true"));
4163 assert!(text.contains("Push Notifications: false"));
4164 assert!(text.contains("Extended Card: false"));
4165 assert!(text.contains("Interfaces:"));
4166 assert!(text.contains("http://host/jsonrpc"));
4167 assert!(text.contains("Skills: 0"));
4168 }
4169
4170 #[test]
4171 fn test_message_render_text_renders_every_part_kind() {
4172 let mut file_part = Part::raw(vec![1, 2, 3]);
4173 file_part.filename = Some("report.bin".to_string());
4174 file_part.media_type = Some("application/octet-stream".to_string());
4175
4176 let message = Message::new(
4177 Role::Agent,
4178 vec![
4179 Part::text("hello"),
4180 Part::data(serde_json::json!({"k": 1})),
4181 file_part,
4182 ],
4183 );
4184
4185 let text = message.render_text();
4186 assert!(text.contains("Text:\nhello"));
4187 assert!(text.contains("Data:"));
4188 assert!(text.contains("\"k\": 1"));
4189 assert!(text.contains("File: report.bin (application/octet-stream, 3 bytes)"));
4190 }
4191
4192 #[test]
4196 fn test_render_part_without_media_type_falls_back_to_name_and_url() {
4197 let message = Message::new(
4198 Role::Agent,
4199 vec![
4200 Part::raw(vec![7; 12]),
4201 Part::raw(vec![1, 2]).with_filename("notes.txt"),
4202 Part::url("https://example.com/spec.pdf"),
4203 Part::url("https://example.com/report.csv").with_media_type("text/csv"),
4204 ],
4205 );
4206
4207 let text = message.render_text();
4208 assert!(text.contains("File: (unnamed) (12 bytes)"), "{text}");
4211 assert!(text.contains("File: notes.txt (2 bytes)"), "{text}");
4212 assert!(
4213 text.contains("File: https://example.com/spec.pdf"),
4214 "{text}"
4215 );
4216 assert!(
4217 text.contains("File: https://example.com/report.csv (text/csv)"),
4218 "{text}"
4219 );
4220 }
4221
4222 #[test]
4226 fn test_message_render_text_marks_an_empty_part_list() {
4227 let message = Message {
4228 message_id: "msg-1".to_string(),
4229 context_id: Some("ctx-1".to_string()),
4230 task_id: Some("task-1".to_string()),
4231 role: Role::Agent,
4232 parts: vec![],
4233 metadata: None,
4234 extensions: None,
4235 reference_task_ids: None,
4236 };
4237
4238 let text = message.render_text();
4239 assert!(text.contains("Context ID: ctx-1"), "{text}");
4240 assert!(text.contains("Task ID: task-1"), "{text}");
4241 assert!(text.contains("Parts: (none)"), "{text}");
4242 }
4243
4244 #[test]
4248 fn test_send_message_response_renders_the_message_arm() {
4249 let message = Message::new(Role::Agent, vec![Part::text("no task needed")]);
4250 let response = SendMessageResponse::Message(message.clone());
4251
4252 assert_eq!(response.render_text(), message.render_text());
4253 assert!(response.render_text().contains("no task needed"));
4254 }
4255
4256 #[test]
4261 fn test_agent_card_render_text_marks_an_empty_interface_list() {
4262 let card = AgentCard {
4263 name: "Bare".to_string(),
4264 description: "no interfaces".to_string(),
4265 version: VERSION.to_string(),
4266 supported_interfaces: vec![],
4267 capabilities: AgentCapabilities {
4268 streaming: None,
4269 push_notifications: None,
4270 extensions: None,
4271 extended_agent_card: None,
4272 },
4273 default_input_modes: vec![],
4274 default_output_modes: vec![],
4275 skills: vec![],
4276 provider: None,
4277 documentation_url: None,
4278 icon_url: None,
4279 security_schemes: None,
4280 security_requirements: None,
4281 signatures: None,
4282 };
4283
4284 let text = card.render_text();
4285 assert!(text.contains("Interfaces:\n (none)"), "{text}");
4286 assert!(text.contains("Streaming: false"), "{text}");
4288 }
4289
4290 #[test]
4294 fn test_task_render_text_lists_artifacts_and_resume_hint() {
4295 let mut task = make_fixture_task(
4296 "task-art",
4297 "ctx-art",
4298 TaskState::InputRequired,
4299 "which region?",
4300 );
4301 task.artifacts = Some(vec![
4302 Artifact {
4303 artifact_id: "art-1".to_string(),
4304 name: Some("summary".to_string()),
4305 description: None,
4306 parts: vec![Part::text("all clear")],
4307 metadata: None,
4308 extensions: None,
4309 },
4310 Artifact {
4311 artifact_id: "art-2".to_string(),
4312 name: None,
4313 description: None,
4314 parts: vec![Part::data(serde_json::json!({"rows": 3}))],
4315 metadata: None,
4316 extensions: None,
4317 },
4318 ]);
4319
4320 let text = task.render_text();
4321 assert!(text.contains("Artifact: summary"), "{text}");
4322 assert!(text.contains("all clear"), "{text}");
4323 assert!(text.contains("Artifact: art-2"), "{text}");
4325 assert!(text.contains("\"rows\": 3"), "{text}");
4326 assert!(
4328 text.contains("Resume with: a2acli send --task-id task-art \"<reply>\""),
4329 "{text}"
4330 );
4331 }
4332
4333 #[test]
4337 fn test_task_state_label_covers_every_state() {
4338 let cases = [
4339 (TaskState::Unspecified, "UNSPECIFIED"),
4340 (TaskState::Submitted, "SUBMITTED"),
4341 (TaskState::Working, "WORKING"),
4342 (TaskState::Completed, "COMPLETED"),
4343 (TaskState::Failed, "FAILED"),
4344 (TaskState::Canceled, "CANCELED"),
4345 (TaskState::InputRequired, "INPUT_REQUIRED"),
4346 (TaskState::Rejected, "REJECTED"),
4347 (TaskState::AuthRequired, "AUTH_REQUIRED"),
4348 ];
4349
4350 for (state, expected) in cases {
4351 assert_eq!(task_state_label(&state), expected);
4352 assert!(!expected.starts_with("TASK_STATE_"));
4353 }
4354 }
4355
4356 #[test]
4357 fn test_list_tasks_render_text_empty_and_paged() {
4358 let empty = ListTasksResponse {
4359 tasks: vec![],
4360 next_page_token: "next-token".to_string(),
4361 page_size: 10,
4362 total_size: 0,
4363 };
4364
4365 let text = empty.render_text();
4366 assert!(text.contains("Total: 0"), "{text}");
4367 assert!(text.contains("Page Size: 10"), "{text}");
4368 assert!(text.contains("Next Page Token: next-token"), "{text}");
4369 assert!(text.contains("Tasks: (none)"), "{text}");
4370
4371 let populated = ListTasksResponse {
4372 tasks: vec![make_fixture_task(
4373 "task-1",
4374 "ctx-1",
4375 TaskState::Completed,
4376 "done",
4377 )],
4378 next_page_token: String::new(),
4379 page_size: 10,
4380 total_size: 1,
4381 };
4382
4383 let text = populated.render_text();
4384 assert!(!text.contains("Next Page Token"), "{text}");
4387 assert!(text.contains("Task:"), "{text}");
4388 assert!(text.contains("Task ID: task-1"), "{text}");
4389 }
4390
4391 #[test]
4392 fn test_push_config_render_text_includes_token_and_auth_scheme() {
4393 let config = TaskPushNotificationConfig {
4394 url: "https://example.com/hook".to_string(),
4395 id: Some("cfg-1".to_string()),
4396 task_id: "task-1".to_string(),
4397 token: Some("tok-1".to_string()),
4398 authentication: Some(AuthenticationInfo {
4399 scheme: "Bearer".to_string(),
4400 credentials: Some("super-secret".to_string()),
4401 }),
4402 tenant: None,
4403 };
4404
4405 let text = config.render_text();
4406 assert!(text.contains("Config ID: cfg-1"), "{text}");
4407 assert!(text.contains("Task ID: task-1"), "{text}");
4408 assert!(text.contains("URL: https://example.com/hook"), "{text}");
4409 assert!(text.contains("Token: tok-1"), "{text}");
4410 assert!(text.contains("Auth Scheme: Bearer"), "{text}");
4411 assert!(!text.contains("super-secret"), "{text}");
4413 }
4414
4415 #[test]
4416 fn test_list_push_configs_render_text_empty_and_paged() {
4417 let empty = ListTaskPushNotificationConfigsResponse {
4418 configs: vec![],
4419 next_page_token: Some("next-token".to_string()),
4420 };
4421
4422 let text = empty.render_text();
4423 assert!(text.contains("Next Page Token: next-token"), "{text}");
4424 assert!(text.contains("Configs: (none)"), "{text}");
4425
4426 let populated = ListTaskPushNotificationConfigsResponse {
4427 configs: vec![TaskPushNotificationConfig {
4428 url: "https://example.com/hook".to_string(),
4429 id: Some("cfg-1".to_string()),
4430 task_id: "task-1".to_string(),
4431 token: None,
4432 authentication: None,
4433 tenant: None,
4434 }],
4435 next_page_token: None,
4436 };
4437
4438 let text = populated.render_text();
4439 assert!(!text.contains("Next Page Token"), "{text}");
4440 assert!(text.contains("Push Config:"), "{text}");
4441 assert!(text.contains("Config ID: cfg-1"), "{text}");
4442 }
4443
4444 #[test]
4445 fn test_push_config_deleted_render_text() {
4446 let deleted = PushConfigDeleted {
4447 deleted: true,
4448 task_id: "task-1".to_string(),
4449 id: "cfg-1".to_string(),
4450 };
4451
4452 let text = deleted.render_text();
4453 assert!(text.contains("Deleted: true"), "{text}");
4454 assert!(text.contains("Task ID: task-1"), "{text}");
4455 assert!(text.contains("Config ID: cfg-1"), "{text}");
4456 }
4457
4458 #[test]
4463 fn test_stream_response_render_text_covers_update_events() {
4464 let status = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
4465 task_id: "task-s".to_string(),
4466 context_id: "ctx-s".to_string(),
4467 status: TaskStatus {
4468 state: TaskState::AuthRequired,
4469 message: None,
4470 timestamp: None,
4471 },
4472 metadata: None,
4473 });
4474
4475 let text = status.render_text();
4476 assert!(text.contains("Task ID: task-s"), "{text}");
4477 assert!(text.contains("Context ID: ctx-s"), "{text}");
4478 assert!(text.contains("State: AUTH_REQUIRED"), "{text}");
4479 assert!(
4480 text.contains("Resume with: a2acli send --task-id task-s \"<reply>\""),
4481 "{text}"
4482 );
4483
4484 let working = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
4486 task_id: "task-s".to_string(),
4487 context_id: "ctx-s".to_string(),
4488 status: TaskStatus {
4489 state: TaskState::Working,
4490 message: None,
4491 timestamp: None,
4492 },
4493 metadata: None,
4494 });
4495 assert!(!working.render_text().contains("Resume with"));
4496
4497 let artifact = StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
4498 task_id: "task-s".to_string(),
4499 context_id: "ctx-s".to_string(),
4500 artifact: Artifact {
4501 artifact_id: "art-9".to_string(),
4502 name: None,
4503 description: None,
4504 parts: vec![Part::text("chunk one")],
4505 metadata: None,
4506 extensions: None,
4507 },
4508 append: None,
4509 last_chunk: None,
4510 metadata: None,
4511 });
4512
4513 let text = artifact.render_text();
4514 assert!(text.contains("Task ID: task-s"), "{text}");
4515 assert!(text.contains("Artifact: art-9"), "{text}");
4516 assert!(text.contains("chunk one"), "{text}");
4517
4518 let task = make_fixture_task("task-s", "ctx-s", TaskState::Completed, "done");
4521 assert_eq!(
4522 StreamResponse::Task(task.clone()).render_text(),
4523 task.render_text()
4524 );
4525 let message = Message::new(Role::Agent, vec![Part::text("interim")]);
4526 assert_eq!(
4527 StreamResponse::Message(message.clone()).render_text(),
4528 message.render_text()
4529 );
4530 }
4531
4532 fn text_cli() -> Cli {
4533 parse_with_matches(&["task", "get", "unused"]).0
4534 }
4535
4536 #[tokio::test]
4540 async fn test_consume_stream_text_mode_does_not_serialize() {
4541 let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
4542 consume_stream(make_test_client(None), stream, &text_cli())
4543 .await
4544 .unwrap();
4545 }
4546
4547 #[test]
4551 fn test_parse_data_part_json_rejects_malformed_content() {
4552 let error = parse_data_part_json("{not json", "payload.json").unwrap_err();
4553
4554 assert_eq!(error.exit_code(), 2);
4555 let message = error.to_string();
4556 assert!(message.contains("payload.json"), "{message}");
4557 assert!(matches!(error, CliError::InvalidInput(_)));
4558 }
4559
4560 struct CardOnlyServer {
4566 base_url: String,
4567 handle: tokio::task::JoinHandle<()>,
4568 }
4569
4570 impl Drop for CardOnlyServer {
4571 fn drop(&mut self) {
4572 self.handle.abort();
4573 }
4574 }
4575
4576 impl CardOnlyServer {
4577 async fn spawn(status: axum::http::StatusCode, body: &'static str) -> Self {
4578 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4579 let base_url = format!("http://{}", listener.local_addr().unwrap());
4580 let app = Router::new().route(
4581 WELL_KNOWN_AGENT_CARD_PATH,
4582 get(move || async move {
4583 (
4584 status,
4585 [(header::CONTENT_TYPE, "application/json")],
4586 body.to_string(),
4587 )
4588 }),
4589 );
4590 let handle = tokio::spawn(async move {
4591 axum::serve(listener, app).await.unwrap();
4592 });
4593
4594 CardOnlyServer { base_url, handle }
4595 }
4596 }
4597
4598 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4603 async fn test_card_fetch_failures_are_classified_per_appendix_d() {
4604 let not_found = CardOnlyServer::spawn(axum::http::StatusCode::NOT_FOUND, "{}").await;
4605 let error = run_test_cli(¬_found.base_url, &["card", "get"])
4606 .await
4607 .unwrap_err();
4608 assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
4609 assert_eq!(error.exit_code(), 3);
4610 assert!(error.envelope().error.hint.is_some());
4611
4612 for status in [
4613 axum::http::StatusCode::UNAUTHORIZED,
4614 axum::http::StatusCode::FORBIDDEN,
4615 ] {
4616 let denied = CardOnlyServer::spawn(status, "{}").await;
4617 let error = run_test_cli(&denied.base_url, &["card", "get"])
4618 .await
4619 .unwrap_err();
4620 assert_eq!(
4621 error.envelope().error.code,
4622 "A2ACLI_ERR_AUTH_FAILED",
4623 "status {status}"
4624 );
4625 assert_eq!(error.exit_code(), 4, "status {status}");
4626 }
4627
4628 let invalid =
4631 CardOnlyServer::spawn(axum::http::StatusCode::OK, r#"{"not":"a card"}"#).await;
4632 let error = run_test_cli(&invalid.base_url, &["card", "get"])
4633 .await
4634 .unwrap_err();
4635 assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
4636 assert_eq!(error.exit_code(), 1);
4637
4638 let base_url = unused_base_url().await;
4640 let error = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
4641 assert_eq!(error.envelope().error.code, "A2ACLI_ERR_UNREACHABLE");
4642 assert_eq!(error.exit_code(), 3);
4643 }
4644
4645 #[test]
4646 fn test_parse_dotenv_basic() {
4647 let parsed = parse_dotenv("A2ACLI_BEARER=secret\nA2ACLI_TRANSPORT=jsonrpc,rest\n");
4648 assert_eq!(
4649 parsed,
4650 vec![
4651 ("A2ACLI_BEARER".to_string(), "secret".to_string()),
4652 ("A2ACLI_TRANSPORT".to_string(), "jsonrpc,rest".to_string()),
4653 ]
4654 );
4655 }
4656
4657 #[test]
4658 fn test_parse_dotenv_ignores_blank_lines_and_comments() {
4659 let parsed = parse_dotenv("\n# a comment\n \nA2ACLI_TENANT=acme\n# another comment\n");
4660 assert_eq!(
4661 parsed,
4662 vec![("A2ACLI_TENANT".to_string(), "acme".to_string())]
4663 );
4664 }
4665
4666 #[test]
4667 fn test_parse_dotenv_tolerates_export_prefix() {
4668 let parsed = parse_dotenv("export A2ACLI_BASE_URL=https://agent.example.com\n");
4669 assert_eq!(
4670 parsed,
4671 vec![(
4672 "A2ACLI_BASE_URL".to_string(),
4673 "https://agent.example.com".to_string()
4674 )]
4675 );
4676 }
4677
4678 #[test]
4679 fn test_parse_dotenv_strips_one_layer_of_quotes() {
4680 let parsed = parse_dotenv("A2ACLI_BEARER=\"Bearer token\"\nA2ACLI_TENANT='acme'\n");
4681 assert_eq!(
4682 parsed,
4683 vec![
4684 ("A2ACLI_BEARER".to_string(), "Bearer token".to_string()),
4685 ("A2ACLI_TENANT".to_string(), "acme".to_string()),
4686 ]
4687 );
4688 }
4689
4690 #[test]
4691 fn test_find_flag_value_supports_space_and_equals_forms() {
4692 let space = vec![
4693 OsString::from("a2acli"),
4694 OsString::from("--config"),
4695 OsString::from("prod.env"),
4696 ];
4697 assert_eq!(
4698 find_flag_value(&space, "--config"),
4699 Some("prod.env".to_string())
4700 );
4701
4702 let equals = vec![
4703 OsString::from("a2acli"),
4704 OsString::from("--config=prod.env"),
4705 ];
4706 assert_eq!(
4707 find_flag_value(&equals, "--config"),
4708 Some("prod.env".to_string())
4709 );
4710
4711 let absent = vec![OsString::from("a2acli"), OsString::from("send")];
4712 assert_eq!(find_flag_value(&absent, "--config"), None);
4713 }
4714
4715 #[test]
4716 fn test_config_source_labels() {
4717 assert_eq!(ConfigSource::Flag.label(), "flag");
4718 assert_eq!(ConfigSource::EnvVar.label(), "environment variable");
4719 assert_eq!(ConfigSource::LocalFile.label(), "local .env file");
4720 assert_eq!(ConfigSource::GlobalFile.label(), "global .env file");
4721 assert_eq!(ConfigSource::Default.label(), "built-in default");
4722 }
4723
4724 #[test]
4725 fn test_redact_secret() {
4726 assert_eq!(
4727 redact_secret(&Some("secret".to_string())),
4728 "(set, redacted)"
4729 );
4730 assert_eq!(redact_secret(&None), "(not set)");
4731 }
4732
4733 #[test]
4740 fn test_normalize_card_reference_host_forms() {
4741 let cases = [
4742 (
4744 "example.com",
4745 "https://example.com/.well-known/agent-card.json",
4746 ),
4747 (
4748 "http://example.com",
4749 "http://example.com/.well-known/agent-card.json",
4750 ),
4751 (
4752 "https://example.com/",
4753 "https://example.com/.well-known/agent-card.json",
4754 ),
4755 (
4757 "localhost:3000",
4758 "http://localhost:3000/.well-known/agent-card.json",
4759 ),
4760 (
4761 "127.0.0.1:8080",
4762 "http://127.0.0.1:8080/.well-known/agent-card.json",
4763 ),
4764 (
4765 "[::1]:9000",
4766 "http://[::1]:9000/.well-known/agent-card.json",
4767 ),
4768 (
4770 "https://example.com/custom/card.json",
4771 "https://example.com/custom/card.json",
4772 ),
4773 (
4774 "http://example.com/agents/a/card",
4775 "http://example.com/agents/a/card",
4776 ),
4777 ];
4778
4779 for (reference, expected) in cases {
4780 assert_eq!(
4781 normalize_card_reference(reference),
4782 CardReference::Url(expected.to_string()),
4783 "reference {reference}"
4784 );
4785 }
4786 }
4787
4788 #[test]
4789 fn test_normalize_card_reference_file_forms() {
4790 assert_eq!(
4792 normalize_card_reference("file:///tmp/card.json"),
4793 CardReference::File(PathBuf::from("/tmp/card.json"))
4794 );
4795 for reference in ["/no/such/card.json", "./card.json", "../card.json"] {
4798 assert_eq!(
4799 normalize_card_reference(reference),
4800 CardReference::File(PathBuf::from(reference)),
4801 "reference {reference}"
4802 );
4803 }
4804 }
4805
4806 #[test]
4812 fn test_windows_path_shapes_are_recognized_as_files() {
4813 for reference in [
4814 r"C:\Users\me\card.json",
4815 r"c:/Users/me/card.json",
4816 r"\\server\share\card.json",
4817 r".\card.json",
4818 r"..\card.json",
4819 ] {
4820 assert!(looks_like_file_path(reference), "{reference}");
4821 assert_eq!(
4822 normalize_card_reference(reference),
4823 CardReference::File(PathBuf::from(reference)),
4824 "{reference}"
4825 );
4826 }
4827
4828 for reference in ["localhost:3000", "a:3000", "example.com:443"] {
4831 assert!(!has_windows_drive_prefix(reference), "{reference}");
4832 assert!(!looks_like_file_path(reference), "{reference}");
4833 }
4834
4835 assert_eq!(
4838 normalize_card_reference("file:///C:/Users/me/card.json"),
4839 CardReference::File(PathBuf::from("C:/Users/me/card.json"))
4840 );
4841 assert_eq!(
4843 normalize_card_reference("file:///tmp/card.json"),
4844 CardReference::File(PathBuf::from("/tmp/card.json"))
4845 );
4846 }
4847
4848 #[test]
4852 fn test_normalize_card_reference_prefers_an_existing_file_over_a_host() {
4853 let dir = std::env::temp_dir().join(format!("a2acli-norm-{}", std::process::id()));
4854 std::fs::create_dir_all(&dir).unwrap();
4855 let path = dir.join("example.com");
4856 std::fs::write(&path, "{}").unwrap();
4857
4858 let previous = std::env::current_dir().unwrap();
4859 std::env::set_current_dir(&dir).unwrap();
4860 let resolved = normalize_card_reference("example.com");
4861 std::env::set_current_dir(previous).unwrap();
4862
4863 assert_eq!(resolved, CardReference::File(PathBuf::from("example.com")));
4864 let _ = std::fs::remove_dir_all(&dir);
4865 }
4866
4867 #[test]
4868 fn test_is_loopback_host() {
4869 for host in [
4870 "localhost",
4871 "localhost:3000",
4872 "127.0.0.1",
4873 "127.0.0.1:80",
4874 "::1",
4875 "[::1]:9000",
4876 ] {
4877 assert!(is_loopback_host(host), "expected loopback: {host}");
4878 }
4879 for host in [
4880 "example.com",
4881 "example.com:443",
4882 "8.8.8.8",
4883 "2606:4700::1111",
4884 ] {
4885 assert!(!is_loopback_host(host), "expected non-loopback: {host}");
4886 }
4887 }
4888
4889 #[test]
4890 fn test_card_file_errors_map_to_appendix_d_codes() {
4891 let missing = CliError::CardFile {
4892 path: "/no/such/card.json".to_string(),
4893 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
4894 };
4895 assert_eq!(missing.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
4896 assert_eq!(missing.exit_code(), 3);
4897
4898 let invalid = CliError::CardInvalid("card.json: missing field `name`".to_string());
4899 assert_eq!(invalid.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
4900 assert_eq!(invalid.exit_code(), 1);
4901 }
4902
4903 #[test]
4907 fn test_resolve_agent_selection_matrix() {
4908 let (cli, matches) = parse_with_matches(&["--agent-card", "example.com", "card", "get"]);
4909 assert_eq!(
4910 resolve_agent_selection(&cli, &matches).unwrap(),
4911 AgentSelection::Card("example.com".to_string())
4912 );
4913
4914 let (cli, matches) = parse_with_matches(&["card", "get"]);
4917 assert_eq!(
4918 resolve_agent_selection(&cli, &matches).unwrap(),
4919 AgentSelection::Card("http://localhost:3000".to_string())
4920 );
4921
4922 let (cli, matches) = parse_with_matches(&["--base-url", "http://host:1", "card", "get"]);
4924 assert_eq!(
4925 resolve_agent_selection(&cli, &matches).unwrap(),
4926 AgentSelection::Card("http://host:1".to_string())
4927 );
4928
4929 let (cli, matches) = parse_with_matches(&[
4930 "--endpoint",
4931 "http://host/jsonrpc",
4932 "--transport",
4933 "jsonrpc",
4934 "card",
4935 "get",
4936 ]);
4937 assert_eq!(
4938 resolve_agent_selection(&cli, &matches).unwrap(),
4939 AgentSelection::Endpoint {
4940 url: "http://host/jsonrpc".to_string(),
4941 binding: Binding::Jsonrpc,
4942 }
4943 );
4944
4945 for transports in [vec![], vec!["jsonrpc", "rest"]] {
4947 let mut args = vec!["--endpoint", "http://host/jsonrpc"];
4948 for transport in &transports {
4949 args.push("--transport");
4950 args.push(transport);
4951 }
4952 args.extend(["card", "get"]);
4953 let (cli, matches) = parse_with_matches(&args);
4954 let error = resolve_agent_selection(&cli, &matches).unwrap_err();
4955 assert_eq!(
4956 error.exit_code(),
4957 2,
4958 "with {} transport(s)",
4959 transports.len()
4960 );
4961 assert!(error.to_string().contains("exactly one --transport"));
4962 }
4963
4964 let (cli, matches) = parse_with_matches(&[
4965 "--agent-card",
4966 "example.com",
4967 "--endpoint",
4968 "http://host/jsonrpc",
4969 "--transport",
4970 "jsonrpc",
4971 "card",
4972 "get",
4973 ]);
4974 let error = resolve_agent_selection(&cli, &matches).unwrap_err();
4975 assert_eq!(error.exit_code(), 2);
4976 assert!(error.to_string().contains("mutually exclusive"));
4977 }
4978
4979 #[test]
4983 fn test_synthesized_endpoint_card_carries_only_the_named_interface() {
4984 let card = synthesized_endpoint_card("http://host/rest", Binding::Rest);
4985
4986 assert_eq!(card.supported_interfaces.len(), 1);
4987 assert_eq!(card.supported_interfaces[0].url, "http://host/rest");
4988 assert_eq!(
4989 card.supported_interfaces[0].protocol_binding,
4990 Binding::Rest.protocol()
4991 );
4992 assert!(card.description.contains("no agent card was resolved"));
4993 assert_eq!(card.capabilities.streaming, None);
4995 assert!(card.skills.is_empty());
4996 }
4997
4998 #[test]
4999 fn test_parse_protocol_version() {
5000 assert_eq!(parse_protocol_version("1"), Some((1, 0)));
5001 assert_eq!(parse_protocol_version("1.0"), Some((1, 0)));
5002 assert_eq!(parse_protocol_version("1.2"), Some((1, 2)));
5003 assert_eq!(parse_protocol_version("1.0.2"), Some((1, 0)));
5006 assert_eq!(parse_protocol_version(" 1.1 "), Some((1, 1)));
5007 assert_eq!(parse_protocol_version("nonsense"), None);
5008 assert_eq!(parse_protocol_version(""), None);
5009 assert_eq!(parse_protocol_version("1.x"), None);
5010 }
5011
5012 #[test]
5015 fn test_validate_a2a_version() {
5016 for accepted in [None, Some("1"), Some("1.0"), Some("1.7")] {
5017 assert!(
5018 validate_a2a_version(accepted).is_ok(),
5019 "should accept {accepted:?}"
5020 );
5021 }
5022
5023 for rejected in ["0.3", "0.9", "2.0", "nonsense", ""] {
5024 let error = validate_a2a_version(Some(rejected)).unwrap_err();
5025 assert_eq!(error.exit_code(), 2, "rejecting {rejected}");
5026 assert_eq!(error.envelope().error.code, "A2ACLI_ERR_USAGE");
5027 }
5028
5029 let error = validate_a2a_version(Some("0.3")).unwrap_err();
5031 assert!(error.to_string().contains("must be 1.x"), "{error}");
5032 }
5033
5034 #[test]
5035 fn test_negotiate_a2a_version_uses_an_explicit_flag_verbatim() {
5036 let negotiated = negotiate_a2a_version(Some("1.0"), "1.0", (1, 0));
5038 assert_eq!(negotiated.version, "1.0");
5039 assert!(negotiated.note.is_none());
5040
5041 let negotiated = negotiate_a2a_version(Some("1.3"), "1.0", (1, 0));
5044 assert_eq!(negotiated.version, "1.3");
5045 assert!(negotiated.note.unwrap().contains("1.3"));
5046 }
5047
5048 #[test]
5052 fn test_negotiate_a2a_version_takes_the_highest_shared_1x() {
5053 let negotiated = negotiate_a2a_version(None, "1.1", (1, 3));
5055 assert_eq!(negotiated.version, "1.1");
5056 assert!(negotiated.note.unwrap().contains("negotiated"));
5057
5058 let negotiated = negotiate_a2a_version(None, "1.5", (1, 3));
5060 assert_eq!(negotiated.version, "1.3");
5061 assert!(negotiated.note.is_none());
5062
5063 let negotiated = negotiate_a2a_version(None, "1.3", (1, 3));
5065 assert_eq!(negotiated.version, "1.3");
5066 assert!(negotiated.note.is_none());
5067 }
5068
5069 #[test]
5072 fn test_negotiate_a2a_version_never_goes_below_the_1x_floor() {
5073 for declared in ["0.3", "0.9", "2.0"] {
5074 let negotiated = negotiate_a2a_version(None, declared, (1, 0));
5075 assert_eq!(negotiated.version, "1.0", "declared {declared}");
5076 let note = negotiated.note.unwrap_or_default();
5077 assert!(note.contains("outside 1.x"), "declared {declared}: {note}");
5078 }
5079
5080 let negotiated = negotiate_a2a_version(None, "not-a-version", (1, 0));
5082 assert_eq!(negotiated.version, "1.0");
5083 assert!(negotiated.note.unwrap().contains("unparseable"));
5084 }
5085
5086 #[test]
5090 fn test_task_outcome_note_names_only_non_success_and_paused_states() {
5091 for (state, expected) in [
5092 (TaskState::Failed, "FAILED"),
5093 (TaskState::Rejected, "REJECTED"),
5094 (TaskState::InputRequired, "INPUT_REQUIRED"),
5095 (TaskState::AuthRequired, "AUTH_REQUIRED"),
5096 ] {
5097 let note =
5098 task_outcome_note(&state).unwrap_or_else(|| panic!("{state:?} should be named"));
5099 assert!(note.contains(expected), "{state:?} -> {note}");
5100 }
5101
5102 for state in [
5103 TaskState::Completed,
5104 TaskState::Canceled,
5105 TaskState::Submitted,
5106 TaskState::Working,
5107 TaskState::Unspecified,
5108 ] {
5109 assert!(
5110 task_outcome_note(&state).is_none(),
5111 "{state:?} should stay silent"
5112 );
5113 }
5114 }
5115
5116 #[test]
5119 fn test_task_outcome_is_read_from_the_reported_value() {
5120 let message = Message::new(Role::Agent, vec![Part::text("hi")]);
5122 assert!(
5123 task_outcome_note(&TaskState::Completed).is_none(),
5124 "sanity: COMPLETED is silent"
5125 );
5126 SendMessageResponse::Message(message.clone()).warn_outcome();
5127 StreamResponse::Message(message).warn_outcome();
5128
5129 let task = make_fixture_task("t-1", "c-1", TaskState::Failed, "broke");
5131 assert_eq!(
5132 task_outcome_note(&task.status.state),
5133 Some("the agent reported it FAILED")
5134 );
5135 SendMessageResponse::Task(task.clone()).warn_outcome();
5136 StreamResponse::Task(task).warn_outcome();
5137
5138 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
5140 task_id: "t-2".to_string(),
5141 context_id: "c-2".to_string(),
5142 status: TaskStatus {
5143 state: TaskState::AuthRequired,
5144 message: None,
5145 timestamp: None,
5146 },
5147 metadata: None,
5148 })
5149 .warn_outcome();
5150 }
5151
5152 #[test]
5157 fn test_flatten_clap_message() {
5158 let rendered = "error: unexpected argument '--nope' found\n\nUsage: a2acli [OPTIONS]\n";
5159 assert_eq!(
5160 flatten_clap_message(rendered),
5161 "unexpected argument '--nope' found"
5162 );
5163
5164 let rendered = "error: the following required arguments were not provided:\n <ID>\n\nUsage: a2acli task get <ID>\n";
5167 assert_eq!(
5168 flatten_clap_message(rendered),
5169 "the following required arguments were not provided: <ID>"
5170 );
5171
5172 assert_eq!(flatten_clap_message("error: bare"), "bare");
5175 assert_eq!(flatten_clap_message("\n\nUsage: a2acli"), "Usage: a2acli");
5176 }
5177
5178 #[test]
5179 fn test_usage_error_maps_to_the_appendix_d_usage_code() {
5180 let error = CliError::Usage("unexpected argument '--nope' found".to_string());
5181 assert_eq!(error.exit_code(), 2);
5182 let envelope = error.envelope();
5183 assert_eq!(envelope.error.code, "A2ACLI_ERR_USAGE");
5184 assert_eq!(envelope.error.message, "unexpected argument '--nope' found");
5185 assert!(envelope.error.hint.unwrap().contains("--help"));
5186 }
5187}