1pub mod card_schema;
5
6use std::collections::{HashMap, HashSet};
7use std::ffi::OsString;
8use std::io::IsTerminal;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Duration;
12
13use a2a::*;
14use a2a_client::auth::AuthInterceptor;
15use a2a_client::{A2AClient, A2AClientFactory, BoxStream};
16use clap::parser::ValueSource;
17use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
18use futures::StreamExt;
19use reqwest::{Client, RequestBuilder};
20use serde::Serialize;
21use serde_json::Value;
22use thiserror::Error;
23
24#[derive(Debug, Clone, Parser, PartialEq, Eq)]
25#[command(
26 name = "a2acli",
27 version,
28 about = "Standalone A2A client CLI",
29 after_help = "\
30Default behavior (SPEC.md §6.5):
31 - Transport: the agent card's first supported interface (--transport overrides)
32 - Task completion: wait until terminal/interrupted state (--async returns immediately)
33 - Output: human-readable text (-o/--output json switches to protocol JSON)
34 - Protocol version: highest shared 1.x, from the agent card (--a2a-version pins it)
35 - Transport security: TLS verification on (--insecure disables it, with a warning)"
36)]
37pub struct Cli {
38 #[arg(long, global = true)]
42 pub config: Option<String>,
43
44 #[arg(
48 short = 'a',
49 long = "agent-card",
50 global = true,
51 value_name = "REF",
52 env = "A2ACLI_AGENT_CARD"
53 )]
54 pub agent_card: Option<String>,
55
56 #[arg(
61 short = 'e',
62 long,
63 global = true,
64 value_name = "REF",
65 env = "A2ACLI_ENDPOINT"
66 )]
67 pub endpoint: Option<String>,
68
69 #[arg(
73 long,
74 global = true,
75 hide = true,
76 default_value = "http://localhost:3000",
77 env = "A2ACLI_BASE_URL"
78 )]
79 pub base_url: String,
80
81 #[arg(
86 long,
87 global = true,
88 value_enum,
89 env = "A2ACLI_TRANSPORT",
90 value_delimiter = ','
91 )]
92 pub transport: Vec<Binding>,
93
94 #[arg(long = "a2a-version", global = true, env = "A2ACLI_A2A_VERSION")]
100 pub a2a_version: Option<String>,
101
102 #[arg(long, global = true, env = "A2ACLI_BEARER")]
103 pub bearer: Option<String>,
104
105 #[arg(long = "api-key", global = true, env = "A2ACLI_API_KEY")]
109 pub api_key: Option<String>,
110
111 #[arg(long = "svc-param", global = true, value_parser = parse_header)]
116 pub svc_params: Vec<HeaderArg>,
117
118 #[arg(long, global = true, env = "A2ACLI_INSECURE")]
122 pub insecure: bool,
123
124 #[arg(long, global = true, env = "A2ACLI_DEBUG")]
132 pub debug: bool,
133
134 #[arg(long, global = true, env = "A2ACLI_TENANT")]
139 pub tenant: Option<String>,
140
141 #[arg(
145 short = 'o',
146 long,
147 global = true,
148 value_enum,
149 default_value_t = OutputFormat::Text,
150 env = "A2ACLI_OUTPUT"
151 )]
152 pub output: OutputFormat,
153
154 #[arg(long, global = true, env = "A2ACLI_COMPACT")]
159 pub compact: bool,
160
161 #[arg(
171 long = "async",
172 alias = "no-wait",
173 global = true,
174 conflicts_with = "wait",
175 env = "A2ACLI_ASYNC"
176 )]
177 pub async_mode: bool,
178
179 #[arg(
183 long,
184 global = true,
185 conflicts_with = "async_mode",
186 env = "A2ACLI_WAIT"
187 )]
188 pub wait: bool,
189
190 #[arg(long, global = true, value_parser = parse_duration, default_value = "2s", env = "A2ACLI_POLL_INTERVAL")]
192 pub poll_interval: Duration,
193
194 #[arg(long, global = true, value_parser = parse_duration, default_value = "30s", env = "A2ACLI_TIMEOUT")]
196 pub timeout: Duration,
197
198 #[command(subcommand)]
199 pub command: Command,
200}
201
202#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
203pub enum Command {
204 Card {
206 #[command(subcommand)]
207 command: CardCommand,
208 },
209 Config {
211 #[command(subcommand)]
212 command: ConfigCommand,
213 },
214 Send(MessageCommand),
216 Task {
218 #[command(subcommand)]
219 command: TaskCommand,
220 },
221 Completion {
223 #[arg(value_name = "SHELL")]
226 shell: clap_complete::Shell,
227 },
228}
229
230#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
231pub enum CardCommand {
232 Get(CardGetCommand),
234}
235
236#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
237pub enum ConfigCommand {
238 Show,
243}
244
245#[derive(Debug, Clone, Args, PartialEq, Eq)]
246pub struct CardGetCommand {
247 #[arg(long)]
249 pub extended: bool,
250
251 #[arg(long)]
255 pub validate: bool,
256}
257
258#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
259pub enum TaskCommand {
260 Get(TaskLookupCommand),
262 List(ListTasksCommand),
264 Cancel(TaskIdCommand),
266 Subscribe(TaskIdCommand),
268 PushConfig {
270 #[command(subcommand)]
271 command: PushConfigCommand,
272 },
273}
274
275#[derive(Debug, Clone, Args, PartialEq, Eq)]
276pub struct MessageCommand {
277 pub text: Option<String>,
281
282 #[arg(long = "text-part")]
285 pub text_parts: Vec<String>,
286
287 #[arg(long = "file-part")]
290 pub file_parts: Vec<String>,
291
292 #[arg(long = "data-part")]
295 pub data_parts: Vec<String>,
296
297 #[arg(long = "media-type")]
300 pub media_types: Vec<String>,
301
302 #[arg(long, env = "A2ACLI_CONTEXT_ID")]
304 pub context_id: Option<String>,
305
306 #[arg(long, env = "A2ACLI_TASK_ID")]
308 pub task_id: Option<String>,
309
310 #[arg(long)]
312 pub history_length: Option<i32>,
313
314 #[arg(long = "accept-output")]
316 pub accepted_output_modes: Vec<String>,
317
318 #[arg(long)]
320 pub return_immediately: bool,
321
322 #[arg(long)]
324 pub stream: bool,
325}
326
327#[derive(Debug, Clone, Args, PartialEq, Eq)]
328pub struct TaskLookupCommand {
329 pub id: String,
331
332 #[arg(long)]
334 pub history_length: Option<i32>,
335}
336
337#[derive(Debug, Clone, Args, PartialEq, Eq)]
338pub struct ListTasksCommand {
339 #[arg(long)]
341 pub context_id: Option<String>,
342
343 #[arg(long, value_enum)]
345 pub status: Option<TaskStateArg>,
346
347 #[arg(long)]
349 pub page_size: Option<i32>,
350
351 #[arg(long)]
353 pub page_token: Option<String>,
354
355 #[arg(long)]
357 pub history_length: Option<i32>,
358
359 #[arg(long)]
361 pub include_artifacts: bool,
362}
363
364#[derive(Debug, Clone, Args, PartialEq, Eq)]
365pub struct TaskIdCommand {
366 pub id: String,
368}
369
370#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
371pub enum PushConfigCommand {
372 Create(CreatePushConfigCommand),
374 Get(PushConfigIdCommand),
376 List(ListPushConfigsCommand),
378 Delete(PushConfigIdCommand),
380}
381
382#[derive(Debug, Clone, Args, PartialEq, Eq)]
383pub struct CreatePushConfigCommand {
384 pub task_id: String,
386
387 pub url: String,
389
390 #[arg(long = "config-id")]
392 pub config_id: Option<String>,
393
394 #[arg(long)]
396 pub token: Option<String>,
397
398 #[arg(long = "auth-scheme")]
400 pub auth_scheme: Option<String>,
401
402 #[arg(long = "auth-credentials")]
404 pub auth_credentials: Option<String>,
405}
406
407#[derive(Debug, Clone, Args, PartialEq, Eq)]
408pub struct PushConfigIdCommand {
409 pub task_id: String,
411
412 pub id: String,
414}
415
416#[derive(Debug, Clone, Args, PartialEq, Eq)]
417pub struct ListPushConfigsCommand {
418 pub task_id: String,
420
421 #[arg(long)]
423 pub page_size: Option<i32>,
424
425 #[arg(long)]
427 pub page_token: Option<String>,
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct HeaderArg {
432 pub name: String,
433 pub value: String,
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
440pub enum OutputFormat {
441 Text,
442 Json,
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
446pub enum Binding {
447 Jsonrpc,
448 #[value(name = "rest")]
451 Rest,
452}
453
454impl Binding {
455 fn protocol(self) -> &'static str {
456 match self {
457 Binding::Jsonrpc => TRANSPORT_PROTOCOL_JSONRPC,
458 Binding::Rest => TRANSPORT_PROTOCOL_HTTP_JSON,
459 }
460 }
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
464pub enum TaskStateArg {
465 Unspecified,
466 Submitted,
467 Working,
468 Completed,
469 Failed,
470 Canceled,
471 InputRequired,
472 Rejected,
473 AuthRequired,
474}
475
476impl From<TaskStateArg> for TaskState {
477 fn from(value: TaskStateArg) -> Self {
478 match value {
479 TaskStateArg::Unspecified => TaskState::Unspecified,
480 TaskStateArg::Submitted => TaskState::Submitted,
481 TaskStateArg::Working => TaskState::Working,
482 TaskStateArg::Completed => TaskState::Completed,
483 TaskStateArg::Failed => TaskState::Failed,
484 TaskStateArg::Canceled => TaskState::Canceled,
485 TaskStateArg::InputRequired => TaskState::InputRequired,
486 TaskStateArg::Rejected => TaskState::Rejected,
487 TaskStateArg::AuthRequired => TaskState::AuthRequired,
488 }
489 }
490}
491
492#[derive(Debug, Error)]
493pub enum CliError {
494 #[error(transparent)]
495 A2A(#[from] A2AError),
496 #[error("http request failed: {0}")]
497 Http(#[from] reqwest::Error),
498 #[error("failed to serialize output: {0}")]
499 Json(#[from] serde_json::Error),
500 #[error("invalid input: {0}")]
501 InvalidInput(String),
502 #[error("failed to read {path}: {source}")]
503 ReadFile {
504 path: String,
505 #[source]
506 source: std::io::Error,
507 },
508 #[error("timed out after {timeout:?} waiting for task {task_id} to settle")]
509 Timeout { task_id: String, timeout: Duration },
510 #[error("failed to read agent card file {path}: {source}")]
513 CardFile {
514 path: String,
515 #[source]
516 source: std::io::Error,
517 },
518 #[error("agent card file is not a valid agent card: {0}")]
521 CardInvalid(String),
522 #[error("agent card failed schema validation ({} violation(s))", violations.len())]
527 CardSchemaInvalid {
528 violations: Vec<card_schema::Violation>,
529 },
530 #[error("{0}")]
535 Usage(String),
536}
537
538const WELL_KNOWN_AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
541
542#[derive(Debug, Clone, Serialize)]
545struct ErrorEnvelope {
546 error: ErrorDetail,
547}
548
549#[derive(Debug, Clone, Serialize)]
550struct ErrorDetail {
551 code: String,
552 message: String,
553 #[serde(skip_serializing_if = "Option::is_none")]
554 hint: Option<String>,
555 #[serde(rename = "a2aCode", skip_serializing_if = "Option::is_none")]
556 a2a_code: Option<i64>,
557 #[serde(skip_serializing_if = "Option::is_none")]
561 details: Option<Vec<card_schema::Violation>>,
562}
563
564impl CliError {
565 pub fn exit_code(&self) -> i32 {
568 match self {
569 CliError::A2A(_) => 1,
570 CliError::Http(error) => http_error_exit_code(error),
571 CliError::Json(_) => 1,
572 CliError::InvalidInput(_) => 2,
573 CliError::ReadFile { .. } => 2,
574 CliError::Timeout { .. } => 5,
575 CliError::CardFile { .. } => 3,
579 CliError::CardInvalid(_) => 1,
580 CliError::CardSchemaInvalid { .. } => 1,
581 CliError::Usage(_) => 2,
582 }
583 }
584
585 fn envelope(&self) -> ErrorEnvelope {
589 let (code, hint, a2a_code) = match self {
590 CliError::A2A(error) => (
591 error_reason(error.code).to_string(),
592 None,
593 Some(error.code as i64),
594 ),
595 CliError::Http(error) => http_error_code_and_hint(error),
596 CliError::Json(_) => ("A2ACLI_ERR_INTERNAL".to_string(), None, None),
597 CliError::InvalidInput(_) => ("A2ACLI_ERR_USAGE".to_string(), None, None),
598 CliError::ReadFile { .. } => (
599 "A2ACLI_ERR_USAGE".to_string(),
600 Some(
601 "check that the --file-part/--data-part path exists and is readable"
602 .to_string(),
603 ),
604 None,
605 ),
606 CliError::CardFile { .. } => (
607 "A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
608 Some("check that the --agent-card path exists and is readable".to_string()),
609 None,
610 ),
611 CliError::Usage(_) => (
612 "A2ACLI_ERR_USAGE".to_string(),
613 Some("run `a2acli --help`, or `a2acli <command> --help`".to_string()),
614 None,
615 ),
616 CliError::CardInvalid(_) => (
617 "A2ACLI_ERR_CARD_INVALID".to_string(),
618 Some("the agent card did not match the expected schema".to_string()),
619 None,
620 ),
621 CliError::CardSchemaInvalid { .. } => (
622 "A2ACLI_ERR_CARD_INVALID".to_string(),
623 Some(format!(
624 "against the A2A JSON schema, version {}; see `details`",
625 card_schema::SCHEMA_A2A_VERSION
626 )),
627 None,
628 ),
629 CliError::Timeout { .. } => (
630 "A2ACLI_ERR_TIMEOUT".to_string(),
631 Some(
632 "increase --timeout/--poll-interval, or check the task later with `task get <id> --wait`"
633 .to_string(),
634 ),
635 None,
636 ),
637 };
638
639 let details = match self {
640 CliError::CardSchemaInvalid { violations } => Some(violations.clone()),
641 _ => None,
642 };
643
644 ErrorEnvelope {
645 error: ErrorDetail {
646 code,
647 message: self.to_string(),
648 hint,
649 a2a_code,
650 details,
651 },
652 }
653 }
654
655 pub fn report(&self) {
659 match serde_json::to_string(&self.envelope()) {
660 Ok(json) => eprintln!("{json}"),
661 Err(_) => eprintln!(
662 "{{\"error\":{{\"code\":\"A2ACLI_ERR_INTERNAL\",\"message\":{:?}}}}}",
663 self.to_string()
664 ),
665 }
666 }
667}
668
669fn http_error_code_and_hint(error: &reqwest::Error) -> (String, Option<String>, Option<i64>) {
675 if error.is_decode() {
676 return (
677 "A2ACLI_ERR_CARD_INVALID".to_string(),
678 Some("the agent card did not match the expected schema".to_string()),
679 None,
680 );
681 }
682
683 if let Some(status) = error.status() {
684 if status.as_u16() == 401 || status.as_u16() == 403 {
685 return (
686 "A2ACLI_ERR_AUTH_FAILED".to_string(),
687 Some("credentials were rejected fetching the agent card".to_string()),
688 None,
689 );
690 }
691 return (
692 "A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
693 Some("check --agent-card/--base-url resolves to a valid agent".to_string()),
694 None,
695 );
696 }
697
698 (
699 "A2ACLI_ERR_UNREACHABLE".to_string(),
700 Some("check --agent-card/--endpoint and network connectivity".to_string()),
701 None,
702 )
703}
704
705fn http_error_exit_code(error: &reqwest::Error) -> i32 {
706 if error.is_decode() {
707 return 1;
708 }
709 if let Some(status) = error.status() {
710 return if status.as_u16() == 401 || status.as_u16() == 403 {
711 4
712 } else {
713 3
714 };
715 }
716 3
717}
718
719pub async fn run_args<I, T>(args: I) -> Result<(), CliError>
725where
726 I: IntoIterator<Item = T>,
727 T: Into<OsString>,
728{
729 let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
730 let dotenv_provenance = load_dotenv_config(&args);
733 let matches = match Cli::command().try_get_matches_from(args) {
738 Ok(matches) => matches,
739 Err(error) => return Err(clap_error_to_cli_error(error)),
740 };
741 let cli = Cli::from_arg_matches(&matches).expect("matches were produced by Cli::command()");
742 run(cli, &matches, &dotenv_provenance).await
743}
744
745fn clap_error_to_cli_error(error: clap::Error) -> CliError {
751 use clap::error::ErrorKind;
752
753 if matches!(
754 error.kind(),
755 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
756 ) {
757 error.exit();
762 }
763
764 CliError::Usage(flatten_clap_message(&error.render().to_string()))
772}
773
774fn flatten_clap_message(rendered: &str) -> String {
779 let first_block: Vec<&str> = rendered
783 .lines()
784 .map(str::trim)
785 .take_while(|line| !line.is_empty())
786 .collect();
787 let joined = first_block.join(" ");
788 let message = if joined.is_empty() {
789 rendered.trim()
790 } else {
791 joined.as_str()
792 };
793 message
794 .strip_prefix("error: ")
795 .unwrap_or(message)
796 .to_string()
797}
798
799pub async fn run(
800 cli: Cli,
801 matches: &ArgMatches,
802 dotenv_provenance: &HashMap<String, ConfigSource>,
803) -> Result<(), CliError> {
804 if cli.debug {
805 let _ = tracing_subscriber::fmt()
810 .with_writer(std::io::stderr)
811 .with_env_filter(tracing_subscriber::EnvFilter::new(
817 "warn,a2a_client=debug,a2a_cli=debug",
818 ))
819 .with_ansi(std::io::stderr().is_terminal())
823 .with_target(false)
824 .try_init();
825 }
826
827 validate_a2a_version(cli.a2a_version.as_deref())?;
830
831 match &cli.command {
832 Command::Card { command } => run_card_command(&cli, matches, command).await?,
833 Command::Config { command } => {
834 run_config_command(&cli, matches, dotenv_provenance, command)?
835 }
836 Command::Completion { shell } => {
837 let mut command = Cli::command();
843 let binary = command.get_name().to_string();
844 clap_complete::generate(*shell, &mut command, binary, &mut std::io::stdout());
845 }
846 Command::Send(command) => {
847 let send_matches = matches
848 .subcommand_matches("send")
849 .expect("send subcommand matches present when cli.command is Command::Send");
850 let parts = resolve_message_parts(send_matches, command)?;
851 let ResolvedClient {
852 client,
853 tenant,
854 capabilities,
855 } = resolve_client(&cli, matches).await?;
856 let request = build_send_message_request(command, parts, tenant.clone());
857
858 let streaming = capabilities.declares(Capability::Streaming);
865 if command.stream && !streaming {
866 eprintln!(
867 "warning: the agent card does not declare streaming; \
868 sending without it and polling for the result instead"
869 );
870 }
871
872 if command.stream && streaming {
873 match client.send_streaming_message(&request).await {
874 Ok(stream) => {
875 consume_stream(client, stream, &cli).await?;
876 }
877 Err(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION => {
878 let response = send_and_maybe_wait(
885 client,
886 &request,
887 tenant,
888 cli.async_mode,
889 cli.poll_interval,
890 cli.timeout,
891 )
892 .await?;
893 response.warn_outcome();
894 print_output(&response, &cli)?;
895 }
896 Err(error) => {
897 let _ = client.destroy().await;
898 return Err(error.into());
899 }
900 }
901 } else {
902 let response = send_and_maybe_wait(
903 client,
904 &request,
905 tenant,
906 cli.async_mode,
907 cli.poll_interval,
908 cli.timeout,
909 )
910 .await?;
911 response.warn_outcome();
912 print_output(&response, &cli)?;
913 }
914 }
915 Command::Task { command } => run_task_command(&cli, matches, command).await?,
916 }
917
918 Ok(())
919}
920
921async fn send_and_maybe_wait<T: a2a_client::Transport>(
926 client: A2AClient<T>,
927 request: &SendMessageRequest,
928 tenant: Option<String>,
929 async_mode: bool,
930 poll_interval: Duration,
931 timeout: Duration,
932) -> Result<SendMessageResponse, CliError> {
933 let result = client.send_message(request).await;
934 let response = match result {
935 Ok(response) => response,
936 Err(error) => {
937 let _ = client.destroy().await;
938 return Err(error.into());
939 }
940 };
941
942 match response {
943 SendMessageResponse::Task(task) => {
944 let task =
945 settle_task(client, task, !async_mode, tenant, poll_interval, timeout).await?;
946 Ok(SendMessageResponse::Task(task))
947 }
948 SendMessageResponse::Message(message) => {
949 client.destroy().await?;
950 Ok(SendMessageResponse::Message(message))
951 }
952 }
953}
954
955async fn settle_task<T: a2a_client::Transport>(
959 client: A2AClient<T>,
960 task: Task,
961 wait: bool,
962 tenant: Option<String>,
963 poll_interval: Duration,
964 timeout: Duration,
965) -> Result<Task, CliError> {
966 let outcome = if wait && !is_settled(&task.status.state) {
967 wait_for_task_settled(&client, &task.id, tenant, poll_interval, timeout).await
968 } else {
969 Ok(task)
970 };
971
972 match outcome {
973 Ok(task) => {
974 client.destroy().await?;
975 Ok(task)
976 }
977 Err(error) => {
978 let _ = client.destroy().await;
979 Err(error)
980 }
981 }
982}
983
984async fn wait_for_task_settled<T: a2a_client::Transport>(
989 client: &A2AClient<T>,
990 task_id: &str,
991 tenant: Option<String>,
992 poll_interval: Duration,
993 timeout: Duration,
994) -> Result<Task, CliError> {
995 let deadline = tokio::time::Instant::now() + timeout;
996 loop {
997 let task = client
998 .get_task(&GetTaskRequest {
999 id: task_id.to_string(),
1000 history_length: None,
1001 tenant: tenant.clone(),
1002 })
1003 .await?;
1004
1005 if is_settled(&task.status.state) {
1006 return Ok(task);
1007 }
1008
1009 let now = tokio::time::Instant::now();
1010 if now >= deadline {
1011 return Err(CliError::Timeout {
1012 task_id: task_id.to_string(),
1013 timeout,
1014 });
1015 }
1016
1017 tokio::time::sleep(poll_interval.min(deadline.saturating_duration_since(now))).await;
1018 }
1019}
1020
1021fn task_outcome_note(state: &TaskState) -> Option<&'static str> {
1031 match state {
1032 TaskState::Failed => Some("the agent reported it FAILED"),
1033 TaskState::Rejected => Some("the agent REJECTED it"),
1034 TaskState::InputRequired => {
1035 Some("it is paused at INPUT_REQUIRED and needs a reply to continue")
1036 }
1037 TaskState::AuthRequired => {
1038 Some("it is paused at AUTH_REQUIRED and needs credentials to continue")
1039 }
1040 _ => None,
1041 }
1042}
1043
1044fn warn_task_outcome(task_id: &str, state: &TaskState) {
1045 if let Some(note) = task_outcome_note(state) {
1046 eprintln!("warning: task {task_id}: {note}");
1047 }
1048}
1049
1050trait TaskOutcome {
1054 fn warn_outcome(&self);
1055}
1056
1057impl TaskOutcome for Task {
1058 fn warn_outcome(&self) {
1059 warn_task_outcome(&self.id, &self.status.state);
1060 }
1061}
1062
1063impl TaskOutcome for SendMessageResponse {
1064 fn warn_outcome(&self) {
1065 match self {
1066 SendMessageResponse::Task(task) => task.warn_outcome(),
1067 SendMessageResponse::Message(_) => {}
1070 }
1071 }
1072}
1073
1074impl TaskOutcome for StreamResponse {
1075 fn warn_outcome(&self) {
1076 match self {
1077 StreamResponse::Task(task) => task.warn_outcome(),
1078 StreamResponse::StatusUpdate(event) => {
1079 warn_task_outcome(&event.task_id, &event.status.state);
1080 }
1081 StreamResponse::Message(_) | StreamResponse::ArtifactUpdate(_) => {}
1082 }
1083 }
1084}
1085
1086fn is_settled(state: &TaskState) -> bool {
1089 state.is_terminal() || matches!(state, TaskState::InputRequired | TaskState::AuthRequired)
1090}
1091
1092async fn run_card_command(
1093 cli: &Cli,
1094 matches: &ArgMatches,
1095 command: &CardCommand,
1096) -> Result<(), CliError> {
1097 match command {
1098 CardCommand::Get(command) => {
1099 if command.extended {
1100 let ResolvedClient {
1101 client,
1102 tenant,
1103 capabilities,
1104 } = resolve_client(cli, matches).await?;
1105 capabilities.require(Capability::ExtendedCard)?;
1108 let result = client
1109 .get_extended_agent_card(&GetExtendedAgentCardRequest { tenant })
1110 .await;
1111 let card = finish_client_call(client, result).await?;
1112 if command.validate {
1113 validate_card_or_fail(&serde_json::to_value(&card)?)?;
1121 }
1122 print_output(&card, cli)?;
1123 } else {
1124 let (card, raw) = resolve_agent_card_with_raw(cli, matches).await?;
1125 if command.validate {
1126 validate_card_or_fail(&raw)?;
1127 }
1128 print_output(&card, cli)?;
1129 }
1130 }
1131 }
1132
1133 Ok(())
1134}
1135
1136fn validate_card_or_fail(card: &Value) -> Result<(), CliError> {
1140 let violations = card_schema::validate_agent_card(card)
1141 .map_err(|error| CliError::CardInvalid(format!("schema could not be loaded: {error}")))?;
1142 if violations.is_empty() {
1143 Ok(())
1144 } else {
1145 Err(CliError::CardSchemaInvalid { violations })
1146 }
1147}
1148
1149async fn run_task_command(
1150 cli: &Cli,
1151 matches: &ArgMatches,
1152 command: &TaskCommand,
1153) -> Result<(), CliError> {
1154 match command {
1155 TaskCommand::Get(command) => {
1156 let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
1157 let result = client
1158 .get_task(&GetTaskRequest {
1159 id: command.id.clone(),
1160 history_length: command.history_length,
1161 tenant: tenant.clone(),
1162 })
1163 .await;
1164 let task = match result {
1165 Ok(task) => task,
1166 Err(error) => {
1167 let _ = client.destroy().await;
1168 return Err(error.into());
1169 }
1170 };
1171 let task = settle_task(
1172 client,
1173 task,
1174 cli.wait,
1175 tenant,
1176 cli.poll_interval,
1177 cli.timeout,
1178 )
1179 .await?;
1180 task.warn_outcome();
1181 print_output(&task, cli)?;
1182 }
1183 TaskCommand::List(command) => {
1184 let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
1185 let result = client
1186 .list_tasks(&ListTasksRequest {
1187 context_id: command.context_id.clone(),
1188 status: command.status.map(TaskState::from),
1189 page_size: command.page_size,
1190 page_token: command.page_token.clone(),
1191 history_length: command.history_length,
1192 status_timestamp_after: None,
1193 include_artifacts: command.include_artifacts.then_some(true),
1194 tenant,
1195 })
1196 .await;
1197 let response = finish_client_call(client, result).await?;
1198 print_output(&response, cli)?;
1199 }
1200 TaskCommand::Cancel(command) => {
1201 let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
1202 let result = client
1203 .cancel_task(&CancelTaskRequest {
1204 id: command.id.clone(),
1205 metadata: None,
1206 tenant,
1207 })
1208 .await;
1209 let task = finish_client_call(client, result).await?;
1210 task.warn_outcome();
1211 print_output(&task, cli)?;
1212 }
1213 TaskCommand::Subscribe(command) => {
1214 let ResolvedClient {
1215 client,
1216 tenant,
1217 capabilities,
1218 } = resolve_client(cli, matches).await?;
1219 capabilities.require(Capability::Streaming)?;
1223 let stream = client
1224 .subscribe_to_task(&SubscribeToTaskRequest {
1225 id: command.id.clone(),
1226 tenant: tenant.clone(),
1227 })
1228 .await?;
1229 subscribe_with_resumption(client, stream, command.id.clone(), tenant, cli).await?;
1230 }
1231 TaskCommand::PushConfig { command } => {
1232 run_push_config_command(cli, matches, command).await?;
1233 }
1234 }
1235
1236 Ok(())
1237}
1238
1239fn resolve_message_parts(
1250 matches: &ArgMatches,
1251 command: &MessageCommand,
1252) -> Result<Vec<Part>, CliError> {
1253 enum Kind {
1254 Text,
1255 File,
1256 Data,
1257 }
1258 enum Token<'a> {
1259 Part(Kind, &'a str),
1260 MediaType(&'a str),
1261 }
1262
1263 let mut tokens: Vec<(usize, Token<'_>)> = Vec::new();
1264 if let Some(indices) = matches.indices_of("text_parts") {
1265 tokens.extend(
1266 indices
1267 .zip(command.text_parts.iter())
1268 .map(|(index, value)| (index, Token::Part(Kind::Text, value.as_str()))),
1269 );
1270 }
1271 if let Some(indices) = matches.indices_of("file_parts") {
1272 tokens.extend(
1273 indices
1274 .zip(command.file_parts.iter())
1275 .map(|(index, value)| (index, Token::Part(Kind::File, value.as_str()))),
1276 );
1277 }
1278 if let Some(indices) = matches.indices_of("data_parts") {
1279 tokens.extend(
1280 indices
1281 .zip(command.data_parts.iter())
1282 .map(|(index, value)| (index, Token::Part(Kind::Data, value.as_str()))),
1283 );
1284 }
1285
1286 if !command.media_types.is_empty() && tokens.is_empty() {
1287 return Err(CliError::InvalidInput(
1290 "--media-type must immediately follow a --text-part/--file-part/--data-part"
1291 .to_string(),
1292 ));
1293 }
1294
1295 if tokens.is_empty() {
1296 return match command.text.as_deref() {
1300 Some(text) => Ok(vec![Part::text(text)]),
1301 None => Err(CliError::InvalidInput(
1302 "message must have at least one part: pass text, or use \
1303 --text-part/--file-part/--data-part"
1304 .to_string(),
1305 )),
1306 };
1307 }
1308
1309 if command.text.is_some() {
1310 return Err(CliError::InvalidInput(
1311 "the positional message text cannot be combined with \
1312 --text-part/--file-part/--data-part; pass it as --text-part instead"
1313 .to_string(),
1314 ));
1315 }
1316
1317 if let Some(indices) = matches.indices_of("media_types") {
1318 tokens.extend(
1319 indices
1320 .zip(command.media_types.iter())
1321 .map(|(index, value)| (index, Token::MediaType(value.as_str()))),
1322 );
1323 }
1324 tokens.sort_by_key(|(index, _)| *index);
1325
1326 let mut parts: Vec<Part> = Vec::new();
1327 for (_, token) in tokens {
1328 match token {
1329 Token::Part(kind, value) => {
1330 let part = match kind {
1331 Kind::Text => Part::text(value),
1332 Kind::File => build_file_part(value)?,
1333 Kind::Data => build_data_part(value)?,
1334 };
1335 parts.push(part);
1336 }
1337 Token::MediaType(media_type) => match parts.last_mut() {
1338 Some(part) => part.media_type = Some(media_type.to_string()),
1339 None => {
1340 return Err(CliError::InvalidInput(
1341 "--media-type must immediately follow a --text-part/--file-part/--data-part"
1342 .to_string(),
1343 ));
1344 }
1345 },
1346 }
1347 }
1348
1349 Ok(parts)
1350}
1351
1352fn build_file_part(value: &str) -> Result<Part, CliError> {
1355 if value.contains("://") {
1356 return Ok(Part::url(value));
1357 }
1358
1359 let bytes = std::fs::read(value).map_err(|source| CliError::ReadFile {
1360 path: value.to_string(),
1361 source,
1362 })?;
1363 let mut part = Part::raw(bytes);
1364 part.filename = std::path::Path::new(value)
1365 .file_name()
1366 .map(|name| name.to_string_lossy().into_owned());
1367 Ok(part)
1368}
1369
1370fn means_not_a_readable_path(kind: std::io::ErrorKind) -> bool {
1384 matches!(
1385 kind,
1386 std::io::ErrorKind::NotFound
1387 | std::io::ErrorKind::InvalidFilename
1388 | std::io::ErrorKind::InvalidInput
1389 )
1390}
1391
1392fn build_data_part(value: &str) -> Result<Part, CliError> {
1393 if value == "-" {
1394 use std::io::Read;
1395 let mut buffer = String::new();
1396 std::io::stdin()
1397 .read_to_string(&mut buffer)
1398 .map_err(|source| CliError::ReadFile {
1399 path: "<stdin>".to_string(),
1400 source,
1401 })?;
1402 return parse_data_part_json(&buffer, "<stdin>");
1403 }
1404
1405 match std::fs::read_to_string(value) {
1406 Ok(text) => return parse_data_part_json(&text, value),
1407 Err(source) if !means_not_a_readable_path(source.kind()) => {
1408 return Err(CliError::ReadFile {
1412 path: value.to_string(),
1413 source,
1414 });
1415 }
1416 Err(_) => {} }
1418
1419 serde_json::from_str(value).map(Part::data).map_err(|_| {
1420 CliError::InvalidInput(format!(
1421 "--data-part must be a file path, \"-\" for stdin, or inline JSON: {value}"
1422 ))
1423 })
1424}
1425
1426fn parse_data_part_json(text: &str, source: &str) -> Result<Part, CliError> {
1431 serde_json::from_str(text).map(Part::data).map_err(|error| {
1432 CliError::InvalidInput(format!("--data-part: invalid JSON in {source}: {error}"))
1433 })
1434}
1435
1436fn parse_duration(input: &str) -> Result<Duration, String> {
1438 let trimmed = input.trim();
1439 let (magnitude, unit) = if let Some(value) = trimmed.strip_suffix("ms") {
1440 (value, "ms")
1441 } else if let Some(value) = trimmed.strip_suffix('s') {
1442 (value, "s")
1443 } else if let Some(value) = trimmed.strip_suffix('m') {
1444 (value, "m")
1445 } else {
1446 (trimmed, "s")
1447 };
1448
1449 let magnitude: f64 = magnitude
1450 .trim()
1451 .parse()
1452 .map_err(|_| format!("invalid duration: {input}"))?;
1453 if magnitude < 0.0 || !magnitude.is_finite() {
1454 return Err(format!("duration must be a non-negative number: {input}"));
1455 }
1456
1457 let seconds = match unit {
1458 "ms" => magnitude / 1000.0,
1459 "m" => magnitude * 60.0,
1460 _ => magnitude,
1461 };
1462 Ok(Duration::from_secs_f64(seconds))
1463}
1464
1465fn build_send_message_request(
1466 command: &MessageCommand,
1467 parts: Vec<Part>,
1468 tenant: Option<String>,
1469) -> SendMessageRequest {
1470 let mut message = Message::new(Role::User, parts);
1471 message.context_id = command.context_id.clone();
1472 message.task_id = command.task_id.clone();
1473
1474 let configuration = if command.history_length.is_some()
1475 || !command.accepted_output_modes.is_empty()
1476 || command.return_immediately
1477 {
1478 Some(SendMessageConfiguration {
1479 accepted_output_modes: (!command.accepted_output_modes.is_empty())
1480 .then_some(command.accepted_output_modes.clone()),
1481 task_push_notification_config: None,
1482 history_length: command.history_length,
1483 return_immediately: command.return_immediately.then_some(true),
1484 })
1485 } else {
1486 None
1487 };
1488
1489 SendMessageRequest {
1490 message,
1491 configuration,
1492 metadata: None,
1493 tenant,
1494 }
1495}
1496
1497fn build_push_notification_config(
1498 command: &CreatePushConfigCommand,
1499) -> Result<TaskPushNotificationConfig, CliError> {
1500 if command.auth_credentials.is_some() && command.auth_scheme.is_none() {
1501 return Err(CliError::InvalidInput(
1502 "--auth-credentials requires --auth-scheme".to_string(),
1503 ));
1504 }
1505
1506 let authentication = command
1507 .auth_scheme
1508 .clone()
1509 .map(|scheme| AuthenticationInfo {
1510 scheme,
1511 credentials: command.auth_credentials.clone(),
1512 });
1513
1514 Ok(TaskPushNotificationConfig {
1515 task_id: String::new(),
1516 url: command.url.clone(),
1517 id: command.config_id.clone(),
1518 token: command.token.clone(),
1519 authentication,
1520 tenant: None,
1521 })
1522}
1523
1524async fn run_push_config_command(
1529 cli: &Cli,
1530 matches: &ArgMatches,
1531 command: &PushConfigCommand,
1532) -> Result<(), CliError> {
1533 match command {
1534 PushConfigCommand::Create(command) => {
1535 let ResolvedClient {
1536 client,
1537 tenant,
1538 capabilities,
1539 } = resolve_client(cli, matches).await?;
1540 capabilities.require(Capability::PushNotifications)?;
1541 let mut config = build_push_notification_config(command)?;
1542 config.task_id = command.task_id.clone();
1543 config.tenant = tenant;
1544 let result = client.create_push_config(&config).await;
1545 let response = finish_client_call(client, result).await?;
1546 print_output(&response, cli)?;
1547 }
1548 PushConfigCommand::Get(command) => {
1549 let ResolvedClient {
1550 client,
1551 tenant,
1552 capabilities,
1553 } = resolve_client(cli, matches).await?;
1554 capabilities.require(Capability::PushNotifications)?;
1555 let result = client
1556 .get_push_config(&GetTaskPushNotificationConfigRequest {
1557 task_id: command.task_id.clone(),
1558 id: command.id.clone(),
1559 tenant,
1560 })
1561 .await;
1562 let response = finish_client_call(client, result).await?;
1563 print_output(&response, cli)?;
1564 }
1565 PushConfigCommand::List(command) => {
1566 let ResolvedClient {
1567 client,
1568 tenant,
1569 capabilities,
1570 } = resolve_client(cli, matches).await?;
1571 capabilities.require(Capability::PushNotifications)?;
1572 let result = client
1573 .list_push_configs(&ListTaskPushNotificationConfigsRequest {
1574 task_id: command.task_id.clone(),
1575 page_size: command.page_size,
1576 page_token: command.page_token.clone(),
1577 tenant,
1578 })
1579 .await;
1580 let response = finish_client_call(client, result).await?;
1581 print_output(&response, cli)?;
1582 }
1583 PushConfigCommand::Delete(command) => {
1584 let ResolvedClient {
1585 client,
1586 tenant,
1587 capabilities,
1588 } = resolve_client(cli, matches).await?;
1589 capabilities.require(Capability::PushNotifications)?;
1590 let result = client
1591 .delete_push_config(&DeleteTaskPushNotificationConfigRequest {
1592 task_id: command.task_id.clone(),
1593 id: command.id.clone(),
1594 tenant,
1595 })
1596 .await;
1597 finish_client_call(client, result).await?;
1598 print_output(
1599 &PushConfigDeleted {
1600 deleted: true,
1601 task_id: command.task_id.clone(),
1602 id: command.id.clone(),
1603 },
1604 cli,
1605 )?;
1606 }
1607 }
1608
1609 Ok(())
1610}
1611
1612#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1620pub enum ConfigSource {
1621 Flag,
1622 EnvVar,
1623 LocalFile,
1624 GlobalFile,
1625 Default,
1626}
1627
1628impl ConfigSource {
1629 fn label(self) -> &'static str {
1630 match self {
1631 ConfigSource::Flag => "flag",
1632 ConfigSource::EnvVar => "environment variable",
1633 ConfigSource::LocalFile => "local .env file",
1634 ConfigSource::GlobalFile => "global .env file",
1635 ConfigSource::Default => "built-in default",
1636 }
1637 }
1638}
1639
1640fn load_dotenv_config(args: &[OsString]) -> HashMap<String, ConfigSource> {
1650 let real_env_keys: HashSet<String> = std::env::vars_os()
1654 .filter_map(|(key, _)| key.into_string().ok())
1655 .filter(|key| key.starts_with("A2ACLI_"))
1656 .collect();
1657
1658 let mut provenance = HashMap::new();
1659
1660 if let Some(global_path) = global_config_path() {
1661 apply_dotenv_file(
1662 &global_path,
1663 ConfigSource::GlobalFile,
1664 &real_env_keys,
1665 &mut provenance,
1666 );
1667 }
1668
1669 let local_path = find_flag_value(args, "--config")
1670 .map(PathBuf::from)
1671 .or_else(find_local_dotenv);
1672 if let Some(local_path) = local_path {
1673 apply_dotenv_file(
1674 &local_path,
1675 ConfigSource::LocalFile,
1676 &real_env_keys,
1677 &mut provenance,
1678 );
1679 }
1680
1681 provenance
1682}
1683
1684fn find_flag_value(args: &[OsString], flag: &str) -> Option<String> {
1687 let prefix = format!("{flag}=");
1688 for (index, arg) in args.iter().enumerate() {
1689 let Some(arg) = arg.to_str() else { continue };
1690 if arg == flag {
1691 return args.get(index + 1)?.to_str().map(str::to_string);
1692 }
1693 if let Some(value) = arg.strip_prefix(&prefix) {
1694 return Some(value.to_string());
1695 }
1696 }
1697 None
1698}
1699
1700fn global_config_path() -> Option<PathBuf> {
1702 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
1703 return Some(PathBuf::from(xdg).join("a2a-cli").join(".env"));
1704 }
1705 let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
1706 Some(
1707 PathBuf::from(home)
1708 .join(".config")
1709 .join("a2a-cli")
1710 .join(".env"),
1711 )
1712}
1713
1714fn find_local_dotenv() -> Option<PathBuf> {
1717 let mut dir = std::env::current_dir().ok()?;
1718 loop {
1719 let candidate = dir.join(".env");
1720 if candidate.is_file() {
1721 return Some(candidate);
1722 }
1723 if !dir.pop() {
1724 return None;
1725 }
1726 }
1727}
1728
1729fn apply_dotenv_file(
1730 path: &Path,
1731 source: ConfigSource,
1732 real_env_keys: &HashSet<String>,
1733 provenance: &mut HashMap<String, ConfigSource>,
1734) {
1735 let Ok(contents) = std::fs::read_to_string(path) else {
1736 return;
1737 };
1738 warn_if_world_readable(path);
1739
1740 for (key, value) in parse_dotenv(&contents) {
1741 if !key.starts_with("A2ACLI_") || real_env_keys.contains(&key) {
1742 continue;
1743 }
1744 unsafe {
1749 std::env::set_var(&key, &value);
1750 }
1751 provenance.insert(key, source);
1752 }
1753}
1754
1755#[cfg(unix)]
1759fn warn_if_world_readable(path: &Path) {
1760 use std::os::unix::fs::PermissionsExt;
1761 let Ok(metadata) = std::fs::metadata(path) else {
1762 return;
1763 };
1764 let mode = metadata.permissions().mode();
1765 if mode & 0o077 != 0 {
1766 eprintln!(
1767 "warning: {} is readable by other users (mode {:o}); it may contain credentials — \
1768 consider `chmod 600 {}`",
1769 path.display(),
1770 mode & 0o777,
1771 path.display()
1772 );
1773 }
1774}
1775
1776#[cfg(not(unix))]
1777fn warn_if_world_readable(_path: &Path) {}
1778
1779fn parse_dotenv(contents: &str) -> Vec<(String, String)> {
1783 contents
1784 .lines()
1785 .filter_map(|line| {
1786 let line = line.trim();
1787 if line.is_empty() || line.starts_with('#') {
1788 return None;
1789 }
1790 let line = line.strip_prefix("export ").unwrap_or(line);
1791 let (key, value) = line.split_once('=')?;
1792 let key = key.trim().to_string();
1793 let value = value.trim();
1794 let value = ["\"", "'"]
1795 .iter()
1796 .find_map(|quote| {
1797 value
1798 .strip_prefix(quote)
1799 .and_then(|rest| rest.strip_suffix(quote))
1800 })
1801 .unwrap_or(value);
1802 Some((key, value.to_string()))
1803 })
1804 .collect()
1805}
1806
1807#[derive(Debug, Clone, Serialize)]
1808struct ConfigSetting {
1809 name: String,
1810 value: String,
1811 source: String,
1812}
1813
1814#[derive(Debug, Clone, Serialize)]
1815struct ConfigSettings {
1816 settings: Vec<ConfigSetting>,
1817}
1818
1819impl TextRender for ConfigSettings {
1820 fn render_text(&self) -> String {
1821 let mut out = TextOutput::default();
1822 for setting in &self.settings {
1823 out.field(
1824 &setting.name,
1825 format!("{} (source: {})", setting.value, setting.source),
1826 );
1827 }
1828 out.finish()
1829 }
1830}
1831
1832fn redact_secret(value: &Option<String>) -> String {
1833 match value {
1834 Some(_) => "(set, redacted)".to_string(),
1835 None => "(not set)".to_string(),
1836 }
1837}
1838
1839fn run_config_command(
1840 cli: &Cli,
1841 matches: &ArgMatches,
1842 dotenv_provenance: &HashMap<String, ConfigSource>,
1843 command: &ConfigCommand,
1844) -> Result<(), CliError> {
1845 match command {
1846 ConfigCommand::Show => {
1847 let settings = effective_config_settings(cli, matches, dotenv_provenance);
1848 print_output(&settings, cli)?;
1849 }
1850 }
1851 Ok(())
1852}
1853
1854fn effective_config_settings(
1857 cli: &Cli,
1858 matches: &ArgMatches,
1859 dotenv_provenance: &HashMap<String, ConfigSource>,
1860) -> ConfigSettings {
1861 let mut settings = Vec::new();
1862 let mut add = |id: &str, name: &str, value: String, env_key: &str| {
1863 let source = match matches.value_source(id) {
1864 Some(ValueSource::CommandLine) => ConfigSource::Flag,
1865 Some(ValueSource::EnvVariable) => dotenv_provenance
1866 .get(env_key)
1867 .copied()
1868 .unwrap_or(ConfigSource::EnvVar),
1869 _ => ConfigSource::Default,
1870 };
1871 settings.push(ConfigSetting {
1872 name: name.to_string(),
1873 value,
1874 source: source.label().to_string(),
1875 });
1876 };
1877
1878 add(
1879 "agent_card",
1880 "agent-card",
1881 cli.agent_card
1882 .clone()
1883 .unwrap_or_else(|| "(not set)".to_string()),
1884 "A2ACLI_AGENT_CARD",
1885 );
1886 add(
1887 "endpoint",
1888 "endpoint",
1889 cli.endpoint
1890 .clone()
1891 .unwrap_or_else(|| "(not set)".to_string()),
1892 "A2ACLI_ENDPOINT",
1893 );
1894 add(
1895 "base_url",
1896 "base-url",
1897 cli.base_url.clone(),
1898 "A2ACLI_BASE_URL",
1899 );
1900 add(
1901 "transport",
1902 "transport",
1903 if cli.transport.is_empty() {
1904 "(agent card's own order)".to_string()
1905 } else {
1906 cli.transport
1907 .iter()
1908 .map(|binding| format!("{binding:?}").to_lowercase())
1909 .collect::<Vec<_>>()
1910 .join(",")
1911 },
1912 "A2ACLI_TRANSPORT",
1913 );
1914 add(
1915 "a2a_version",
1916 "a2a-version",
1917 cli.a2a_version.clone().unwrap_or_else(|| {
1918 format!("(negotiated from the agent card; a2acli supports {VERSION})")
1919 }),
1920 "A2ACLI_A2A_VERSION",
1921 );
1922 add(
1923 "bearer",
1924 "bearer",
1925 redact_secret(&cli.bearer),
1926 "A2ACLI_BEARER",
1927 );
1928 add(
1929 "api_key",
1930 "api-key",
1931 redact_secret(&cli.api_key),
1932 "A2ACLI_API_KEY",
1933 );
1934 add(
1935 "insecure",
1936 "insecure",
1937 cli.insecure.to_string(),
1938 "A2ACLI_INSECURE",
1939 );
1940 add("debug", "debug", cli.debug.to_string(), "A2ACLI_DEBUG");
1941 add(
1942 "tenant",
1943 "tenant",
1944 cli.tenant
1945 .clone()
1946 .unwrap_or_else(|| "(not set)".to_string()),
1947 "A2ACLI_TENANT",
1948 );
1949 add(
1950 "output",
1951 "output",
1952 format!("{:?}", cli.output).to_lowercase(),
1953 "A2ACLI_OUTPUT",
1954 );
1955 add(
1956 "compact",
1957 "compact",
1958 cli.compact.to_string(),
1959 "A2ACLI_COMPACT",
1960 );
1961 add(
1962 "async_mode",
1963 "async",
1964 cli.async_mode.to_string(),
1965 "A2ACLI_ASYNC",
1966 );
1967 add("wait", "wait", cli.wait.to_string(), "A2ACLI_WAIT");
1968 add(
1969 "poll_interval",
1970 "poll-interval",
1971 format!("{:?}", cli.poll_interval),
1972 "A2ACLI_POLL_INTERVAL",
1973 );
1974 add(
1975 "timeout",
1976 "timeout",
1977 format!("{:?}", cli.timeout),
1978 "A2ACLI_TIMEOUT",
1979 );
1980
1981 settings.push(ConfigSetting {
1985 name: "resolved-card".to_string(),
1986 value: match &cli.endpoint {
1987 Some(endpoint) => format!("(none; --endpoint {endpoint})"),
1988 None => {
1989 match normalize_card_reference(cli.agent_card.as_deref().unwrap_or(&cli.base_url)) {
1990 CardReference::File(path) => format!("file://{}", path.display()),
1991 CardReference::Url(url) => url,
1992 }
1993 }
1994 },
1995 source: "derived".to_string(),
1996 });
1997
1998 ConfigSettings { settings }
1999}
2000
2001struct ResolvedClient {
2006 client: A2AClient<Box<dyn a2a_client::Transport>>,
2007 tenant: Option<String>,
2008 capabilities: DeclaredCapabilities,
2009}
2010
2011#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2013enum Capability {
2014 Streaming,
2015 PushNotifications,
2016 ExtendedCard,
2017}
2018
2019impl Capability {
2020 fn declared_in(self, capabilities: &AgentCapabilities) -> bool {
2023 match self {
2024 Capability::Streaming => capabilities.streaming.unwrap_or(false),
2025 Capability::PushNotifications => capabilities.push_notifications.unwrap_or(false),
2026 Capability::ExtendedCard => capabilities.extended_agent_card.unwrap_or(false),
2027 }
2028 }
2029
2030 fn field(self) -> &'static str {
2032 match self {
2033 Capability::Streaming => "streaming",
2034 Capability::PushNotifications => "pushNotifications",
2035 Capability::ExtendedCard => "extendedAgentCard",
2036 }
2037 }
2038
2039 fn undeclared_error(self) -> A2AError {
2043 let message = format!("the agent card does not declare {}", self.field());
2044 match self {
2045 Capability::PushNotifications => {
2048 A2AError::new(a2a::error_code::PUSH_NOTIFICATION_NOT_SUPPORTED, message)
2049 }
2050 Capability::Streaming | Capability::ExtendedCard => {
2055 A2AError::unsupported_operation(message)
2056 }
2057 }
2058 }
2059}
2060
2061#[derive(Debug, Clone, PartialEq)]
2063enum DeclaredCapabilities {
2064 Card(AgentCapabilities),
2066 NoCard,
2071}
2072
2073impl DeclaredCapabilities {
2074 fn declares(&self, capability: Capability) -> bool {
2075 match self {
2076 DeclaredCapabilities::Card(capabilities) => capability.declared_in(capabilities),
2077 DeclaredCapabilities::NoCard => true,
2078 }
2079 }
2080
2081 fn require(&self, capability: Capability) -> Result<(), CliError> {
2084 if self.declares(capability) {
2085 Ok(())
2086 } else {
2087 Err(CliError::A2A(capability.undeclared_error()))
2088 }
2089 }
2090}
2091
2092fn parse_protocol_version(value: &str) -> Option<(u32, u32)> {
2096 let mut parts = value.trim().split('.');
2097 let major = parts.next()?.parse().ok()?;
2098 let minor = match parts.next() {
2099 Some(minor) => minor.parse().ok()?,
2100 None => 0,
2101 };
2102 Some((major, minor))
2103}
2104
2105fn supported_protocol_version() -> (u32, u32) {
2107 parse_protocol_version(VERSION).unwrap_or((1, 0))
2108}
2109
2110struct NegotiatedVersion {
2113 version: String,
2114 note: Option<String>,
2117}
2118
2119fn negotiate_a2a_version(
2125 explicit: Option<&str>,
2126 declared: &str,
2127 supported: (u32, u32),
2128) -> NegotiatedVersion {
2129 let supported_label = format!("{}.{}", supported.0, supported.1);
2133
2134 if let Some(requested) = explicit {
2135 let note = (parse_protocol_version(requested) != Some(supported)).then(|| {
2136 format!("signaling A2A-Version {requested} (a2acli supports {supported_label})")
2137 });
2138 return NegotiatedVersion {
2139 version: requested.to_string(),
2140 note,
2141 };
2142 }
2143
2144 let Some(declared_version) = parse_protocol_version(declared) else {
2145 return NegotiatedVersion {
2146 version: supported_label.clone(),
2147 note: Some(format!(
2148 "agent card declares an unparseable protocol version ({declared}); \
2149 signaling {supported_label}"
2150 )),
2151 };
2152 };
2153
2154 if declared_version.0 != 1 {
2157 return NegotiatedVersion {
2158 version: supported_label.clone(),
2159 note: Some(format!(
2160 "agent card declares protocol version {declared}, which is outside 1.x; \
2161 signaling {supported_label} rather than negotiating below 1.0"
2162 )),
2163 };
2164 }
2165
2166 if declared_version < supported {
2167 let version = format!("{}.{}", declared_version.0, declared_version.1);
2168 return NegotiatedVersion {
2169 note: Some(format!(
2170 "negotiated A2A-Version down to {version} (agent card declares {version}, \
2171 a2acli supports {supported_label})"
2172 )),
2173 version,
2174 };
2175 }
2176
2177 NegotiatedVersion {
2178 version: supported_label,
2179 note: None,
2180 }
2181}
2182
2183fn validate_a2a_version(value: Option<&str>) -> Result<(), CliError> {
2188 let Some(requested) = value else {
2189 return Ok(());
2190 };
2191 match parse_protocol_version(requested) {
2192 Some((1, _)) => Ok(()),
2193 Some(_) => Err(CliError::InvalidInput(format!(
2194 "--a2a-version must be 1.x, got {requested}: A2A reads an empty or pre-1.0 \
2195 version as 0.3, and a2acli never signals below 1.0"
2196 ))),
2197 None => Err(CliError::InvalidInput(format!(
2198 "--a2a-version must be a version like 1.0, got {requested}"
2199 ))),
2200 }
2201}
2202
2203struct VersionInterceptor {
2208 version: String,
2209}
2210
2211#[async_trait::async_trait]
2212impl a2a_client::middleware::CallInterceptor for VersionInterceptor {
2213 async fn before(
2214 &self,
2215 _method: &str,
2216 params: &mut a2a_client::ServiceParams,
2217 ) -> Result<(), A2AError> {
2218 params.insert(SVC_PARAM_VERSION.to_string(), vec![self.version.clone()]);
2219 Ok(())
2220 }
2221}
2222
2223#[derive(Debug, Clone, PartialEq, Eq)]
2227enum AgentSelection {
2228 Card(String),
2229 Endpoint { url: String, binding: Binding },
2230}
2231
2232fn resolve_agent_selection(cli: &Cli, matches: &ArgMatches) -> Result<AgentSelection, CliError> {
2233 match (&cli.agent_card, &cli.endpoint) {
2234 (Some(_), Some(_)) => Err(CliError::InvalidInput(
2235 "--endpoint and --agent-card are mutually exclusive: --endpoint names an interface \
2236 directly, so there is no card to resolve"
2237 .to_string(),
2238 )),
2239 (None, Some(url)) => match cli.transport.as_slice() {
2243 [binding] => Ok(AgentSelection::Endpoint {
2244 url: url.clone(),
2245 binding: *binding,
2246 }),
2247 transports => Err(CliError::InvalidInput(format!(
2248 "--endpoint requires exactly one --transport (got {}): there is no agent card \
2249 to declare the interface's protocol binding",
2250 transports.len()
2251 ))),
2252 },
2253 (Some(reference), None) => Ok(AgentSelection::Card(reference.clone())),
2254 (None, None) => Ok(AgentSelection::Card(base_url_card_reference(cli, matches))),
2255 }
2256}
2257
2258fn synthesized_endpoint_card(url: &str, binding: Binding) -> AgentCard {
2262 AgentCard {
2263 name: url.to_string(),
2264 description: "synthesized from --endpoint; no agent card was resolved".to_string(),
2265 version: VERSION.to_string(),
2266 supported_interfaces: vec![AgentInterface::new(url, binding.protocol())],
2267 capabilities: AgentCapabilities::default(),
2268 default_input_modes: vec![],
2269 default_output_modes: vec![],
2270 skills: vec![],
2271 provider: None,
2272 documentation_url: None,
2273 icon_url: None,
2274 security_schemes: None,
2275 security_requirements: None,
2276 signatures: None,
2277 }
2278}
2279
2280async fn resolve_client(cli: &Cli, matches: &ArgMatches) -> Result<ResolvedClient, CliError> {
2281 let card = resolve_agent_card(cli, matches).await?;
2282 let capabilities = match resolve_agent_selection(cli, matches)? {
2286 AgentSelection::Endpoint { .. } => DeclaredCapabilities::NoCard,
2287 AgentSelection::Card(_) => DeclaredCapabilities::Card(card.capabilities.clone()),
2288 };
2289
2290 let mut builder = A2AClientFactory::builder();
2291 if !cli.transport.is_empty() {
2292 let preferred = cli
2293 .transport
2294 .iter()
2295 .map(|binding| binding.protocol().to_string())
2296 .collect();
2297 builder = builder.preferred_bindings(preferred);
2298 }
2299 if cli.insecure {
2300 let insecure = build_insecure_reqwest_client()?;
2301 builder = builder
2302 .register(Arc::new(a2a_client::jsonrpc::JsonRpcTransportFactory::new(
2303 Some(insecure.clone()),
2304 )))
2305 .register(Arc::new(a2a_client::rest::RestTransportFactory::new(Some(
2306 insecure,
2307 ))));
2308 }
2309 let mut interceptors: Vec<Arc<dyn a2a_client::middleware::CallInterceptor>> = Vec::new();
2314 if let Some(token) = &cli.bearer {
2315 interceptors.push(Arc::new(AuthInterceptor::bearer(token.clone())));
2316 }
2317 if let Some(api_key) = &cli.api_key {
2318 interceptors.push(Arc::new(AuthInterceptor::custom(
2319 "X-API-Key",
2320 api_key.clone(),
2321 )));
2322 }
2323 for param in &cli.svc_params {
2324 interceptors.push(Arc::new(AuthInterceptor::custom(
2325 param.name.clone(),
2326 param.value.clone(),
2327 )));
2328 }
2329 if cli.debug {
2330 interceptors.push(Arc::new(a2a_client::middleware::LoggingInterceptor));
2331 }
2332
2333 let factory = builder.build();
2334 let (client, interface) = factory.create_from_card_with_interface(&card).await?;
2335
2336 let negotiated = negotiate_a2a_version(
2337 cli.a2a_version.as_deref(),
2338 &interface.protocol_version,
2339 supported_protocol_version(),
2340 );
2341 if let Some(note) = &negotiated.note {
2344 eprintln!("warning: {note}");
2345 }
2346 interceptors.push(Arc::new(VersionInterceptor {
2347 version: negotiated.version,
2348 }));
2349
2350 let tenant = cli.tenant.clone();
2360 Ok(ResolvedClient {
2361 client: client.with_interceptors(interceptors),
2362 tenant,
2363 capabilities,
2364 })
2365}
2366
2367#[derive(Debug, Clone, PartialEq, Eq)]
2371enum CardReference {
2372 File(PathBuf),
2373 Url(String),
2374}
2375
2376fn looks_like_file_path(reference: &str) -> bool {
2382 reference.starts_with('/')
2383 || reference.starts_with("./")
2384 || reference.starts_with("../")
2385 || reference.starts_with('\\')
2390 || reference.starts_with(".\\")
2391 || reference.starts_with("..\\")
2392 || has_windows_drive_prefix(reference)
2393 || std::fs::metadata(reference).is_ok()
2394}
2395
2396fn has_windows_drive_prefix(reference: &str) -> bool {
2402 let bytes = reference.as_bytes();
2403 bytes.len() >= 3
2404 && bytes[0].is_ascii_alphabetic()
2405 && bytes[1] == b':'
2406 && (bytes[2] == b'\\' || bytes[2] == b'/')
2407}
2408
2409fn is_loopback_host(host: &str) -> bool {
2414 if let Ok(address) = host.parse::<std::net::IpAddr>() {
2418 return address.is_loopback();
2419 }
2420
2421 let host = match host.strip_prefix('[') {
2422 Some(rest) => rest.split_once(']').map_or(rest, |(inside, _)| inside),
2424 None => host.split_once(':').map_or(host, |(head, _)| head),
2426 };
2427
2428 host == "localhost"
2429 || host
2430 .parse::<std::net::IpAddr>()
2431 .is_ok_and(|address| address.is_loopback())
2432}
2433
2434fn normalize_card_reference(reference: &str) -> CardReference {
2439 if let Some(rest) = reference.strip_prefix("file://") {
2440 let path = match rest.strip_prefix('/') {
2441 Some(without_slash) if has_windows_drive_prefix(without_slash) => without_slash,
2446 _ => rest,
2447 };
2448 return CardReference::File(PathBuf::from(path));
2449 }
2450
2451 if !reference.contains("://") {
2452 if looks_like_file_path(reference) {
2453 return CardReference::File(PathBuf::from(reference));
2454 }
2455 let scheme = if is_loopback_host(reference) {
2456 "http"
2457 } else {
2458 "https"
2459 };
2460 return CardReference::Url(append_well_known_path(&format!("{scheme}://{reference}")));
2461 }
2462
2463 CardReference::Url(append_well_known_path(reference))
2464}
2465
2466fn append_well_known_path(url: &str) -> String {
2469 let trimmed = url.trim_end_matches('/');
2470 let has_path = trimmed
2471 .split_once("://")
2472 .is_some_and(|(_, rest)| rest.contains('/'));
2473 if has_path {
2474 url.to_string()
2475 } else {
2476 format!("{trimmed}{WELL_KNOWN_AGENT_CARD_PATH}")
2477 }
2478}
2479
2480fn base_url_card_reference(cli: &Cli, matches: &ArgMatches) -> String {
2486 if matches!(
2487 matches.value_source("base_url"),
2488 Some(ValueSource::CommandLine)
2489 ) {
2490 eprintln!(
2491 "warning: --base-url is deprecated; use --agent-card, which also accepts a full \
2492 card URL or a local file path"
2493 );
2494 }
2495 cli.base_url.clone()
2496}
2497
2498async fn resolve_agent_card(cli: &Cli, matches: &ArgMatches) -> Result<AgentCard, CliError> {
2499 Ok(resolve_agent_card_with_raw(cli, matches).await?.0)
2500}
2501
2502async fn resolve_agent_card_with_raw(
2511 cli: &Cli,
2512 matches: &ArgMatches,
2513) -> Result<(AgentCard, Value), CliError> {
2514 warn_if_insecure_with_credentials(cli);
2515
2516 let reference = match resolve_agent_selection(cli, matches)? {
2517 AgentSelection::Card(reference) => reference,
2518 AgentSelection::Endpoint { url, binding } => {
2519 let card = synthesized_endpoint_card(&url, binding);
2520 let raw = serde_json::to_value(&card)?;
2523 return Ok((card, raw));
2524 }
2525 };
2526
2527 match normalize_card_reference(&reference) {
2528 CardReference::File(path) => read_agent_card_file(&path),
2529 CardReference::Url(url) => {
2530 let client = if cli.insecure {
2531 build_insecure_reqwest_client()?
2532 } else {
2533 Client::new()
2534 };
2535 let request = apply_request_auth(client.get(&url), cli);
2536 let response = request.send().await?.error_for_status()?;
2537 let raw: Value = response.json().await?;
2538 let card: AgentCard = serde_json::from_value(raw.clone())
2543 .map_err(|error| CliError::CardInvalid(format!("{url}: {error}")))?;
2544 Ok((card, raw))
2545 }
2546 }
2547}
2548
2549fn read_agent_card_file(path: &Path) -> Result<(AgentCard, Value), CliError> {
2555 let text = std::fs::read_to_string(path).map_err(|source| CliError::CardFile {
2556 path: path.display().to_string(),
2557 source,
2558 })?;
2559 let raw: Value = serde_json::from_str(&text)
2560 .map_err(|error| CliError::CardInvalid(format!("{}: {error}", path.display())))?;
2561 let card: AgentCard = serde_json::from_value(raw.clone())
2562 .map_err(|error| CliError::CardInvalid(format!("{}: {error}", path.display())))?;
2563 Ok((card, raw))
2564}
2565
2566fn apply_request_auth(mut request: RequestBuilder, cli: &Cli) -> RequestBuilder {
2567 if let Some(token) = &cli.bearer {
2568 request = request.bearer_auth(token);
2569 }
2570 if let Some(api_key) = &cli.api_key {
2571 request = request.header("X-API-Key", api_key);
2572 }
2573 for param in &cli.svc_params {
2574 request = request.header(¶m.name, ¶m.value);
2575 }
2576 request
2577}
2578
2579#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
2581fn build_insecure_reqwest_client() -> Result<Client, CliError> {
2582 Client::builder()
2583 .danger_accept_invalid_certs(true)
2584 .build()
2585 .map_err(CliError::Http)
2586}
2587
2588#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
2591fn build_insecure_reqwest_client() -> Result<Client, CliError> {
2592 Client::builder().build().map_err(CliError::Http)
2593}
2594
2595fn warn_if_insecure_with_credentials(cli: &Cli) {
2599 if !cli.insecure {
2600 return;
2601 }
2602 let sends_credentials = cli.bearer.is_some() || cli.api_key.is_some();
2603 if sends_credentials {
2604 eprintln!(
2605 "warning: --insecure disables TLS certificate verification, and a credential \
2606 (--bearer/--api-key) is being sent over this connection — use only against a \
2607 trusted development endpoint"
2608 );
2609 } else {
2610 eprintln!(
2611 "warning: --insecure disables TLS certificate verification — use only against a \
2612 trusted development endpoint"
2613 );
2614 }
2615}
2616
2617trait TextRender {
2623 fn render_text(&self) -> String;
2624}
2625
2626#[derive(Default)]
2629struct TextOutput {
2630 lines: Vec<String>,
2631}
2632
2633impl TextOutput {
2634 fn field(&mut self, label: &str, value: impl std::fmt::Display) -> &mut Self {
2635 self.lines.push(format!("{label}: {value}"));
2636 self
2637 }
2638
2639 fn raw(&mut self, line: impl Into<String>) -> &mut Self {
2640 self.lines.push(line.into());
2641 self
2642 }
2643
2644 fn block(&mut self, label: &str, content: &str) -> &mut Self {
2647 self.lines.push(format!("{label}:"));
2648 self.lines
2649 .extend(content.lines().map(|line| line.to_string()));
2650 self.lines.push(String::new());
2651 self
2652 }
2653
2654 fn finish(self) -> String {
2655 let mut text = self.lines.join("\n");
2656 while text.ends_with('\n') {
2657 text.pop();
2658 }
2659 text
2660 }
2661}
2662
2663fn task_state_label(state: &TaskState) -> &'static str {
2666 match state {
2667 TaskState::Unspecified => "UNSPECIFIED",
2668 TaskState::Submitted => "SUBMITTED",
2669 TaskState::Working => "WORKING",
2670 TaskState::Completed => "COMPLETED",
2671 TaskState::Failed => "FAILED",
2672 TaskState::Canceled => "CANCELED",
2673 TaskState::InputRequired => "INPUT_REQUIRED",
2674 TaskState::Rejected => "REJECTED",
2675 TaskState::AuthRequired => "AUTH_REQUIRED",
2676 }
2677}
2678
2679fn render_part(out: &mut TextOutput, part: &Part) {
2683 match &part.content {
2684 PartContent::Text(text) => {
2685 out.block("Text", text);
2686 }
2687 PartContent::Data(value) => {
2688 let pretty = serde_json::to_string_pretty(value)
2689 .unwrap_or_else(|_| serde_json::Value::Null.to_string());
2690 out.block("Data", &pretty);
2691 }
2692 PartContent::Raw(bytes) => {
2693 let name = part.filename.as_deref().unwrap_or("(unnamed)");
2694 match &part.media_type {
2695 Some(media_type) => out.field(
2696 "File",
2697 format!("{name} ({media_type}, {} bytes)", bytes.len()),
2698 ),
2699 None => out.field("File", format!("{name} ({} bytes)", bytes.len())),
2700 };
2701 }
2702 PartContent::Url(url) => {
2703 match &part.media_type {
2704 Some(media_type) => out.field("File", format!("{url} ({media_type})")),
2705 None => out.field("File", url),
2706 };
2707 }
2708 }
2709}
2710
2711fn render_message_parts(out: &mut TextOutput, message: &Message) {
2712 if message.parts.is_empty() {
2713 out.field("Parts", "(none)");
2714 return;
2715 }
2716 for part in &message.parts {
2717 render_part(out, part);
2718 }
2719}
2720
2721impl TextRender for AgentCard {
2722 fn render_text(&self) -> String {
2723 let mut out = TextOutput::default();
2724 out.field("Name", &self.name)
2725 .field("Description", &self.description)
2726 .field("Version", &self.version)
2727 .field("Streaming", self.capabilities.streaming.unwrap_or(false))
2728 .field(
2729 "Push Notifications",
2730 self.capabilities.push_notifications.unwrap_or(false),
2731 )
2732 .field(
2733 "Extended Card",
2734 self.capabilities.extended_agent_card.unwrap_or(false),
2735 );
2736
2737 let interfaces = if self.supported_interfaces.is_empty() {
2738 " (none)".to_string()
2739 } else {
2740 self.supported_interfaces
2741 .iter()
2742 .map(|interface| {
2743 format!(
2744 " - {} {} (v{})",
2745 interface.protocol_binding, interface.url, interface.protocol_version
2746 )
2747 })
2748 .collect::<Vec<_>>()
2749 .join("\n")
2750 };
2751 out.block("Interfaces", &interfaces);
2752 out.field("Skills", self.skills.len());
2753 out.finish()
2754 }
2755}
2756
2757impl TextRender for Task {
2758 fn render_text(&self) -> String {
2759 let mut out = TextOutput::default();
2760 out.field("Task ID", &self.id)
2761 .field("Context ID", &self.context_id)
2762 .field("State", task_state_label(&self.status.state));
2763
2764 if let Some(message) = &self.status.message {
2765 render_message_parts(&mut out, message);
2766 }
2767
2768 if let Some(artifacts) = &self.artifacts {
2769 for artifact in artifacts {
2770 let label = artifact.name.as_deref().unwrap_or(&artifact.artifact_id);
2771 out.raw(format!("Artifact: {label}"));
2772 for part in &artifact.parts {
2773 render_part(&mut out, part);
2774 }
2775 }
2776 }
2777
2778 if matches!(
2779 self.status.state,
2780 TaskState::InputRequired | TaskState::AuthRequired
2781 ) {
2782 out.field(
2783 "Resume with",
2784 format!("a2acli send --task-id {} \"<reply>\"", self.id),
2785 );
2786 }
2787
2788 out.finish()
2789 }
2790}
2791
2792impl TextRender for Message {
2793 fn render_text(&self) -> String {
2794 let mut out = TextOutput::default();
2795 if let Some(context_id) = &self.context_id {
2796 out.field("Context ID", context_id);
2797 }
2798 if let Some(task_id) = &self.task_id {
2799 out.field("Task ID", task_id);
2800 }
2801 render_message_parts(&mut out, self);
2802 out.finish()
2803 }
2804}
2805
2806impl TextRender for SendMessageResponse {
2807 fn render_text(&self) -> String {
2808 match self {
2809 SendMessageResponse::Task(task) => task.render_text(),
2810 SendMessageResponse::Message(message) => message.render_text(),
2811 }
2812 }
2813}
2814
2815impl TextRender for ListTasksResponse {
2816 fn render_text(&self) -> String {
2817 let mut out = TextOutput::default();
2818 out.field("Total", self.total_size)
2819 .field("Page Size", self.page_size);
2820 if !self.next_page_token.is_empty() {
2821 out.field("Next Page Token", &self.next_page_token);
2822 }
2823 if self.tasks.is_empty() {
2824 out.field("Tasks", "(none)");
2825 } else {
2826 for task in &self.tasks {
2827 out.block("Task", &task.render_text());
2828 }
2829 }
2830 out.finish()
2831 }
2832}
2833
2834impl TextRender for TaskPushNotificationConfig {
2835 fn render_text(&self) -> String {
2836 let mut out = TextOutput::default();
2837 if let Some(id) = &self.id {
2838 out.field("Config ID", id);
2839 }
2840 out.field("Task ID", &self.task_id).field("URL", &self.url);
2841 if let Some(token) = &self.token {
2842 out.field("Token", token);
2843 }
2844 if let Some(auth) = &self.authentication {
2845 out.field("Auth Scheme", &auth.scheme);
2846 }
2847 out.finish()
2848 }
2849}
2850
2851impl TextRender for ListTaskPushNotificationConfigsResponse {
2852 fn render_text(&self) -> String {
2853 let mut out = TextOutput::default();
2854 if let Some(token) = &self.next_page_token {
2855 out.field("Next Page Token", token);
2856 }
2857 if self.configs.is_empty() {
2858 out.field("Configs", "(none)");
2859 } else {
2860 for config in &self.configs {
2861 out.block("Push Config", &config.render_text());
2862 }
2863 }
2864 out.finish()
2865 }
2866}
2867
2868#[derive(Debug, Clone, Serialize)]
2872#[serde(rename_all = "camelCase")]
2873struct PushConfigDeleted {
2874 deleted: bool,
2875 task_id: String,
2876 id: String,
2877}
2878
2879impl TextRender for PushConfigDeleted {
2880 fn render_text(&self) -> String {
2881 let mut out = TextOutput::default();
2882 out.field("Deleted", self.deleted)
2883 .field("Task ID", &self.task_id)
2884 .field("Config ID", &self.id);
2885 out.finish()
2886 }
2887}
2888
2889impl TextRender for StreamResponse {
2890 fn render_text(&self) -> String {
2891 match self {
2892 StreamResponse::Task(task) => task.render_text(),
2893 StreamResponse::Message(message) => message.render_text(),
2894 StreamResponse::StatusUpdate(event) => {
2895 let mut out = TextOutput::default();
2896 out.field("Task ID", &event.task_id)
2897 .field("Context ID", &event.context_id)
2898 .field("State", task_state_label(&event.status.state));
2899 if matches!(
2900 event.status.state,
2901 TaskState::InputRequired | TaskState::AuthRequired
2902 ) {
2903 out.field(
2904 "Resume with",
2905 format!("a2acli send --task-id {} \"<reply>\"", event.task_id),
2906 );
2907 }
2908 out.finish()
2909 }
2910 StreamResponse::ArtifactUpdate(event) => {
2911 let mut out = TextOutput::default();
2912 out.field("Task ID", &event.task_id)
2913 .field("Context ID", &event.context_id);
2914 let label = event
2915 .artifact
2916 .name
2917 .as_deref()
2918 .unwrap_or(&event.artifact.artifact_id);
2919 out.raw(format!("Artifact: {label}"));
2920 for part in &event.artifact.parts {
2921 render_part(&mut out, part);
2922 }
2923 out.finish()
2924 }
2925 }
2926 }
2927}
2928
2929fn print_output<T: Serialize + TextRender>(value: &T, cli: &Cli) -> Result<(), CliError> {
2933 match cli.output {
2934 OutputFormat::Text => {
2935 println!("{}", value.render_text());
2936 Ok(())
2937 }
2938 OutputFormat::Json => print_json(value, cli.compact),
2939 }
2940}
2941
2942fn print_json<T: Serialize>(value: &T, compact: bool) -> Result<(), CliError> {
2943 if compact {
2944 println!("{}", serde_json::to_string(value)?);
2945 } else {
2946 println!("{}", serde_json::to_string_pretty(value)?);
2947 }
2948 Ok(())
2949}
2950
2951fn parse_header(input: &str) -> Result<HeaderArg, String> {
2952 let (name, value) = input
2953 .split_once(':')
2954 .ok_or_else(|| "header must be in NAME:VALUE format".to_string())?;
2955
2956 let name = name.trim();
2957 let value = value.trim();
2958
2959 if name.is_empty() {
2960 return Err("header name cannot be empty".to_string());
2961 }
2962
2963 Ok(HeaderArg {
2964 name: name.to_string(),
2965 value: value.to_string(),
2966 })
2967}
2968
2969async fn finish_client_call<T: a2a_client::Transport, V>(
2970 client: A2AClient<T>,
2971 result: Result<V, A2AError>,
2972) -> Result<V, CliError> {
2973 match result {
2974 Ok(value) => {
2975 client.destroy().await?;
2976 Ok(value)
2977 }
2978 Err(error) => {
2979 let _ = client.destroy().await;
2980 Err(error.into())
2981 }
2982 }
2983}
2984
2985fn print_stream_value<V: Serialize + TextRender>(value: &V, cli: &Cli) -> Result<(), CliError> {
2987 match cli.output {
2988 OutputFormat::Text => {
2989 println!("{}", value.render_text());
2990 Ok(())
2991 }
2992 OutputFormat::Json => serde_json::to_string(value)
2993 .map(|line| println!("{line}"))
2994 .map_err(CliError::from),
2995 }
2996}
2997
2998async fn print_or_destroy<T: a2a_client::Transport, V: Serialize + TextRender + TaskOutcome>(
3000 client: &A2AClient<T>,
3001 value: &V,
3002 cli: &Cli,
3003) -> Result<(), CliError> {
3004 value.warn_outcome();
3005 if let Err(error) = print_stream_value(value, cli) {
3006 let _ = client.destroy().await;
3007 return Err(error);
3008 }
3009 Ok(())
3010}
3011
3012async fn consume_stream<T: a2a_client::Transport, V: Serialize + TextRender + TaskOutcome>(
3016 client: A2AClient<T>,
3017 mut stream: BoxStream<'static, Result<V, A2AError>>,
3018 cli: &Cli,
3019) -> Result<(), CliError> {
3020 loop {
3021 match stream.next().await {
3022 Some(Ok(value)) => {
3023 print_or_destroy(&client, &value, cli).await?;
3024 }
3025 Some(Err(error)) => {
3026 let _ = client.destroy().await;
3027 return Err(error.into());
3028 }
3029 None => {
3030 client.destroy().await?;
3031 return Ok(());
3032 }
3033 }
3034 }
3035}
3036
3037fn stream_response_state(value: &StreamResponse) -> Option<TaskState> {
3039 match value {
3040 StreamResponse::Task(task) => Some(task.status.state.clone()),
3041 StreamResponse::StatusUpdate(event) => Some(event.status.state.clone()),
3042 StreamResponse::Message(_) | StreamResponse::ArtifactUpdate(_) => None,
3043 }
3044}
3045
3046async fn subscribe_with_resumption<T: a2a_client::Transport>(
3052 client: A2AClient<T>,
3053 mut stream: BoxStream<'static, Result<StreamResponse, A2AError>>,
3054 task_id: String,
3055 tenant: Option<String>,
3056 cli: &Cli,
3057) -> Result<(), CliError> {
3058 let deadline = tokio::time::Instant::now() + cli.timeout;
3059 let mut last_state: Option<TaskState> = None;
3060 let mut attempt: u32 = 0;
3061
3062 loop {
3063 let state_before_this_attempt = last_state.clone();
3064 let mut first_event = attempt > 0;
3065
3066 loop {
3067 match stream.next().await {
3068 Some(Ok(value)) => {
3069 let state = stream_response_state(&value);
3070 if let Some(state) = &state {
3071 last_state = Some(state.clone());
3072 }
3073
3074 let is_unchanged_reconciliation =
3075 first_event && state.is_some() && state == state_before_this_attempt;
3076 first_event = false;
3077 if is_unchanged_reconciliation {
3078 continue;
3079 }
3080
3081 print_or_destroy(&client, &value, cli).await?;
3082 }
3083 Some(Err(error)) => {
3084 let _ = client.destroy().await;
3085 return Err(error.into());
3086 }
3087 None => break,
3088 }
3089 }
3090
3091 if last_state.as_ref().is_some_and(is_settled) {
3092 client.destroy().await?;
3093 return Ok(());
3094 }
3095
3096 loop {
3098 let now = tokio::time::Instant::now();
3099 if now >= deadline {
3100 let _ = client.destroy().await;
3101 return Err(CliError::Timeout {
3102 task_id: task_id.clone(),
3103 timeout: cli.timeout,
3104 });
3105 }
3106 attempt += 1;
3107 eprintln!(
3108 "warning: subscription to task {task_id} was interrupted before it settled; \
3109 reconnecting (attempt {attempt})..."
3110 );
3111 tokio::time::sleep(
3112 cli.poll_interval
3113 .min(deadline.saturating_duration_since(now)),
3114 )
3115 .await;
3116
3117 match client
3118 .subscribe_to_task(&SubscribeToTaskRequest {
3119 id: task_id.clone(),
3120 tenant: tenant.clone(),
3121 })
3122 .await
3123 {
3124 Ok(new_stream) => {
3125 stream = new_stream;
3126 break;
3127 }
3128 Err(_) => continue,
3129 }
3130 }
3131 }
3132}
3133
3134#[cfg(test)]
3135mod tests {
3136 use super::*;
3137 use a2a_client::{ServiceParams, Transport};
3138 use a2a_server::jsonrpc::jsonrpc_router;
3139 use a2a_server::rest::rest_router;
3140 use a2a_server::{
3141 RequestHandler, ServiceParams as HandlerServiceParams, WELL_KNOWN_AGENT_CARD_PATH,
3142 };
3143 use async_trait::async_trait;
3144 use axum::routing::get;
3145 use axum::{Json, Router};
3146 use futures::stream;
3147 use reqwest::header;
3148 use serde::ser;
3149 use std::collections::BTreeMap;
3150 use std::sync::Mutex;
3151 use tokio::net::TcpListener;
3152
3153 struct TestTransport {
3154 destroy_error: Option<A2AError>,
3155 }
3156
3157 #[async_trait]
3158 impl Transport for TestTransport {
3159 async fn send_message(
3160 &self,
3161 _params: &ServiceParams,
3162 _req: &SendMessageRequest,
3163 ) -> Result<SendMessageResponse, A2AError> {
3164 Err(A2AError::unsupported_operation("unused"))
3165 }
3166
3167 async fn send_streaming_message(
3168 &self,
3169 _params: &ServiceParams,
3170 _req: &SendMessageRequest,
3171 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3172 Err(A2AError::unsupported_operation("unused"))
3173 }
3174
3175 async fn get_task(
3176 &self,
3177 _params: &ServiceParams,
3178 _req: &GetTaskRequest,
3179 ) -> Result<Task, A2AError> {
3180 Err(A2AError::unsupported_operation("unused"))
3181 }
3182
3183 async fn list_tasks(
3184 &self,
3185 _params: &ServiceParams,
3186 _req: &ListTasksRequest,
3187 ) -> Result<ListTasksResponse, A2AError> {
3188 Err(A2AError::unsupported_operation("unused"))
3189 }
3190
3191 async fn cancel_task(
3192 &self,
3193 _params: &ServiceParams,
3194 _req: &CancelTaskRequest,
3195 ) -> Result<Task, A2AError> {
3196 Err(A2AError::unsupported_operation("unused"))
3197 }
3198
3199 async fn subscribe_to_task(
3200 &self,
3201 _params: &ServiceParams,
3202 _req: &SubscribeToTaskRequest,
3203 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3204 Err(A2AError::unsupported_operation("unused"))
3205 }
3206
3207 async fn create_push_config(
3208 &self,
3209 _params: &ServiceParams,
3210 _req: &TaskPushNotificationConfig,
3211 ) -> Result<TaskPushNotificationConfig, A2AError> {
3212 Err(A2AError::unsupported_operation("unused"))
3213 }
3214
3215 async fn get_push_config(
3216 &self,
3217 _params: &ServiceParams,
3218 _req: &GetTaskPushNotificationConfigRequest,
3219 ) -> Result<TaskPushNotificationConfig, A2AError> {
3220 Err(A2AError::unsupported_operation("unused"))
3221 }
3222
3223 async fn list_push_configs(
3224 &self,
3225 _params: &ServiceParams,
3226 _req: &ListTaskPushNotificationConfigsRequest,
3227 ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
3228 Err(A2AError::unsupported_operation("unused"))
3229 }
3230
3231 async fn delete_push_config(
3232 &self,
3233 _params: &ServiceParams,
3234 _req: &DeleteTaskPushNotificationConfigRequest,
3235 ) -> Result<(), A2AError> {
3236 Err(A2AError::unsupported_operation("unused"))
3237 }
3238
3239 async fn get_extended_agent_card(
3240 &self,
3241 _params: &ServiceParams,
3242 _req: &GetExtendedAgentCardRequest,
3243 ) -> Result<AgentCard, A2AError> {
3244 Err(A2AError::unsupported_operation("unused"))
3245 }
3246
3247 async fn destroy(&self) -> Result<(), A2AError> {
3248 match &self.destroy_error {
3249 Some(error) => Err(error.clone()),
3250 None => Ok(()),
3251 }
3252 }
3253 }
3254
3255 struct FailingSerialize;
3256
3257 impl Serialize for FailingSerialize {
3258 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
3259 where
3260 S: serde::Serializer,
3261 {
3262 Err(ser::Error::custom("serialize failed"))
3263 }
3264 }
3265
3266 impl TextRender for FailingSerialize {
3267 fn render_text(&self) -> String {
3268 String::new()
3269 }
3270 }
3271
3272 impl TaskOutcome for FailingSerialize {
3273 fn warn_outcome(&self) {}
3274 }
3275
3276 fn make_test_client(destroy_error: Option<A2AError>) -> A2AClient<TestTransport> {
3277 A2AClient::new(TestTransport { destroy_error })
3278 }
3279
3280 #[derive(Default)]
3281 struct RunTestState {
3282 tasks: Mutex<BTreeMap<String, Task>>,
3283 push_configs: Mutex<BTreeMap<(String, String), TaskPushNotificationConfig>>,
3284 }
3285
3286 struct RunTestHandler {
3287 state: Arc<RunTestState>,
3288 extended_card: AgentCard,
3289 }
3290
3291 struct RunTestServer {
3292 base_url: String,
3293 handle: tokio::task::JoinHandle<()>,
3294 }
3295
3296 impl Drop for RunTestServer {
3297 fn drop(&mut self) {
3298 self.handle.abort();
3299 }
3300 }
3301
3302 impl RunTestServer {
3303 async fn spawn() -> Self {
3304 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3305 let base_url = format!("http://{}", listener.local_addr().unwrap());
3306 let state = Arc::new(RunTestState::default());
3307
3308 state.tasks.lock().unwrap().insert(
3309 "task-1".to_string(),
3310 make_fixture_task("task-1", "ctx-1", TaskState::Completed, "seeded result"),
3311 );
3312
3313 let public_card = make_fixture_card(&base_url, "Fixture Agent");
3314 let extended_card = make_fixture_card(&base_url, "Fixture Agent (extended)");
3315 let handler = Arc::new(RunTestHandler {
3316 state,
3317 extended_card,
3318 });
3319
3320 let card = public_card.clone();
3321 let app = Router::new()
3322 .route(
3323 WELL_KNOWN_AGENT_CARD_PATH,
3324 get(move || {
3325 let card = card.clone();
3326 async move { Json(card) }
3327 }),
3328 )
3329 .nest("/jsonrpc", jsonrpc_router(handler.clone()))
3330 .nest("/rest", rest_router(handler));
3331
3332 let handle = tokio::spawn(async move {
3333 axum::serve(listener, app).await.unwrap();
3334 });
3335
3336 RunTestServer { base_url, handle }
3337 }
3338 }
3339
3340 #[async_trait]
3341 impl RequestHandler for RunTestHandler {
3342 async fn send_message(
3343 &self,
3344 _params: &HandlerServiceParams,
3345 req: SendMessageRequest,
3346 ) -> Result<SendMessageResponse, A2AError> {
3347 let task_id = req
3348 .message
3349 .task_id
3350 .clone()
3351 .unwrap_or_else(|| "task-send".to_string());
3352 let context_id = req
3353 .message
3354 .context_id
3355 .clone()
3356 .unwrap_or_else(|| "ctx-send".to_string());
3357 let text = req.message.text().unwrap_or_default();
3358 if text == "send-error" {
3359 return Err(A2AError::invalid_request("send failed"));
3360 }
3361
3362 let task = make_fixture_task(
3363 &task_id,
3364 &context_id,
3365 TaskState::Completed,
3366 &format!("Echo: {text}"),
3367 );
3368 self.state
3369 .tasks
3370 .lock()
3371 .unwrap()
3372 .insert(task_id, task.clone());
3373 Ok(SendMessageResponse::Task(task))
3374 }
3375
3376 async fn send_streaming_message(
3377 &self,
3378 _params: &HandlerServiceParams,
3379 req: SendMessageRequest,
3380 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3381 let task_id = req
3382 .message
3383 .task_id
3384 .clone()
3385 .unwrap_or_else(|| "task-stream".to_string());
3386 let context_id = req
3387 .message
3388 .context_id
3389 .clone()
3390 .unwrap_or_else(|| "ctx-stream".to_string());
3391 let text = req.message.text().unwrap_or_default();
3392 if text == "stream-error" {
3393 return Ok(Box::pin(stream::once(async {
3394 Err(A2AError::internal("stream failed"))
3395 })));
3396 }
3397
3398 let task = make_fixture_task(
3399 &task_id,
3400 &context_id,
3401 TaskState::Completed,
3402 &format!("Echo: {text}"),
3403 );
3404 self.state
3405 .tasks
3406 .lock()
3407 .unwrap()
3408 .insert(task_id.clone(), task.clone());
3409
3410 Ok(Box::pin(stream::iter(vec![
3411 Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
3412 task_id,
3413 context_id,
3414 status: TaskStatus {
3415 state: TaskState::Working,
3416 message: None,
3417 timestamp: None,
3418 },
3419 metadata: None,
3420 })),
3421 Ok(StreamResponse::Task(task)),
3422 ])))
3423 }
3424
3425 async fn get_task(
3426 &self,
3427 _params: &HandlerServiceParams,
3428 req: GetTaskRequest,
3429 ) -> Result<Task, A2AError> {
3430 self.state
3431 .tasks
3432 .lock()
3433 .unwrap()
3434 .get(&req.id)
3435 .cloned()
3436 .ok_or_else(|| A2AError::task_not_found(&req.id))
3437 }
3438
3439 async fn list_tasks(
3440 &self,
3441 _params: &HandlerServiceParams,
3442 req: ListTasksRequest,
3443 ) -> Result<ListTasksResponse, A2AError> {
3444 if req.context_id.as_deref() == Some("error") {
3445 return Err(A2AError::invalid_params("list failed"));
3446 }
3447
3448 let tasks = self
3449 .state
3450 .tasks
3451 .lock()
3452 .unwrap()
3453 .values()
3454 .filter(|task| {
3455 req.context_id
3456 .as_ref()
3457 .map(|context_id| task.context_id == *context_id)
3458 .unwrap_or(true)
3459 })
3460 .filter(|task| {
3461 req.status
3462 .as_ref()
3463 .map(|status| task.status.state == *status)
3464 .unwrap_or(true)
3465 })
3466 .cloned()
3467 .collect();
3468
3469 Ok(ListTasksResponse {
3470 tasks,
3471 next_page_token: String::new(),
3472 page_size: 0,
3473 total_size: 0,
3474 })
3475 }
3476
3477 async fn cancel_task(
3478 &self,
3479 _params: &HandlerServiceParams,
3480 req: CancelTaskRequest,
3481 ) -> Result<Task, A2AError> {
3482 let mut tasks = self.state.tasks.lock().unwrap();
3483 let task = tasks
3484 .get(&req.id)
3485 .cloned()
3486 .ok_or_else(|| A2AError::task_not_found(&req.id))?;
3487 let canceled =
3488 make_fixture_task(&task.id, &task.context_id, TaskState::Canceled, "canceled");
3489 tasks.insert(req.id, canceled.clone());
3490 Ok(canceled)
3491 }
3492
3493 async fn subscribe_to_task(
3494 &self,
3495 _params: &HandlerServiceParams,
3496 req: SubscribeToTaskRequest,
3497 ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3498 if req.id == "stream-error" {
3499 return Ok(Box::pin(stream::once(async {
3500 Err(A2AError::internal("stream failed"))
3501 })));
3502 }
3503
3504 let task = self
3505 .state
3506 .tasks
3507 .lock()
3508 .unwrap()
3509 .get(&req.id)
3510 .cloned()
3511 .ok_or_else(|| A2AError::task_not_found(&req.id))?;
3512
3513 Ok(Box::pin(stream::iter(vec![
3514 Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
3515 task_id: req.id.clone(),
3516 context_id: task.context_id.clone(),
3517 status: TaskStatus {
3518 state: TaskState::Working,
3519 message: None,
3520 timestamp: None,
3521 },
3522 metadata: None,
3523 })),
3524 Ok(StreamResponse::Task(task)),
3525 ])))
3526 }
3527
3528 async fn create_push_config(
3529 &self,
3530 _params: &HandlerServiceParams,
3531 req: TaskPushNotificationConfig,
3532 ) -> Result<TaskPushNotificationConfig, A2AError> {
3533 if !self.state.tasks.lock().unwrap().contains_key(&req.task_id) {
3534 return Err(A2AError::task_not_found(&req.task_id));
3535 }
3536
3537 let config_id = req.id.clone().unwrap_or_else(|| "generated".to_string());
3538 self.state
3539 .push_configs
3540 .lock()
3541 .unwrap()
3542 .insert((req.task_id.clone(), config_id), req.clone());
3543 Ok(req)
3544 }
3545
3546 async fn get_push_config(
3547 &self,
3548 _params: &HandlerServiceParams,
3549 req: GetTaskPushNotificationConfigRequest,
3550 ) -> Result<TaskPushNotificationConfig, A2AError> {
3551 self.state
3552 .push_configs
3553 .lock()
3554 .unwrap()
3555 .get(&(req.task_id.clone(), req.id.clone()))
3556 .cloned()
3557 .ok_or_else(|| A2AError::task_not_found(&req.task_id))
3558 }
3559
3560 async fn list_push_configs(
3561 &self,
3562 _params: &HandlerServiceParams,
3563 req: ListTaskPushNotificationConfigsRequest,
3564 ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
3565 if req.task_id == "missing" {
3566 return Err(A2AError::task_not_found(&req.task_id));
3567 }
3568
3569 let configs = self
3570 .state
3571 .push_configs
3572 .lock()
3573 .unwrap()
3574 .values()
3575 .filter(|config| config.task_id == req.task_id)
3576 .cloned()
3577 .collect();
3578
3579 Ok(ListTaskPushNotificationConfigsResponse {
3580 configs,
3581 next_page_token: None,
3582 })
3583 }
3584
3585 async fn delete_push_config(
3586 &self,
3587 _params: &HandlerServiceParams,
3588 req: DeleteTaskPushNotificationConfigRequest,
3589 ) -> Result<(), A2AError> {
3590 let deleted = self
3591 .state
3592 .push_configs
3593 .lock()
3594 .unwrap()
3595 .remove(&(req.task_id.clone(), req.id.clone()));
3596 if deleted.is_none() {
3597 return Err(A2AError::task_not_found(&req.task_id));
3598 }
3599 Ok(())
3600 }
3601
3602 async fn get_extended_agent_card(
3603 &self,
3604 _params: &HandlerServiceParams,
3605 req: GetExtendedAgentCardRequest,
3606 ) -> Result<AgentCard, A2AError> {
3607 if req.tenant.as_deref() == Some("error") {
3608 return Err(A2AError::unsupported_operation("extended card denied"));
3609 }
3610
3611 Ok(self.extended_card.clone())
3612 }
3613 }
3614
3615 fn make_fixture_card(base_url: &str, name: &str) -> AgentCard {
3616 AgentCard {
3617 name: name.to_string(),
3618 description: "CLI unit-test fixture".to_string(),
3619 version: VERSION.to_string(),
3620 supported_interfaces: vec![
3621 AgentInterface::new(format!("{base_url}/jsonrpc"), TRANSPORT_PROTOCOL_JSONRPC),
3622 AgentInterface::new(format!("{base_url}/rest"), TRANSPORT_PROTOCOL_HTTP_JSON),
3623 ],
3624 capabilities: AgentCapabilities {
3625 streaming: Some(true),
3626 push_notifications: Some(true),
3627 extensions: None,
3628 extended_agent_card: Some(true),
3629 },
3630 default_input_modes: vec!["text/plain".to_string()],
3631 default_output_modes: vec!["text/plain".to_string()],
3632 skills: vec![],
3633 provider: None,
3634 documentation_url: None,
3635 icon_url: None,
3636 security_schemes: None,
3637 security_requirements: None,
3638 signatures: None,
3639 }
3640 }
3641
3642 fn make_fixture_task(task_id: &str, context_id: &str, state: TaskState, text: &str) -> Task {
3643 Task {
3644 id: task_id.to_string(),
3645 context_id: context_id.to_string(),
3646 status: TaskStatus {
3647 state,
3648 message: Some(Message {
3649 message_id: format!("msg-{task_id}"),
3650 context_id: Some(context_id.to_string()),
3651 task_id: Some(task_id.to_string()),
3652 role: Role::Agent,
3653 parts: vec![Part::text(text)],
3654 metadata: None,
3655 extensions: None,
3656 reference_task_ids: None,
3657 }),
3658 timestamp: None,
3659 },
3660 artifacts: None,
3661 history: None,
3662 metadata: None,
3663 }
3664 }
3665
3666 fn build_args(base_url: &str, args: &[&str]) -> Vec<String> {
3667 let mut argv = vec![
3668 "a2acli".to_string(),
3669 "--agent-card".to_string(),
3670 base_url.to_string(),
3671 ];
3672 argv.extend(args.iter().map(|arg| (*arg).to_string()));
3673 argv
3674 }
3675
3676 async fn run_test_cli(base_url: &str, args: &[&str]) -> Result<(), CliError> {
3680 run_args(build_args(base_url, args)).await
3681 }
3682
3683 async fn unused_base_url() -> String {
3684 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3685 let addr = listener.local_addr().unwrap();
3686 drop(listener);
3687 format!("http://{addr}")
3688 }
3689
3690 fn assert_unsupported_operation<T>(result: Result<T, A2AError>) {
3691 match result {
3692 Ok(_) => panic!("expected unsupported operation error"),
3693 Err(err) => assert_eq!(err.code, a2a::error_code::UNSUPPORTED_OPERATION),
3694 }
3695 }
3696
3697 #[test]
3698 fn test_parse_header() {
3699 let header = parse_header("Authorization: Bearer token").unwrap();
3700 assert_eq!(header.name, "Authorization");
3701 assert_eq!(header.value, "Bearer token");
3702 }
3703
3704 #[test]
3705 fn test_parse_header_requires_separator() {
3706 let err = parse_header("Authorization").unwrap_err();
3707 assert_eq!(err, "header must be in NAME:VALUE format");
3708 }
3709
3710 #[test]
3711 fn test_parse_header_requires_name() {
3712 let err = parse_header(": value").unwrap_err();
3713 assert_eq!(err, "header name cannot be empty");
3714 }
3715
3716 #[test]
3717 fn test_build_send_message_request_populates_optional_fields() {
3718 let request = build_send_message_request(
3719 &MessageCommand {
3720 text: Some("hello".to_string()),
3721 text_parts: Vec::new(),
3722 file_parts: Vec::new(),
3723 data_parts: Vec::new(),
3724 media_types: Vec::new(),
3725 context_id: Some("ctx-1".to_string()),
3726 task_id: Some("task-1".to_string()),
3727 history_length: Some(4),
3728 accepted_output_modes: vec!["text/plain".to_string()],
3729 return_immediately: true,
3730 stream: false,
3731 },
3732 vec![Part::text("hello")],
3733 Some("tenant-1".to_string()),
3734 );
3735
3736 assert_eq!(request.message.text(), Some("hello"));
3737 assert_eq!(request.message.context_id.as_deref(), Some("ctx-1"));
3738 assert_eq!(request.message.task_id.as_deref(), Some("task-1"));
3739 assert_eq!(request.tenant.as_deref(), Some("tenant-1"));
3740 assert_eq!(
3741 request
3742 .configuration
3743 .as_ref()
3744 .and_then(|config| config.history_length),
3745 Some(4)
3746 );
3747 assert_eq!(
3748 request
3749 .configuration
3750 .as_ref()
3751 .and_then(|config| config.return_immediately),
3752 Some(true)
3753 );
3754 }
3755
3756 #[test]
3757 fn test_build_send_message_request_without_optional_fields() {
3758 let request = build_send_message_request(
3759 &MessageCommand {
3760 text: Some("hello".to_string()),
3761 text_parts: Vec::new(),
3762 file_parts: Vec::new(),
3763 data_parts: Vec::new(),
3764 media_types: Vec::new(),
3765 context_id: None,
3766 task_id: None,
3767 history_length: None,
3768 accepted_output_modes: Vec::new(),
3769 return_immediately: false,
3770 stream: false,
3771 },
3772 vec![Part::text("hello")],
3773 None,
3774 );
3775
3776 assert_eq!(request.message.text(), Some("hello"));
3777 assert!(request.configuration.is_none());
3778 assert!(request.tenant.is_none());
3779 }
3780
3781 #[test]
3782 fn test_cli_parse_send_command() {
3783 let cli = Cli::try_parse_from([
3784 "a2acli",
3785 "--transport",
3786 "jsonrpc",
3787 "--svc-param",
3788 "X-Test:123",
3789 "send",
3790 "hello",
3791 "--history-length",
3792 "2",
3793 ])
3794 .unwrap();
3795
3796 assert_eq!(cli.transport, vec![Binding::Jsonrpc]);
3797 assert_eq!(cli.svc_params.len(), 1);
3798 assert!(matches!(cli.command, Command::Send(_)));
3799 }
3800
3801 #[test]
3802 fn test_cli_parse_push_config_create_command() {
3803 let cli = Cli::try_parse_from([
3804 "a2acli",
3805 "task",
3806 "push-config",
3807 "create",
3808 "task-1",
3809 "https://example.com/callback",
3810 "--config-id",
3811 "cfg-1",
3812 "--token",
3813 "tok-1",
3814 "--auth-scheme",
3815 "Bearer",
3816 "--auth-credentials",
3817 "secret",
3818 ])
3819 .unwrap();
3820
3821 match cli.command {
3822 Command::Task {
3823 command:
3824 TaskCommand::PushConfig {
3825 command: PushConfigCommand::Create(command),
3826 },
3827 } => {
3828 assert_eq!(command.task_id, "task-1");
3829 assert_eq!(command.url, "https://example.com/callback");
3830 assert_eq!(command.config_id.as_deref(), Some("cfg-1"));
3831 assert_eq!(command.token.as_deref(), Some("tok-1"));
3832 assert_eq!(command.auth_scheme.as_deref(), Some("Bearer"));
3833 assert_eq!(command.auth_credentials.as_deref(), Some("secret"));
3834 }
3835 other => panic!("unexpected command: {other:?}"),
3836 }
3837 }
3838
3839 #[test]
3840 fn test_build_push_notification_config() {
3841 let config = build_push_notification_config(&CreatePushConfigCommand {
3842 task_id: "task-1".to_string(),
3843 url: "https://example.com/callback".to_string(),
3844 config_id: Some("cfg-1".to_string()),
3845 token: Some("tok-1".to_string()),
3846 auth_scheme: Some("Bearer".to_string()),
3847 auth_credentials: Some("secret".to_string()),
3848 })
3849 .unwrap();
3850
3851 assert_eq!(config.id.as_deref(), Some("cfg-1"));
3852 assert_eq!(config.token.as_deref(), Some("tok-1"));
3853 assert_eq!(
3854 config
3855 .authentication
3856 .as_ref()
3857 .map(|auth| auth.scheme.as_str()),
3858 Some("Bearer")
3859 );
3860 assert_eq!(
3861 config
3862 .authentication
3863 .as_ref()
3864 .and_then(|auth| auth.credentials.as_deref()),
3865 Some("secret")
3866 );
3867 }
3868
3869 #[test]
3870 fn test_build_push_notification_config_requires_auth_scheme() {
3871 let err = build_push_notification_config(&CreatePushConfigCommand {
3872 task_id: "task-1".to_string(),
3873 url: "https://example.com/callback".to_string(),
3874 config_id: None,
3875 token: None,
3876 auth_scheme: None,
3877 auth_credentials: Some("secret".to_string()),
3878 })
3879 .unwrap_err();
3880
3881 assert!(matches!(err, CliError::InvalidInput(_)));
3882 }
3883
3884 #[test]
3885 fn test_build_push_notification_config_without_authentication() {
3886 let config = build_push_notification_config(&CreatePushConfigCommand {
3887 task_id: "task-1".to_string(),
3888 url: "https://example.com/callback".to_string(),
3889 config_id: None,
3890 token: None,
3891 auth_scheme: None,
3892 auth_credentials: None,
3893 })
3894 .unwrap();
3895
3896 assert_eq!(config.url, "https://example.com/callback");
3897 assert!(config.authentication.is_none());
3898 }
3899
3900 #[test]
3901 fn test_binding_protocols() {
3902 assert_eq!(Binding::Jsonrpc.protocol(), TRANSPORT_PROTOCOL_JSONRPC);
3903 assert_eq!(Binding::Rest.protocol(), TRANSPORT_PROTOCOL_HTTP_JSON);
3904 }
3905
3906 #[test]
3907 fn test_apply_request_auth_builds_headers() {
3908 let cli = Cli::try_parse_from([
3909 "a2acli",
3910 "--bearer",
3911 "secret",
3912 "--api-key",
3913 "key-123",
3914 "--svc-param",
3915 "X-Test: 123",
3916 "card",
3917 "get",
3918 ])
3919 .unwrap();
3920
3921 let request = apply_request_auth(Client::new().get("http://example.com"), &cli)
3922 .build()
3923 .unwrap();
3924
3925 assert_eq!(
3926 request.headers().get(header::AUTHORIZATION).unwrap(),
3927 "Bearer secret"
3928 );
3929 assert_eq!(request.headers().get("X-API-Key").unwrap(), "key-123");
3930 assert_eq!(request.headers().get("X-Test").unwrap(), "123");
3931 }
3932
3933 #[tokio::test]
3934 async fn test_finish_client_call_propagates_destroy_error() {
3935 let err = finish_client_call(
3936 make_test_client(Some(A2AError::internal("destroy failed"))),
3937 Ok(serde_json::json!({ "ok": true })),
3938 )
3939 .await
3940 .unwrap_err();
3941
3942 assert!(matches!(err, CliError::A2A(_)));
3943 }
3944
3945 fn json_cli() -> Cli {
3946 parse_with_matches(&["--output", "json", "task", "get", "unused"]).0
3947 }
3948
3949 #[tokio::test]
3950 async fn test_consume_stream_reports_json_error() {
3951 let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
3952 let err = consume_stream(make_test_client(None), stream, &json_cli())
3953 .await
3954 .unwrap_err();
3955
3956 assert!(matches!(err, CliError::Json(_)));
3957 }
3958
3959 #[tokio::test]
3965 async fn test_print_or_destroy_reports_json_error() {
3966 let client = make_test_client(None);
3967 let err = print_or_destroy(&client, &FailingSerialize, &json_cli())
3968 .await
3969 .unwrap_err();
3970
3971 assert!(matches!(err, CliError::Json(_)));
3972 }
3973
3974 #[tokio::test]
3975 async fn test_print_or_destroy_succeeds_in_text_mode() {
3976 let client = make_test_client(None);
3977 print_or_destroy(&client, &FailingSerialize, &text_cli())
3978 .await
3979 .unwrap();
3980 }
3981
3982 #[tokio::test]
3983 async fn test_consume_stream_propagates_destroy_error_on_completion() {
3984 let stream = Box::pin(stream::empty::<Result<Task, A2AError>>());
3985 let err = consume_stream(
3986 make_test_client(Some(A2AError::internal("destroy failed"))),
3987 stream,
3988 &json_cli(),
3989 )
3990 .await
3991 .unwrap_err();
3992
3993 assert!(matches!(err, CliError::A2A(_)));
3994 }
3995
3996 #[tokio::test]
3997 async fn test_test_transport_methods_return_unused_errors() {
3998 let transport = TestTransport {
3999 destroy_error: None,
4000 };
4001 let params = ServiceParams::new();
4002
4003 assert_unsupported_operation(
4004 transport
4005 .send_message(
4006 ¶ms,
4007 &SendMessageRequest {
4008 message: Message::new(Role::User, vec![Part::text("hello")]),
4009 configuration: None,
4010 metadata: None,
4011 tenant: None,
4012 },
4013 )
4014 .await,
4015 );
4016 assert_unsupported_operation(
4017 transport
4018 .send_streaming_message(
4019 ¶ms,
4020 &SendMessageRequest {
4021 message: Message::new(Role::User, vec![Part::text("hello")]),
4022 configuration: None,
4023 metadata: None,
4024 tenant: None,
4025 },
4026 )
4027 .await,
4028 );
4029 assert_unsupported_operation(
4030 transport
4031 .get_task(
4032 ¶ms,
4033 &GetTaskRequest {
4034 id: "task-1".to_string(),
4035 history_length: None,
4036 tenant: None,
4037 },
4038 )
4039 .await,
4040 );
4041 assert_unsupported_operation(
4042 transport
4043 .list_tasks(
4044 ¶ms,
4045 &ListTasksRequest {
4046 context_id: None,
4047 status: None,
4048 page_size: None,
4049 page_token: None,
4050 history_length: None,
4051 status_timestamp_after: None,
4052 include_artifacts: None,
4053 tenant: None,
4054 },
4055 )
4056 .await,
4057 );
4058 assert_unsupported_operation(
4059 transport
4060 .cancel_task(
4061 ¶ms,
4062 &CancelTaskRequest {
4063 id: "task-1".to_string(),
4064 metadata: None,
4065 tenant: None,
4066 },
4067 )
4068 .await,
4069 );
4070 assert_unsupported_operation(
4071 transport
4072 .subscribe_to_task(
4073 ¶ms,
4074 &SubscribeToTaskRequest {
4075 id: "task-1".to_string(),
4076 tenant: None,
4077 },
4078 )
4079 .await,
4080 );
4081 assert_unsupported_operation(
4082 transport
4083 .create_push_config(
4084 ¶ms,
4085 &TaskPushNotificationConfig {
4086 task_id: "task-1".to_string(),
4087 url: "https://example.com/callback".to_string(),
4088 id: Some("cfg-1".to_string()),
4089 token: None,
4090 authentication: None,
4091 tenant: None,
4092 },
4093 )
4094 .await,
4095 );
4096 assert_unsupported_operation(
4097 transport
4098 .get_push_config(
4099 ¶ms,
4100 &GetTaskPushNotificationConfigRequest {
4101 task_id: "task-1".to_string(),
4102 id: "cfg-1".to_string(),
4103 tenant: None,
4104 },
4105 )
4106 .await,
4107 );
4108 assert_unsupported_operation(
4109 transport
4110 .list_push_configs(
4111 ¶ms,
4112 &ListTaskPushNotificationConfigsRequest {
4113 task_id: "task-1".to_string(),
4114 page_size: None,
4115 page_token: None,
4116 tenant: None,
4117 },
4118 )
4119 .await,
4120 );
4121 assert_unsupported_operation(
4122 transport
4123 .delete_push_config(
4124 ¶ms,
4125 &DeleteTaskPushNotificationConfigRequest {
4126 task_id: "task-1".to_string(),
4127 id: "cfg-1".to_string(),
4128 tenant: None,
4129 },
4130 )
4131 .await,
4132 );
4133 assert_unsupported_operation(
4134 transport
4135 .get_extended_agent_card(¶ms, &GetExtendedAgentCardRequest { tenant: None })
4136 .await,
4137 );
4138 }
4139
4140 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4141 async fn test_run_executes_all_commands_in_lib_tests() {
4142 let server = RunTestServer::spawn().await;
4143
4144 run_test_cli(&server.base_url, &["card", "get"])
4145 .await
4146 .unwrap();
4147 run_test_cli(
4148 &server.base_url,
4149 &["--compact", "card", "get", "--extended"],
4150 )
4151 .await
4152 .unwrap();
4153 run_test_cli(
4154 &server.base_url,
4155 &[
4156 "--transport",
4157 "jsonrpc",
4158 "--bearer",
4159 "secret",
4160 "--svc-param",
4161 "X-Test: 123",
4162 "send",
4163 "hello from unit test",
4164 "--task-id",
4165 "task-send",
4166 "--context-id",
4167 "ctx-send",
4168 ],
4169 )
4170 .await
4171 .unwrap();
4172 run_test_cli(
4173 &server.base_url,
4174 &[
4175 "--compact",
4176 "send",
4177 "streaming request",
4178 "--stream",
4179 "--task-id",
4180 "task-stream",
4181 "--context-id",
4182 "ctx-stream",
4183 ],
4184 )
4185 .await
4186 .unwrap();
4187 run_test_cli(&server.base_url, &["task", "get", "task-send"])
4188 .await
4189 .unwrap();
4190 run_test_cli(
4191 &server.base_url,
4192 &[
4193 "--compact",
4194 "task",
4195 "list",
4196 "--context-id",
4197 "ctx-send",
4198 "--status",
4199 "completed",
4200 ],
4201 )
4202 .await
4203 .unwrap();
4204 run_test_cli(&server.base_url, &["task", "cancel", "task-send"])
4205 .await
4206 .unwrap();
4207 run_test_cli(
4208 &server.base_url,
4209 &["--compact", "task", "subscribe", "task-stream"],
4210 )
4211 .await
4212 .unwrap();
4213 run_test_cli(
4214 &server.base_url,
4215 &[
4216 "task",
4217 "push-config",
4218 "create",
4219 "task-1",
4220 "https://example.com/callback",
4221 "--config-id",
4222 "cfg-1",
4223 ],
4224 )
4225 .await
4226 .unwrap();
4227 run_test_cli(
4228 &server.base_url,
4229 &["--compact", "task", "push-config", "get", "task-1", "cfg-1"],
4230 )
4231 .await
4232 .unwrap();
4233 run_test_cli(
4234 &server.base_url,
4235 &["--compact", "task", "push-config", "list", "task-1"],
4236 )
4237 .await
4238 .unwrap();
4239 run_test_cli(
4240 &server.base_url,
4241 &["task", "push-config", "delete", "task-1", "cfg-1"],
4242 )
4243 .await
4244 .unwrap();
4245 }
4246
4247 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4248 async fn test_run_surfaces_errors_in_lib_tests() {
4249 let server = RunTestServer::spawn().await;
4250
4251 let err = run_test_cli(
4252 &server.base_url,
4253 &["card", "get", "--extended", "--tenant", "error"],
4254 )
4255 .await
4256 .unwrap_err();
4257 assert!(matches!(
4258 err,
4259 CliError::A2A(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION
4260 ));
4261
4262 let err = run_test_cli(&server.base_url, &["send", "send-error"])
4263 .await
4264 .unwrap_err();
4265 assert!(matches!(
4266 err,
4267 CliError::A2A(error) if error.code == a2a::error_code::INVALID_REQUEST
4268 ));
4269
4270 let err = run_test_cli(&server.base_url, &["task", "list", "--context-id", "error"])
4271 .await
4272 .unwrap_err();
4273 assert!(matches!(
4274 err,
4275 CliError::A2A(error) if error.code == a2a::error_code::INVALID_PARAMS
4276 ));
4277
4278 let err = run_test_cli(
4279 &server.base_url,
4280 &["--compact", "send", "stream-error", "--stream"],
4281 )
4282 .await
4283 .unwrap_err();
4284 assert!(matches!(
4285 err,
4286 CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
4287 ));
4288
4289 let err = run_test_cli(
4290 &server.base_url,
4291 &["--compact", "task", "subscribe", "stream-error"],
4292 )
4293 .await
4294 .unwrap_err();
4295 assert!(matches!(
4296 err,
4297 CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
4298 ));
4299
4300 let err = run_test_cli(
4301 &server.base_url,
4302 &[
4303 "task",
4304 "push-config",
4305 "create",
4306 "missing",
4307 "https://example.com/callback",
4308 "--config-id",
4309 "cfg-missing",
4310 ],
4311 )
4312 .await
4313 .unwrap_err();
4314 assert!(matches!(
4315 err,
4316 CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
4317 ));
4318
4319 let err = run_test_cli(
4320 &server.base_url,
4321 &["task", "push-config", "list", "missing"],
4322 )
4323 .await
4324 .unwrap_err();
4325 assert!(matches!(
4326 err,
4327 CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
4328 ));
4329
4330 let base_url = unused_base_url().await;
4331 let err = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
4332 assert!(matches!(err, CliError::Http(_)));
4333 }
4334
4335 #[test]
4336 fn test_task_state_conversion() {
4337 let cases = [
4338 (TaskStateArg::Unspecified, TaskState::Unspecified),
4339 (TaskStateArg::Submitted, TaskState::Submitted),
4340 (TaskStateArg::Working, TaskState::Working),
4341 (TaskStateArg::Completed, TaskState::Completed),
4342 (TaskStateArg::Failed, TaskState::Failed),
4343 (TaskStateArg::Canceled, TaskState::Canceled),
4344 (TaskStateArg::InputRequired, TaskState::InputRequired),
4345 (TaskStateArg::Rejected, TaskState::Rejected),
4346 (TaskStateArg::AuthRequired, TaskState::AuthRequired),
4347 ];
4348
4349 for (input, expected) in cases {
4350 assert_eq!(TaskState::from(input), expected);
4351 }
4352 }
4353
4354 #[test]
4355 fn test_means_not_a_readable_path_classification() {
4356 use std::io::ErrorKind;
4357
4358 for kind in [
4363 ErrorKind::NotFound,
4364 ErrorKind::InvalidFilename,
4365 ErrorKind::InvalidInput,
4366 ] {
4367 assert!(
4368 means_not_a_readable_path(kind),
4369 "{kind:?} should fall through to inline-JSON parsing"
4370 );
4371 }
4372
4373 for kind in [
4375 ErrorKind::PermissionDenied,
4376 ErrorKind::IsADirectory,
4377 ErrorKind::InvalidData,
4378 ] {
4379 assert!(
4380 !means_not_a_readable_path(kind),
4381 "{kind:?} should be surfaced as a read error"
4382 );
4383 }
4384 }
4385
4386 #[test]
4387 fn test_parse_duration_variants() {
4388 assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
4389 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
4390 assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
4391 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
4392 assert_eq!(parse_duration("1.5s").unwrap(), Duration::from_millis(1500));
4393 }
4394
4395 #[test]
4396 fn test_parse_duration_rejects_invalid_input() {
4397 assert!(parse_duration("banana").is_err());
4398 assert!(parse_duration("-1s").is_err());
4399 }
4400
4401 #[test]
4402 fn test_is_settled_classifies_task_states() {
4403 let settled = [
4404 TaskState::Completed,
4405 TaskState::Failed,
4406 TaskState::Canceled,
4407 TaskState::Rejected,
4408 TaskState::InputRequired,
4409 TaskState::AuthRequired,
4410 ];
4411 for state in settled {
4412 assert!(is_settled(&state), "{state:?} should be settled");
4413 }
4414
4415 let unsettled = [
4416 TaskState::Unspecified,
4417 TaskState::Submitted,
4418 TaskState::Working,
4419 ];
4420 for state in unsettled {
4421 assert!(!is_settled(&state), "{state:?} should not be settled");
4422 }
4423 }
4424
4425 fn parse_with_matches(args: &[&str]) -> (Cli, ArgMatches) {
4426 let mut argv = vec!["a2acli".to_string()];
4427 argv.extend(args.iter().map(|arg| (*arg).to_string()));
4428 let matches = Cli::command().try_get_matches_from(argv).unwrap();
4429 let cli = Cli::from_arg_matches(&matches).unwrap();
4430 (cli, matches)
4431 }
4432
4433 fn send_command(cli: &Cli) -> &MessageCommand {
4434 match &cli.command {
4435 Command::Send(command) => command,
4436 other => panic!("expected Command::Send, got {other:?}"),
4437 }
4438 }
4439
4440 #[test]
4441 fn test_resolve_message_parts_preserves_interleaved_order_and_media_type() {
4442 let (cli, matches) = parse_with_matches(&[
4443 "send",
4444 "--text-part",
4445 "hello",
4446 "--file-part",
4447 "https://example.com/doc.pdf",
4448 "--media-type",
4449 "application/pdf",
4450 "--data-part",
4451 r#"{"k":1}"#,
4452 ]);
4453 let send_matches = matches.subcommand_matches("send").unwrap();
4454 let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
4455
4456 assert_eq!(parts.len(), 3);
4457 assert_eq!(parts[0].as_text(), Some("hello"));
4458 assert!(parts[0].media_type.is_none());
4459 assert!(matches!(
4460 &parts[1].content,
4461 PartContent::Url(url) if url == "https://example.com/doc.pdf"
4462 ));
4463 assert_eq!(parts[1].media_type.as_deref(), Some("application/pdf"));
4464 assert!(matches!(&parts[2].content, PartContent::Data(_)));
4465 assert!(parts[2].media_type.is_none());
4466 }
4467
4468 #[test]
4469 fn test_resolve_message_parts_positional_text_is_shorthand() {
4470 let (cli, matches) = parse_with_matches(&["send", "hello"]);
4471 let send_matches = matches.subcommand_matches("send").unwrap();
4472 let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
4473
4474 assert_eq!(parts.len(), 1);
4475 assert_eq!(parts[0].as_text(), Some("hello"));
4476 }
4477
4478 #[test]
4479 fn test_resolve_message_parts_rejects_media_type_without_preceding_part() {
4480 let (cli, matches) =
4481 parse_with_matches(&["send", "--media-type", "text/plain", "--text-part", "hi"]);
4482 let send_matches = matches.subcommand_matches("send").unwrap();
4483 let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
4484 assert!(matches!(err, CliError::InvalidInput(_)));
4485 }
4486
4487 #[test]
4488 fn test_resolve_message_parts_rejects_positional_text_with_part_flags() {
4489 let (cli, matches) = parse_with_matches(&["send", "hello", "--text-part", "world"]);
4490 let send_matches = matches.subcommand_matches("send").unwrap();
4491 let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
4492 assert!(matches!(err, CliError::InvalidInput(_)));
4493 }
4494
4495 #[test]
4496 fn test_cli_error_exit_codes_and_envelope_codes() {
4497 let a2a = CliError::A2A(A2AError::task_not_found("t-1"));
4498 assert_eq!(a2a.exit_code(), 1);
4499 let envelope = a2a.envelope();
4500 assert_eq!(envelope.error.code, "TASK_NOT_FOUND");
4501 assert_eq!(envelope.error.a2a_code, Some(-32001));
4502
4503 let usage = CliError::InvalidInput("bad flag".to_string());
4504 assert_eq!(usage.exit_code(), 2);
4505 assert_eq!(usage.envelope().error.code, "A2ACLI_ERR_USAGE");
4506 assert!(usage.envelope().error.a2a_code.is_none());
4507
4508 let read_file = CliError::ReadFile {
4509 path: "missing.bin".to_string(),
4510 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
4511 };
4512 assert_eq!(read_file.exit_code(), 2);
4513 assert_eq!(read_file.envelope().error.code, "A2ACLI_ERR_USAGE");
4514
4515 let timeout = CliError::Timeout {
4516 task_id: "t-2".to_string(),
4517 timeout: Duration::from_secs(30),
4518 };
4519 assert_eq!(timeout.exit_code(), 5);
4520 assert_eq!(timeout.envelope().error.code, "A2ACLI_ERR_TIMEOUT");
4521
4522 let json = CliError::Json(serde_json::from_str::<serde_json::Value>("{").unwrap_err());
4523 assert_eq!(json.exit_code(), 1);
4524 assert_eq!(json.envelope().error.code, "A2ACLI_ERR_INTERNAL");
4525 }
4526
4527 #[test]
4528 fn test_cli_error_report_prints_one_json_line_to_stderr() {
4529 let error = CliError::A2A(A2AError::unsupported_operation("nope"));
4533 let json = serde_json::to_string(&error.envelope()).unwrap();
4534 assert!(json.contains("\"code\":\"UNSUPPORTED_OPERATION\""));
4535 assert!(!json.contains('\n'));
4536 }
4537
4538 #[test]
4539 fn test_agent_card_render_text() {
4540 let card = AgentCard {
4541 name: "Fixture".to_string(),
4542 description: "desc".to_string(),
4543 version: VERSION.to_string(),
4544 supported_interfaces: vec![AgentInterface::new(
4545 "http://host/jsonrpc",
4546 TRANSPORT_PROTOCOL_JSONRPC,
4547 )],
4548 capabilities: AgentCapabilities {
4549 streaming: Some(true),
4550 push_notifications: Some(false),
4551 extensions: None,
4552 extended_agent_card: None,
4553 },
4554 default_input_modes: vec![],
4555 default_output_modes: vec![],
4556 skills: vec![],
4557 provider: None,
4558 documentation_url: None,
4559 icon_url: None,
4560 security_schemes: None,
4561 security_requirements: None,
4562 signatures: None,
4563 };
4564
4565 let text = card.render_text();
4566 assert!(text.contains("Name: Fixture"));
4567 assert!(text.contains("Streaming: true"));
4568 assert!(text.contains("Push Notifications: false"));
4569 assert!(text.contains("Extended Card: false"));
4570 assert!(text.contains("Interfaces:"));
4571 assert!(text.contains("http://host/jsonrpc"));
4572 assert!(text.contains("Skills: 0"));
4573 }
4574
4575 #[test]
4576 fn test_message_render_text_renders_every_part_kind() {
4577 let mut file_part = Part::raw(vec![1, 2, 3]);
4578 file_part.filename = Some("report.bin".to_string());
4579 file_part.media_type = Some("application/octet-stream".to_string());
4580
4581 let message = Message::new(
4582 Role::Agent,
4583 vec![
4584 Part::text("hello"),
4585 Part::data(serde_json::json!({"k": 1})),
4586 file_part,
4587 ],
4588 );
4589
4590 let text = message.render_text();
4591 assert!(text.contains("Text:\nhello"));
4592 assert!(text.contains("Data:"));
4593 assert!(text.contains("\"k\": 1"));
4594 assert!(text.contains("File: report.bin (application/octet-stream, 3 bytes)"));
4595 }
4596
4597 #[test]
4601 fn test_render_part_without_media_type_falls_back_to_name_and_url() {
4602 let message = Message::new(
4603 Role::Agent,
4604 vec![
4605 Part::raw(vec![7; 12]),
4606 Part::raw(vec![1, 2]).with_filename("notes.txt"),
4607 Part::url("https://example.com/spec.pdf"),
4608 Part::url("https://example.com/report.csv").with_media_type("text/csv"),
4609 ],
4610 );
4611
4612 let text = message.render_text();
4613 assert!(text.contains("File: (unnamed) (12 bytes)"), "{text}");
4616 assert!(text.contains("File: notes.txt (2 bytes)"), "{text}");
4617 assert!(
4618 text.contains("File: https://example.com/spec.pdf"),
4619 "{text}"
4620 );
4621 assert!(
4622 text.contains("File: https://example.com/report.csv (text/csv)"),
4623 "{text}"
4624 );
4625 }
4626
4627 #[test]
4631 fn test_message_render_text_marks_an_empty_part_list() {
4632 let message = Message {
4633 message_id: "msg-1".to_string(),
4634 context_id: Some("ctx-1".to_string()),
4635 task_id: Some("task-1".to_string()),
4636 role: Role::Agent,
4637 parts: vec![],
4638 metadata: None,
4639 extensions: None,
4640 reference_task_ids: None,
4641 };
4642
4643 let text = message.render_text();
4644 assert!(text.contains("Context ID: ctx-1"), "{text}");
4645 assert!(text.contains("Task ID: task-1"), "{text}");
4646 assert!(text.contains("Parts: (none)"), "{text}");
4647 }
4648
4649 #[test]
4653 fn test_send_message_response_renders_the_message_arm() {
4654 let message = Message::new(Role::Agent, vec![Part::text("no task needed")]);
4655 let response = SendMessageResponse::Message(message.clone());
4656
4657 assert_eq!(response.render_text(), message.render_text());
4658 assert!(response.render_text().contains("no task needed"));
4659 }
4660
4661 #[test]
4666 fn test_agent_card_render_text_marks_an_empty_interface_list() {
4667 let card = AgentCard {
4668 name: "Bare".to_string(),
4669 description: "no interfaces".to_string(),
4670 version: VERSION.to_string(),
4671 supported_interfaces: vec![],
4672 capabilities: AgentCapabilities {
4673 streaming: None,
4674 push_notifications: None,
4675 extensions: None,
4676 extended_agent_card: None,
4677 },
4678 default_input_modes: vec![],
4679 default_output_modes: vec![],
4680 skills: vec![],
4681 provider: None,
4682 documentation_url: None,
4683 icon_url: None,
4684 security_schemes: None,
4685 security_requirements: None,
4686 signatures: None,
4687 };
4688
4689 let text = card.render_text();
4690 assert!(text.contains("Interfaces:\n (none)"), "{text}");
4691 assert!(text.contains("Streaming: false"), "{text}");
4693 }
4694
4695 #[test]
4699 fn test_task_render_text_lists_artifacts_and_resume_hint() {
4700 let mut task = make_fixture_task(
4701 "task-art",
4702 "ctx-art",
4703 TaskState::InputRequired,
4704 "which region?",
4705 );
4706 task.artifacts = Some(vec![
4707 Artifact {
4708 artifact_id: "art-1".to_string(),
4709 name: Some("summary".to_string()),
4710 description: None,
4711 parts: vec![Part::text("all clear")],
4712 metadata: None,
4713 extensions: None,
4714 },
4715 Artifact {
4716 artifact_id: "art-2".to_string(),
4717 name: None,
4718 description: None,
4719 parts: vec![Part::data(serde_json::json!({"rows": 3}))],
4720 metadata: None,
4721 extensions: None,
4722 },
4723 ]);
4724
4725 let text = task.render_text();
4726 assert!(text.contains("Artifact: summary"), "{text}");
4727 assert!(text.contains("all clear"), "{text}");
4728 assert!(text.contains("Artifact: art-2"), "{text}");
4730 assert!(text.contains("\"rows\": 3"), "{text}");
4731 assert!(
4733 text.contains("Resume with: a2acli send --task-id task-art \"<reply>\""),
4734 "{text}"
4735 );
4736 }
4737
4738 #[test]
4742 fn test_task_state_label_covers_every_state() {
4743 let cases = [
4744 (TaskState::Unspecified, "UNSPECIFIED"),
4745 (TaskState::Submitted, "SUBMITTED"),
4746 (TaskState::Working, "WORKING"),
4747 (TaskState::Completed, "COMPLETED"),
4748 (TaskState::Failed, "FAILED"),
4749 (TaskState::Canceled, "CANCELED"),
4750 (TaskState::InputRequired, "INPUT_REQUIRED"),
4751 (TaskState::Rejected, "REJECTED"),
4752 (TaskState::AuthRequired, "AUTH_REQUIRED"),
4753 ];
4754
4755 for (state, expected) in cases {
4756 assert_eq!(task_state_label(&state), expected);
4757 assert!(!expected.starts_with("TASK_STATE_"));
4758 }
4759 }
4760
4761 #[test]
4762 fn test_list_tasks_render_text_empty_and_paged() {
4763 let empty = ListTasksResponse {
4764 tasks: vec![],
4765 next_page_token: "next-token".to_string(),
4766 page_size: 10,
4767 total_size: 0,
4768 };
4769
4770 let text = empty.render_text();
4771 assert!(text.contains("Total: 0"), "{text}");
4772 assert!(text.contains("Page Size: 10"), "{text}");
4773 assert!(text.contains("Next Page Token: next-token"), "{text}");
4774 assert!(text.contains("Tasks: (none)"), "{text}");
4775
4776 let populated = ListTasksResponse {
4777 tasks: vec![make_fixture_task(
4778 "task-1",
4779 "ctx-1",
4780 TaskState::Completed,
4781 "done",
4782 )],
4783 next_page_token: String::new(),
4784 page_size: 10,
4785 total_size: 1,
4786 };
4787
4788 let text = populated.render_text();
4789 assert!(!text.contains("Next Page Token"), "{text}");
4792 assert!(text.contains("Task:"), "{text}");
4793 assert!(text.contains("Task ID: task-1"), "{text}");
4794 }
4795
4796 #[test]
4797 fn test_push_config_render_text_includes_token_and_auth_scheme() {
4798 let config = TaskPushNotificationConfig {
4799 url: "https://example.com/hook".to_string(),
4800 id: Some("cfg-1".to_string()),
4801 task_id: "task-1".to_string(),
4802 token: Some("tok-1".to_string()),
4803 authentication: Some(AuthenticationInfo {
4804 scheme: "Bearer".to_string(),
4805 credentials: Some("super-secret".to_string()),
4806 }),
4807 tenant: None,
4808 };
4809
4810 let text = config.render_text();
4811 assert!(text.contains("Config ID: cfg-1"), "{text}");
4812 assert!(text.contains("Task ID: task-1"), "{text}");
4813 assert!(text.contains("URL: https://example.com/hook"), "{text}");
4814 assert!(text.contains("Token: tok-1"), "{text}");
4815 assert!(text.contains("Auth Scheme: Bearer"), "{text}");
4816 assert!(!text.contains("super-secret"), "{text}");
4818 }
4819
4820 #[test]
4821 fn test_list_push_configs_render_text_empty_and_paged() {
4822 let empty = ListTaskPushNotificationConfigsResponse {
4823 configs: vec![],
4824 next_page_token: Some("next-token".to_string()),
4825 };
4826
4827 let text = empty.render_text();
4828 assert!(text.contains("Next Page Token: next-token"), "{text}");
4829 assert!(text.contains("Configs: (none)"), "{text}");
4830
4831 let populated = ListTaskPushNotificationConfigsResponse {
4832 configs: vec![TaskPushNotificationConfig {
4833 url: "https://example.com/hook".to_string(),
4834 id: Some("cfg-1".to_string()),
4835 task_id: "task-1".to_string(),
4836 token: None,
4837 authentication: None,
4838 tenant: None,
4839 }],
4840 next_page_token: None,
4841 };
4842
4843 let text = populated.render_text();
4844 assert!(!text.contains("Next Page Token"), "{text}");
4845 assert!(text.contains("Push Config:"), "{text}");
4846 assert!(text.contains("Config ID: cfg-1"), "{text}");
4847 }
4848
4849 #[test]
4850 fn test_push_config_deleted_render_text() {
4851 let deleted = PushConfigDeleted {
4852 deleted: true,
4853 task_id: "task-1".to_string(),
4854 id: "cfg-1".to_string(),
4855 };
4856
4857 let text = deleted.render_text();
4858 assert!(text.contains("Deleted: true"), "{text}");
4859 assert!(text.contains("Task ID: task-1"), "{text}");
4860 assert!(text.contains("Config ID: cfg-1"), "{text}");
4861 }
4862
4863 #[test]
4868 fn test_stream_response_render_text_covers_update_events() {
4869 let status = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
4870 task_id: "task-s".to_string(),
4871 context_id: "ctx-s".to_string(),
4872 status: TaskStatus {
4873 state: TaskState::AuthRequired,
4874 message: None,
4875 timestamp: None,
4876 },
4877 metadata: None,
4878 });
4879
4880 let text = status.render_text();
4881 assert!(text.contains("Task ID: task-s"), "{text}");
4882 assert!(text.contains("Context ID: ctx-s"), "{text}");
4883 assert!(text.contains("State: AUTH_REQUIRED"), "{text}");
4884 assert!(
4885 text.contains("Resume with: a2acli send --task-id task-s \"<reply>\""),
4886 "{text}"
4887 );
4888
4889 let working = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
4891 task_id: "task-s".to_string(),
4892 context_id: "ctx-s".to_string(),
4893 status: TaskStatus {
4894 state: TaskState::Working,
4895 message: None,
4896 timestamp: None,
4897 },
4898 metadata: None,
4899 });
4900 assert!(!working.render_text().contains("Resume with"));
4901
4902 let artifact = StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
4903 task_id: "task-s".to_string(),
4904 context_id: "ctx-s".to_string(),
4905 artifact: Artifact {
4906 artifact_id: "art-9".to_string(),
4907 name: None,
4908 description: None,
4909 parts: vec![Part::text("chunk one")],
4910 metadata: None,
4911 extensions: None,
4912 },
4913 append: None,
4914 last_chunk: None,
4915 metadata: None,
4916 });
4917
4918 let text = artifact.render_text();
4919 assert!(text.contains("Task ID: task-s"), "{text}");
4920 assert!(text.contains("Artifact: art-9"), "{text}");
4921 assert!(text.contains("chunk one"), "{text}");
4922
4923 let task = make_fixture_task("task-s", "ctx-s", TaskState::Completed, "done");
4926 assert_eq!(
4927 StreamResponse::Task(task.clone()).render_text(),
4928 task.render_text()
4929 );
4930 let message = Message::new(Role::Agent, vec![Part::text("interim")]);
4931 assert_eq!(
4932 StreamResponse::Message(message.clone()).render_text(),
4933 message.render_text()
4934 );
4935 }
4936
4937 fn text_cli() -> Cli {
4938 parse_with_matches(&["task", "get", "unused"]).0
4939 }
4940
4941 #[tokio::test]
4945 async fn test_consume_stream_text_mode_does_not_serialize() {
4946 let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
4947 consume_stream(make_test_client(None), stream, &text_cli())
4948 .await
4949 .unwrap();
4950 }
4951
4952 #[test]
4956 fn test_parse_data_part_json_rejects_malformed_content() {
4957 let error = parse_data_part_json("{not json", "payload.json").unwrap_err();
4958
4959 assert_eq!(error.exit_code(), 2);
4960 let message = error.to_string();
4961 assert!(message.contains("payload.json"), "{message}");
4962 assert!(matches!(error, CliError::InvalidInput(_)));
4963 }
4964
4965 struct CardOnlyServer {
4971 base_url: String,
4972 handle: tokio::task::JoinHandle<()>,
4973 }
4974
4975 impl Drop for CardOnlyServer {
4976 fn drop(&mut self) {
4977 self.handle.abort();
4978 }
4979 }
4980
4981 impl CardOnlyServer {
4982 async fn spawn(status: axum::http::StatusCode, body: &'static str) -> Self {
4983 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4984 let base_url = format!("http://{}", listener.local_addr().unwrap());
4985 let app = Router::new().route(
4986 WELL_KNOWN_AGENT_CARD_PATH,
4987 get(move || async move {
4988 (
4989 status,
4990 [(header::CONTENT_TYPE, "application/json")],
4991 body.to_string(),
4992 )
4993 }),
4994 );
4995 let handle = tokio::spawn(async move {
4996 axum::serve(listener, app).await.unwrap();
4997 });
4998
4999 CardOnlyServer { base_url, handle }
5000 }
5001 }
5002
5003 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5008 async fn test_card_fetch_failures_are_classified_per_appendix_d() {
5009 let not_found = CardOnlyServer::spawn(axum::http::StatusCode::NOT_FOUND, "{}").await;
5010 let error = run_test_cli(¬_found.base_url, &["card", "get"])
5011 .await
5012 .unwrap_err();
5013 assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
5014 assert_eq!(error.exit_code(), 3);
5015 assert!(error.envelope().error.hint.is_some());
5016
5017 for status in [
5018 axum::http::StatusCode::UNAUTHORIZED,
5019 axum::http::StatusCode::FORBIDDEN,
5020 ] {
5021 let denied = CardOnlyServer::spawn(status, "{}").await;
5022 let error = run_test_cli(&denied.base_url, &["card", "get"])
5023 .await
5024 .unwrap_err();
5025 assert_eq!(
5026 error.envelope().error.code,
5027 "A2ACLI_ERR_AUTH_FAILED",
5028 "status {status}"
5029 );
5030 assert_eq!(error.exit_code(), 4, "status {status}");
5031 }
5032
5033 let invalid =
5036 CardOnlyServer::spawn(axum::http::StatusCode::OK, r#"{"not":"a card"}"#).await;
5037 let error = run_test_cli(&invalid.base_url, &["card", "get"])
5038 .await
5039 .unwrap_err();
5040 assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
5041 assert_eq!(error.exit_code(), 1);
5042
5043 let base_url = unused_base_url().await;
5045 let error = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
5046 assert_eq!(error.envelope().error.code, "A2ACLI_ERR_UNREACHABLE");
5047 assert_eq!(error.exit_code(), 3);
5048 }
5049
5050 #[test]
5051 fn test_parse_dotenv_basic() {
5052 let parsed = parse_dotenv("A2ACLI_BEARER=secret\nA2ACLI_TRANSPORT=jsonrpc,rest\n");
5053 assert_eq!(
5054 parsed,
5055 vec![
5056 ("A2ACLI_BEARER".to_string(), "secret".to_string()),
5057 ("A2ACLI_TRANSPORT".to_string(), "jsonrpc,rest".to_string()),
5058 ]
5059 );
5060 }
5061
5062 #[test]
5063 fn test_parse_dotenv_ignores_blank_lines_and_comments() {
5064 let parsed = parse_dotenv("\n# a comment\n \nA2ACLI_TENANT=acme\n# another comment\n");
5065 assert_eq!(
5066 parsed,
5067 vec![("A2ACLI_TENANT".to_string(), "acme".to_string())]
5068 );
5069 }
5070
5071 #[test]
5072 fn test_parse_dotenv_tolerates_export_prefix() {
5073 let parsed = parse_dotenv("export A2ACLI_BASE_URL=https://agent.example.com\n");
5074 assert_eq!(
5075 parsed,
5076 vec![(
5077 "A2ACLI_BASE_URL".to_string(),
5078 "https://agent.example.com".to_string()
5079 )]
5080 );
5081 }
5082
5083 #[test]
5084 fn test_parse_dotenv_strips_one_layer_of_quotes() {
5085 let parsed = parse_dotenv("A2ACLI_BEARER=\"Bearer token\"\nA2ACLI_TENANT='acme'\n");
5086 assert_eq!(
5087 parsed,
5088 vec![
5089 ("A2ACLI_BEARER".to_string(), "Bearer token".to_string()),
5090 ("A2ACLI_TENANT".to_string(), "acme".to_string()),
5091 ]
5092 );
5093 }
5094
5095 #[test]
5096 fn test_find_flag_value_supports_space_and_equals_forms() {
5097 let space = vec![
5098 OsString::from("a2acli"),
5099 OsString::from("--config"),
5100 OsString::from("prod.env"),
5101 ];
5102 assert_eq!(
5103 find_flag_value(&space, "--config"),
5104 Some("prod.env".to_string())
5105 );
5106
5107 let equals = vec![
5108 OsString::from("a2acli"),
5109 OsString::from("--config=prod.env"),
5110 ];
5111 assert_eq!(
5112 find_flag_value(&equals, "--config"),
5113 Some("prod.env".to_string())
5114 );
5115
5116 let absent = vec![OsString::from("a2acli"), OsString::from("send")];
5117 assert_eq!(find_flag_value(&absent, "--config"), None);
5118 }
5119
5120 #[test]
5121 fn test_config_source_labels() {
5122 assert_eq!(ConfigSource::Flag.label(), "flag");
5123 assert_eq!(ConfigSource::EnvVar.label(), "environment variable");
5124 assert_eq!(ConfigSource::LocalFile.label(), "local .env file");
5125 assert_eq!(ConfigSource::GlobalFile.label(), "global .env file");
5126 assert_eq!(ConfigSource::Default.label(), "built-in default");
5127 }
5128
5129 #[test]
5130 fn test_redact_secret() {
5131 assert_eq!(
5132 redact_secret(&Some("secret".to_string())),
5133 "(set, redacted)"
5134 );
5135 assert_eq!(redact_secret(&None), "(not set)");
5136 }
5137
5138 #[test]
5145 fn test_normalize_card_reference_host_forms() {
5146 let cases = [
5147 (
5149 "example.com",
5150 "https://example.com/.well-known/agent-card.json",
5151 ),
5152 (
5153 "http://example.com",
5154 "http://example.com/.well-known/agent-card.json",
5155 ),
5156 (
5157 "https://example.com/",
5158 "https://example.com/.well-known/agent-card.json",
5159 ),
5160 (
5162 "localhost:3000",
5163 "http://localhost:3000/.well-known/agent-card.json",
5164 ),
5165 (
5166 "127.0.0.1:8080",
5167 "http://127.0.0.1:8080/.well-known/agent-card.json",
5168 ),
5169 (
5170 "[::1]:9000",
5171 "http://[::1]:9000/.well-known/agent-card.json",
5172 ),
5173 (
5175 "https://example.com/custom/card.json",
5176 "https://example.com/custom/card.json",
5177 ),
5178 (
5179 "http://example.com/agents/a/card",
5180 "http://example.com/agents/a/card",
5181 ),
5182 ];
5183
5184 for (reference, expected) in cases {
5185 assert_eq!(
5186 normalize_card_reference(reference),
5187 CardReference::Url(expected.to_string()),
5188 "reference {reference}"
5189 );
5190 }
5191 }
5192
5193 #[test]
5194 fn test_normalize_card_reference_file_forms() {
5195 assert_eq!(
5197 normalize_card_reference("file:///tmp/card.json"),
5198 CardReference::File(PathBuf::from("/tmp/card.json"))
5199 );
5200 for reference in ["/no/such/card.json", "./card.json", "../card.json"] {
5203 assert_eq!(
5204 normalize_card_reference(reference),
5205 CardReference::File(PathBuf::from(reference)),
5206 "reference {reference}"
5207 );
5208 }
5209 }
5210
5211 #[test]
5217 fn test_windows_path_shapes_are_recognized_as_files() {
5218 for reference in [
5219 r"C:\Users\me\card.json",
5220 r"c:/Users/me/card.json",
5221 r"\\server\share\card.json",
5222 r".\card.json",
5223 r"..\card.json",
5224 ] {
5225 assert!(looks_like_file_path(reference), "{reference}");
5226 assert_eq!(
5227 normalize_card_reference(reference),
5228 CardReference::File(PathBuf::from(reference)),
5229 "{reference}"
5230 );
5231 }
5232
5233 for reference in ["localhost:3000", "a:3000", "example.com:443"] {
5236 assert!(!has_windows_drive_prefix(reference), "{reference}");
5237 assert!(!looks_like_file_path(reference), "{reference}");
5238 }
5239
5240 assert_eq!(
5243 normalize_card_reference("file:///C:/Users/me/card.json"),
5244 CardReference::File(PathBuf::from("C:/Users/me/card.json"))
5245 );
5246 assert_eq!(
5248 normalize_card_reference("file:///tmp/card.json"),
5249 CardReference::File(PathBuf::from("/tmp/card.json"))
5250 );
5251 }
5252
5253 #[test]
5257 fn test_normalize_card_reference_prefers_an_existing_file_over_a_host() {
5258 let dir = std::env::temp_dir().join(format!("a2acli-norm-{}", std::process::id()));
5259 std::fs::create_dir_all(&dir).unwrap();
5260 let path = dir.join("example.com");
5261 std::fs::write(&path, "{}").unwrap();
5262
5263 let previous = std::env::current_dir().unwrap();
5264 std::env::set_current_dir(&dir).unwrap();
5265 let resolved = normalize_card_reference("example.com");
5266 std::env::set_current_dir(previous).unwrap();
5267
5268 assert_eq!(resolved, CardReference::File(PathBuf::from("example.com")));
5269 let _ = std::fs::remove_dir_all(&dir);
5270 }
5271
5272 #[test]
5273 fn test_is_loopback_host() {
5274 for host in [
5275 "localhost",
5276 "localhost:3000",
5277 "127.0.0.1",
5278 "127.0.0.1:80",
5279 "::1",
5280 "[::1]:9000",
5281 ] {
5282 assert!(is_loopback_host(host), "expected loopback: {host}");
5283 }
5284 for host in [
5285 "example.com",
5286 "example.com:443",
5287 "8.8.8.8",
5288 "2606:4700::1111",
5289 ] {
5290 assert!(!is_loopback_host(host), "expected non-loopback: {host}");
5291 }
5292 }
5293
5294 #[test]
5295 fn test_card_file_errors_map_to_appendix_d_codes() {
5296 let missing = CliError::CardFile {
5297 path: "/no/such/card.json".to_string(),
5298 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
5299 };
5300 assert_eq!(missing.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
5301 assert_eq!(missing.exit_code(), 3);
5302
5303 let invalid = CliError::CardInvalid("card.json: missing field `name`".to_string());
5304 assert_eq!(invalid.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
5305 assert_eq!(invalid.exit_code(), 1);
5306 }
5307
5308 #[test]
5312 fn test_resolve_agent_selection_matrix() {
5313 let (cli, matches) = parse_with_matches(&["--agent-card", "example.com", "card", "get"]);
5314 assert_eq!(
5315 resolve_agent_selection(&cli, &matches).unwrap(),
5316 AgentSelection::Card("example.com".to_string())
5317 );
5318
5319 let (cli, matches) = parse_with_matches(&["card", "get"]);
5322 assert_eq!(
5323 resolve_agent_selection(&cli, &matches).unwrap(),
5324 AgentSelection::Card("http://localhost:3000".to_string())
5325 );
5326
5327 let (cli, matches) = parse_with_matches(&["--base-url", "http://host:1", "card", "get"]);
5329 assert_eq!(
5330 resolve_agent_selection(&cli, &matches).unwrap(),
5331 AgentSelection::Card("http://host:1".to_string())
5332 );
5333
5334 let (cli, matches) = parse_with_matches(&[
5335 "--endpoint",
5336 "http://host/jsonrpc",
5337 "--transport",
5338 "jsonrpc",
5339 "card",
5340 "get",
5341 ]);
5342 assert_eq!(
5343 resolve_agent_selection(&cli, &matches).unwrap(),
5344 AgentSelection::Endpoint {
5345 url: "http://host/jsonrpc".to_string(),
5346 binding: Binding::Jsonrpc,
5347 }
5348 );
5349
5350 for transports in [vec![], vec!["jsonrpc", "rest"]] {
5352 let mut args = vec!["--endpoint", "http://host/jsonrpc"];
5353 for transport in &transports {
5354 args.push("--transport");
5355 args.push(transport);
5356 }
5357 args.extend(["card", "get"]);
5358 let (cli, matches) = parse_with_matches(&args);
5359 let error = resolve_agent_selection(&cli, &matches).unwrap_err();
5360 assert_eq!(
5361 error.exit_code(),
5362 2,
5363 "with {} transport(s)",
5364 transports.len()
5365 );
5366 assert!(error.to_string().contains("exactly one --transport"));
5367 }
5368
5369 let (cli, matches) = parse_with_matches(&[
5370 "--agent-card",
5371 "example.com",
5372 "--endpoint",
5373 "http://host/jsonrpc",
5374 "--transport",
5375 "jsonrpc",
5376 "card",
5377 "get",
5378 ]);
5379 let error = resolve_agent_selection(&cli, &matches).unwrap_err();
5380 assert_eq!(error.exit_code(), 2);
5381 assert!(error.to_string().contains("mutually exclusive"));
5382 }
5383
5384 #[test]
5388 fn test_synthesized_endpoint_card_carries_only_the_named_interface() {
5389 let card = synthesized_endpoint_card("http://host/rest", Binding::Rest);
5390
5391 assert_eq!(card.supported_interfaces.len(), 1);
5392 assert_eq!(card.supported_interfaces[0].url, "http://host/rest");
5393 assert_eq!(
5394 card.supported_interfaces[0].protocol_binding,
5395 Binding::Rest.protocol()
5396 );
5397 assert!(card.description.contains("no agent card was resolved"));
5398 assert_eq!(card.capabilities.streaming, None);
5400 assert!(card.skills.is_empty());
5401 }
5402
5403 #[test]
5404 fn test_parse_protocol_version() {
5405 assert_eq!(parse_protocol_version("1"), Some((1, 0)));
5406 assert_eq!(parse_protocol_version("1.0"), Some((1, 0)));
5407 assert_eq!(parse_protocol_version("1.2"), Some((1, 2)));
5408 assert_eq!(parse_protocol_version("1.0.2"), Some((1, 0)));
5411 assert_eq!(parse_protocol_version(" 1.1 "), Some((1, 1)));
5412 assert_eq!(parse_protocol_version("nonsense"), None);
5413 assert_eq!(parse_protocol_version(""), None);
5414 assert_eq!(parse_protocol_version("1.x"), None);
5415 }
5416
5417 #[test]
5420 fn test_validate_a2a_version() {
5421 for accepted in [None, Some("1"), Some("1.0"), Some("1.7")] {
5422 assert!(
5423 validate_a2a_version(accepted).is_ok(),
5424 "should accept {accepted:?}"
5425 );
5426 }
5427
5428 for rejected in ["0.3", "0.9", "2.0", "nonsense", ""] {
5429 let error = validate_a2a_version(Some(rejected)).unwrap_err();
5430 assert_eq!(error.exit_code(), 2, "rejecting {rejected}");
5431 assert_eq!(error.envelope().error.code, "A2ACLI_ERR_USAGE");
5432 }
5433
5434 let error = validate_a2a_version(Some("0.3")).unwrap_err();
5436 assert!(error.to_string().contains("must be 1.x"), "{error}");
5437 }
5438
5439 #[test]
5440 fn test_negotiate_a2a_version_uses_an_explicit_flag_verbatim() {
5441 let negotiated = negotiate_a2a_version(Some("1.0"), "1.0", (1, 0));
5443 assert_eq!(negotiated.version, "1.0");
5444 assert!(negotiated.note.is_none());
5445
5446 let negotiated = negotiate_a2a_version(Some("1.3"), "1.0", (1, 0));
5449 assert_eq!(negotiated.version, "1.3");
5450 assert!(negotiated.note.unwrap().contains("1.3"));
5451 }
5452
5453 #[test]
5457 fn test_negotiate_a2a_version_takes_the_highest_shared_1x() {
5458 let negotiated = negotiate_a2a_version(None, "1.1", (1, 3));
5460 assert_eq!(negotiated.version, "1.1");
5461 assert!(negotiated.note.unwrap().contains("negotiated"));
5462
5463 let negotiated = negotiate_a2a_version(None, "1.5", (1, 3));
5465 assert_eq!(negotiated.version, "1.3");
5466 assert!(negotiated.note.is_none());
5467
5468 let negotiated = negotiate_a2a_version(None, "1.3", (1, 3));
5470 assert_eq!(negotiated.version, "1.3");
5471 assert!(negotiated.note.is_none());
5472 }
5473
5474 #[test]
5477 fn test_negotiate_a2a_version_never_goes_below_the_1x_floor() {
5478 for declared in ["0.3", "0.9", "2.0"] {
5479 let negotiated = negotiate_a2a_version(None, declared, (1, 0));
5480 assert_eq!(negotiated.version, "1.0", "declared {declared}");
5481 let note = negotiated.note.unwrap_or_default();
5482 assert!(note.contains("outside 1.x"), "declared {declared}: {note}");
5483 }
5484
5485 let negotiated = negotiate_a2a_version(None, "not-a-version", (1, 0));
5487 assert_eq!(negotiated.version, "1.0");
5488 assert!(negotiated.note.unwrap().contains("unparseable"));
5489 }
5490
5491 #[test]
5495 fn test_task_outcome_note_names_only_non_success_and_paused_states() {
5496 for (state, expected) in [
5497 (TaskState::Failed, "FAILED"),
5498 (TaskState::Rejected, "REJECTED"),
5499 (TaskState::InputRequired, "INPUT_REQUIRED"),
5500 (TaskState::AuthRequired, "AUTH_REQUIRED"),
5501 ] {
5502 let note =
5503 task_outcome_note(&state).unwrap_or_else(|| panic!("{state:?} should be named"));
5504 assert!(note.contains(expected), "{state:?} -> {note}");
5505 }
5506
5507 for state in [
5508 TaskState::Completed,
5509 TaskState::Canceled,
5510 TaskState::Submitted,
5511 TaskState::Working,
5512 TaskState::Unspecified,
5513 ] {
5514 assert!(
5515 task_outcome_note(&state).is_none(),
5516 "{state:?} should stay silent"
5517 );
5518 }
5519 }
5520
5521 #[test]
5524 fn test_task_outcome_is_read_from_the_reported_value() {
5525 let message = Message::new(Role::Agent, vec![Part::text("hi")]);
5527 assert!(
5528 task_outcome_note(&TaskState::Completed).is_none(),
5529 "sanity: COMPLETED is silent"
5530 );
5531 SendMessageResponse::Message(message.clone()).warn_outcome();
5532 StreamResponse::Message(message).warn_outcome();
5533
5534 let task = make_fixture_task("t-1", "c-1", TaskState::Failed, "broke");
5536 assert_eq!(
5537 task_outcome_note(&task.status.state),
5538 Some("the agent reported it FAILED")
5539 );
5540 SendMessageResponse::Task(task.clone()).warn_outcome();
5541 StreamResponse::Task(task).warn_outcome();
5542
5543 StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
5545 task_id: "t-2".to_string(),
5546 context_id: "c-2".to_string(),
5547 status: TaskStatus {
5548 state: TaskState::AuthRequired,
5549 message: None,
5550 timestamp: None,
5551 },
5552 metadata: None,
5553 })
5554 .warn_outcome();
5555 }
5556
5557 #[test]
5562 fn test_flatten_clap_message() {
5563 let rendered = "error: unexpected argument '--nope' found\n\nUsage: a2acli [OPTIONS]\n";
5564 assert_eq!(
5565 flatten_clap_message(rendered),
5566 "unexpected argument '--nope' found"
5567 );
5568
5569 let rendered = "error: the following required arguments were not provided:\n <ID>\n\nUsage: a2acli task get <ID>\n";
5572 assert_eq!(
5573 flatten_clap_message(rendered),
5574 "the following required arguments were not provided: <ID>"
5575 );
5576
5577 assert_eq!(flatten_clap_message("error: bare"), "bare");
5580 assert_eq!(flatten_clap_message("\n\nUsage: a2acli"), "Usage: a2acli");
5581 }
5582
5583 #[test]
5584 fn test_usage_error_maps_to_the_appendix_d_usage_code() {
5585 let error = CliError::Usage("unexpected argument '--nope' found".to_string());
5586 assert_eq!(error.exit_code(), 2);
5587 let envelope = error.envelope();
5588 assert_eq!(envelope.error.code, "A2ACLI_ERR_USAGE");
5589 assert_eq!(envelope.error.message, "unexpected argument '--nope' found");
5590 assert!(envelope.error.hint.unwrap().contains("--help"));
5591 }
5592}