pub mod card_schema;
use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use a2a::*;
use a2a_client::auth::AuthInterceptor;
use a2a_client::{A2AClient, A2AClientFactory, BoxStream};
use clap::parser::ValueSource;
use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use futures::StreamExt;
use reqwest::{Client, RequestBuilder};
use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Clone, Parser, PartialEq, Eq)]
#[command(
name = "a2acli",
version,
about = "Standalone A2A client CLI",
after_help = "\
Default behavior (SPEC.md §6.5):
- Transport: the agent card's first supported interface (--transport overrides)
- Task completion: wait until terminal/interrupted state (--async returns immediately)
- Output: human-readable text (-o/--output json switches to protocol JSON)
- Protocol version: highest shared 1.x, from the agent card (--a2a-version pins it)
- Transport security: TLS verification on (--insecure disables it, with a warning)"
)]
pub struct Cli {
#[arg(long, global = true)]
pub config: Option<String>,
#[arg(
short = 'a',
long = "agent-card",
global = true,
value_name = "REF",
env = "A2ACLI_AGENT_CARD"
)]
pub agent_card: Option<String>,
#[arg(
short = 'e',
long,
global = true,
value_name = "REF",
env = "A2ACLI_ENDPOINT"
)]
pub endpoint: Option<String>,
#[arg(
long,
global = true,
hide = true,
default_value = "http://localhost:3000",
env = "A2ACLI_BASE_URL"
)]
pub base_url: String,
#[arg(
long,
global = true,
value_enum,
env = "A2ACLI_TRANSPORT",
value_delimiter = ','
)]
pub transport: Vec<Binding>,
#[arg(long = "a2a-version", global = true, env = "A2ACLI_A2A_VERSION")]
pub a2a_version: Option<String>,
#[arg(long, global = true, env = "A2ACLI_BEARER")]
pub bearer: Option<String>,
#[arg(long = "api-key", global = true, env = "A2ACLI_API_KEY")]
pub api_key: Option<String>,
#[arg(long = "svc-param", global = true, value_parser = parse_header)]
pub svc_params: Vec<HeaderArg>,
#[arg(long, global = true, env = "A2ACLI_INSECURE")]
pub insecure: bool,
#[arg(long, global = true, env = "A2ACLI_DEBUG")]
pub debug: bool,
#[arg(long, global = true, env = "A2ACLI_TENANT")]
pub tenant: Option<String>,
#[arg(
short = 'o',
long,
global = true,
value_enum,
default_value_t = OutputFormat::Text,
env = "A2ACLI_OUTPUT"
)]
pub output: OutputFormat,
#[arg(long, global = true, env = "A2ACLI_COMPACT")]
pub compact: bool,
#[arg(
long = "async",
alias = "no-wait",
global = true,
conflicts_with = "wait",
env = "A2ACLI_ASYNC"
)]
pub async_mode: bool,
#[arg(
long,
global = true,
conflicts_with = "async_mode",
env = "A2ACLI_WAIT"
)]
pub wait: bool,
#[arg(long, global = true, value_parser = parse_duration, default_value = "2s", env = "A2ACLI_POLL_INTERVAL")]
pub poll_interval: Duration,
#[arg(long, global = true, value_parser = parse_duration, default_value = "30s", env = "A2ACLI_TIMEOUT")]
pub timeout: Duration,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
pub enum Command {
Card {
#[command(subcommand)]
command: CardCommand,
},
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Send(MessageCommand),
Task {
#[command(subcommand)]
command: TaskCommand,
},
Completion {
#[arg(value_name = "SHELL")]
shell: clap_complete::Shell,
},
}
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
pub enum CardCommand {
Get(CardGetCommand),
}
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
pub enum ConfigCommand {
Show,
}
#[derive(Debug, Clone, Args, PartialEq, Eq)]
pub struct CardGetCommand {
#[arg(long)]
pub extended: bool,
#[arg(long)]
pub validate: bool,
}
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
pub enum TaskCommand {
Get(TaskLookupCommand),
List(ListTasksCommand),
Cancel(TaskIdCommand),
Subscribe(TaskIdCommand),
PushConfig {
#[command(subcommand)]
command: PushConfigCommand,
},
}
#[derive(Debug, Clone, Args, PartialEq, Eq)]
pub struct MessageCommand {
pub text: Option<String>,
#[arg(long = "text-part")]
pub text_parts: Vec<String>,
#[arg(long = "file-part")]
pub file_parts: Vec<String>,
#[arg(long = "data-part")]
pub data_parts: Vec<String>,
#[arg(long = "media-type")]
pub media_types: Vec<String>,
#[arg(long, env = "A2ACLI_CONTEXT_ID")]
pub context_id: Option<String>,
#[arg(long, env = "A2ACLI_TASK_ID")]
pub task_id: Option<String>,
#[arg(long)]
pub history_length: Option<i32>,
#[arg(long = "accept-output")]
pub accepted_output_modes: Vec<String>,
#[arg(long)]
pub return_immediately: bool,
#[arg(long)]
pub stream: bool,
}
#[derive(Debug, Clone, Args, PartialEq, Eq)]
pub struct TaskLookupCommand {
pub id: String,
#[arg(long)]
pub history_length: Option<i32>,
}
#[derive(Debug, Clone, Args, PartialEq, Eq)]
pub struct ListTasksCommand {
#[arg(long)]
pub context_id: Option<String>,
#[arg(long, value_enum)]
pub status: Option<TaskStateArg>,
#[arg(long)]
pub page_size: Option<i32>,
#[arg(long)]
pub page_token: Option<String>,
#[arg(long)]
pub history_length: Option<i32>,
#[arg(long)]
pub include_artifacts: bool,
}
#[derive(Debug, Clone, Args, PartialEq, Eq)]
pub struct TaskIdCommand {
pub id: String,
}
#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
pub enum PushConfigCommand {
Create(CreatePushConfigCommand),
Get(PushConfigIdCommand),
List(ListPushConfigsCommand),
Delete(PushConfigIdCommand),
}
#[derive(Debug, Clone, Args, PartialEq, Eq)]
pub struct CreatePushConfigCommand {
pub task_id: String,
pub url: String,
#[arg(long = "config-id")]
pub config_id: Option<String>,
#[arg(long)]
pub token: Option<String>,
#[arg(long = "auth-scheme")]
pub auth_scheme: Option<String>,
#[arg(long = "auth-credentials")]
pub auth_credentials: Option<String>,
}
#[derive(Debug, Clone, Args, PartialEq, Eq)]
pub struct PushConfigIdCommand {
pub task_id: String,
pub id: String,
}
#[derive(Debug, Clone, Args, PartialEq, Eq)]
pub struct ListPushConfigsCommand {
pub task_id: String,
#[arg(long)]
pub page_size: Option<i32>,
#[arg(long)]
pub page_token: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HeaderArg {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Binding {
Jsonrpc,
#[value(name = "rest")]
Rest,
}
impl Binding {
fn protocol(self) -> &'static str {
match self {
Binding::Jsonrpc => TRANSPORT_PROTOCOL_JSONRPC,
Binding::Rest => TRANSPORT_PROTOCOL_HTTP_JSON,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TaskStateArg {
Unspecified,
Submitted,
Working,
Completed,
Failed,
Canceled,
InputRequired,
Rejected,
AuthRequired,
}
impl From<TaskStateArg> for TaskState {
fn from(value: TaskStateArg) -> Self {
match value {
TaskStateArg::Unspecified => TaskState::Unspecified,
TaskStateArg::Submitted => TaskState::Submitted,
TaskStateArg::Working => TaskState::Working,
TaskStateArg::Completed => TaskState::Completed,
TaskStateArg::Failed => TaskState::Failed,
TaskStateArg::Canceled => TaskState::Canceled,
TaskStateArg::InputRequired => TaskState::InputRequired,
TaskStateArg::Rejected => TaskState::Rejected,
TaskStateArg::AuthRequired => TaskState::AuthRequired,
}
}
}
#[derive(Debug, Error)]
pub enum CliError {
#[error(transparent)]
A2A(#[from] A2AError),
#[error("http request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("failed to serialize output: {0}")]
Json(#[from] serde_json::Error),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("failed to read {path}: {source}")]
ReadFile {
path: String,
#[source]
source: std::io::Error,
},
#[error("timed out after {timeout:?} waiting for task {task_id} to settle")]
Timeout { task_id: String, timeout: Duration },
#[error("failed to read agent card file {path}: {source}")]
CardFile {
path: String,
#[source]
source: std::io::Error,
},
#[error("agent card file is not a valid agent card: {0}")]
CardInvalid(String),
#[error("agent card failed schema validation ({} violation(s))", violations.len())]
CardSchemaInvalid {
violations: Vec<card_schema::Violation>,
},
#[error("{0}")]
Usage(String),
}
const WELL_KNOWN_AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
#[derive(Debug, Clone, Serialize)]
struct ErrorEnvelope {
error: ErrorDetail,
}
#[derive(Debug, Clone, Serialize)]
struct ErrorDetail {
code: String,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<String>,
#[serde(rename = "a2aCode", skip_serializing_if = "Option::is_none")]
a2a_code: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Vec<card_schema::Violation>>,
}
impl CliError {
pub fn exit_code(&self) -> i32 {
match self {
CliError::A2A(_) => 1,
CliError::Http(error) => http_error_exit_code(error),
CliError::Json(_) => 1,
CliError::InvalidInput(_) => 2,
CliError::ReadFile { .. } => 2,
CliError::Timeout { .. } => 5,
CliError::CardFile { .. } => 3,
CliError::CardInvalid(_) => 1,
CliError::CardSchemaInvalid { .. } => 1,
CliError::Usage(_) => 2,
}
}
fn envelope(&self) -> ErrorEnvelope {
let (code, hint, a2a_code) = match self {
CliError::A2A(error) => (
error_reason(error.code).to_string(),
None,
Some(error.code as i64),
),
CliError::Http(error) => http_error_code_and_hint(error),
CliError::Json(_) => ("A2ACLI_ERR_INTERNAL".to_string(), None, None),
CliError::InvalidInput(_) => ("A2ACLI_ERR_USAGE".to_string(), None, None),
CliError::ReadFile { .. } => (
"A2ACLI_ERR_USAGE".to_string(),
Some(
"check that the --file-part/--data-part path exists and is readable"
.to_string(),
),
None,
),
CliError::CardFile { .. } => (
"A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
Some("check that the --agent-card path exists and is readable".to_string()),
None,
),
CliError::Usage(_) => (
"A2ACLI_ERR_USAGE".to_string(),
Some("run `a2acli --help`, or `a2acli <command> --help`".to_string()),
None,
),
CliError::CardInvalid(_) => (
"A2ACLI_ERR_CARD_INVALID".to_string(),
Some("the agent card did not match the expected schema".to_string()),
None,
),
CliError::CardSchemaInvalid { .. } => (
"A2ACLI_ERR_CARD_INVALID".to_string(),
Some(format!(
"against the A2A JSON schema, version {}; see `details`",
card_schema::SCHEMA_A2A_VERSION
)),
None,
),
CliError::Timeout { .. } => (
"A2ACLI_ERR_TIMEOUT".to_string(),
Some(
"increase --timeout/--poll-interval, or check the task later with `task get <id> --wait`"
.to_string(),
),
None,
),
};
let details = match self {
CliError::CardSchemaInvalid { violations } => Some(violations.clone()),
_ => None,
};
ErrorEnvelope {
error: ErrorDetail {
code,
message: self.to_string(),
hint,
a2a_code,
details,
},
}
}
pub fn report(&self) {
match serde_json::to_string(&self.envelope()) {
Ok(json) => eprintln!("{json}"),
Err(_) => eprintln!(
"{{\"error\":{{\"code\":\"A2ACLI_ERR_INTERNAL\",\"message\":{:?}}}}}",
self.to_string()
),
}
}
}
fn http_error_code_and_hint(error: &reqwest::Error) -> (String, Option<String>, Option<i64>) {
if error.is_decode() {
return (
"A2ACLI_ERR_CARD_INVALID".to_string(),
Some("the agent card did not match the expected schema".to_string()),
None,
);
}
if let Some(status) = error.status() {
if status.as_u16() == 401 || status.as_u16() == 403 {
return (
"A2ACLI_ERR_AUTH_FAILED".to_string(),
Some("credentials were rejected fetching the agent card".to_string()),
None,
);
}
return (
"A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
Some("check --agent-card/--base-url resolves to a valid agent".to_string()),
None,
);
}
(
"A2ACLI_ERR_UNREACHABLE".to_string(),
Some("check --agent-card/--endpoint and network connectivity".to_string()),
None,
)
}
fn http_error_exit_code(error: &reqwest::Error) -> i32 {
if error.is_decode() {
return 1;
}
if let Some(status) = error.status() {
return if status.as_u16() == 401 || status.as_u16() == 403 {
4
} else {
3
};
}
3
}
pub async fn run_args<I, T>(args: I) -> Result<(), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
let dotenv_provenance = load_dotenv_config(&args);
let matches = match Cli::command().try_get_matches_from(args) {
Ok(matches) => matches,
Err(error) => return Err(clap_error_to_cli_error(error)),
};
let cli = Cli::from_arg_matches(&matches).expect("matches were produced by Cli::command()");
run(cli, &matches, &dotenv_provenance).await
}
fn clap_error_to_cli_error(error: clap::Error) -> CliError {
use clap::error::ErrorKind;
if matches!(
error.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) {
error.exit();
}
CliError::Usage(flatten_clap_message(&error.render().to_string()))
}
fn flatten_clap_message(rendered: &str) -> String {
let first_block: Vec<&str> = rendered
.lines()
.map(str::trim)
.take_while(|line| !line.is_empty())
.collect();
let joined = first_block.join(" ");
let message = if joined.is_empty() {
rendered.trim()
} else {
joined.as_str()
};
message
.strip_prefix("error: ")
.unwrap_or(message)
.to_string()
}
pub async fn run(
cli: Cli,
matches: &ArgMatches,
dotenv_provenance: &HashMap<String, ConfigSource>,
) -> Result<(), CliError> {
if cli.debug {
let _ = tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(tracing_subscriber::EnvFilter::new(
"warn,a2a_client=debug,a2a_cli=debug",
))
.with_ansi(std::io::stderr().is_terminal())
.with_target(false)
.try_init();
}
validate_a2a_version(cli.a2a_version.as_deref())?;
match &cli.command {
Command::Card { command } => run_card_command(&cli, matches, command).await?,
Command::Config { command } => {
run_config_command(&cli, matches, dotenv_provenance, command)?
}
Command::Completion { shell } => {
let mut command = Cli::command();
let binary = command.get_name().to_string();
clap_complete::generate(*shell, &mut command, binary, &mut std::io::stdout());
}
Command::Send(command) => {
let send_matches = matches
.subcommand_matches("send")
.expect("send subcommand matches present when cli.command is Command::Send");
let parts = resolve_message_parts(send_matches, command)?;
let ResolvedClient {
client,
tenant,
capabilities,
} = resolve_client(&cli, matches).await?;
let request = build_send_message_request(command, parts, tenant.clone());
let streaming = capabilities.declares(Capability::Streaming);
if command.stream && !streaming {
eprintln!(
"warning: the agent card does not declare streaming; \
sending without it and polling for the result instead"
);
}
if command.stream && streaming {
match client.send_streaming_message(&request).await {
Ok(stream) => {
consume_stream(client, stream, &cli).await?;
}
Err(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION => {
let response = send_and_maybe_wait(
client,
&request,
tenant,
cli.async_mode,
cli.poll_interval,
cli.timeout,
)
.await?;
response.warn_outcome();
print_output(&response, &cli)?;
}
Err(error) => {
let _ = client.destroy().await;
return Err(error.into());
}
}
} else {
let response = send_and_maybe_wait(
client,
&request,
tenant,
cli.async_mode,
cli.poll_interval,
cli.timeout,
)
.await?;
response.warn_outcome();
print_output(&response, &cli)?;
}
}
Command::Task { command } => run_task_command(&cli, matches, command).await?,
}
Ok(())
}
async fn send_and_maybe_wait<T: a2a_client::Transport>(
client: A2AClient<T>,
request: &SendMessageRequest,
tenant: Option<String>,
async_mode: bool,
poll_interval: Duration,
timeout: Duration,
) -> Result<SendMessageResponse, CliError> {
let result = client.send_message(request).await;
let response = match result {
Ok(response) => response,
Err(error) => {
let _ = client.destroy().await;
return Err(error.into());
}
};
match response {
SendMessageResponse::Task(task) => {
let task =
settle_task(client, task, !async_mode, tenant, poll_interval, timeout).await?;
Ok(SendMessageResponse::Task(task))
}
SendMessageResponse::Message(message) => {
client.destroy().await?;
Ok(SendMessageResponse::Message(message))
}
}
}
async fn settle_task<T: a2a_client::Transport>(
client: A2AClient<T>,
task: Task,
wait: bool,
tenant: Option<String>,
poll_interval: Duration,
timeout: Duration,
) -> Result<Task, CliError> {
let outcome = if wait && !is_settled(&task.status.state) {
wait_for_task_settled(&client, &task.id, tenant, poll_interval, timeout).await
} else {
Ok(task)
};
match outcome {
Ok(task) => {
client.destroy().await?;
Ok(task)
}
Err(error) => {
let _ = client.destroy().await;
Err(error)
}
}
}
async fn wait_for_task_settled<T: a2a_client::Transport>(
client: &A2AClient<T>,
task_id: &str,
tenant: Option<String>,
poll_interval: Duration,
timeout: Duration,
) -> Result<Task, CliError> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
let task = client
.get_task(&GetTaskRequest {
id: task_id.to_string(),
history_length: None,
tenant: tenant.clone(),
})
.await?;
if is_settled(&task.status.state) {
return Ok(task);
}
let now = tokio::time::Instant::now();
if now >= deadline {
return Err(CliError::Timeout {
task_id: task_id.to_string(),
timeout,
});
}
tokio::time::sleep(poll_interval.min(deadline.saturating_duration_since(now))).await;
}
}
fn task_outcome_note(state: &TaskState) -> Option<&'static str> {
match state {
TaskState::Failed => Some("the agent reported it FAILED"),
TaskState::Rejected => Some("the agent REJECTED it"),
TaskState::InputRequired => {
Some("it is paused at INPUT_REQUIRED and needs a reply to continue")
}
TaskState::AuthRequired => {
Some("it is paused at AUTH_REQUIRED and needs credentials to continue")
}
_ => None,
}
}
fn warn_task_outcome(task_id: &str, state: &TaskState) {
if let Some(note) = task_outcome_note(state) {
eprintln!("warning: task {task_id}: {note}");
}
}
trait TaskOutcome {
fn warn_outcome(&self);
}
impl TaskOutcome for Task {
fn warn_outcome(&self) {
warn_task_outcome(&self.id, &self.status.state);
}
}
impl TaskOutcome for SendMessageResponse {
fn warn_outcome(&self) {
match self {
SendMessageResponse::Task(task) => task.warn_outcome(),
SendMessageResponse::Message(_) => {}
}
}
}
impl TaskOutcome for StreamResponse {
fn warn_outcome(&self) {
match self {
StreamResponse::Task(task) => task.warn_outcome(),
StreamResponse::StatusUpdate(event) => {
warn_task_outcome(&event.task_id, &event.status.state);
}
StreamResponse::Message(_) | StreamResponse::ArtifactUpdate(_) => {}
}
}
}
fn is_settled(state: &TaskState) -> bool {
state.is_terminal() || matches!(state, TaskState::InputRequired | TaskState::AuthRequired)
}
async fn run_card_command(
cli: &Cli,
matches: &ArgMatches,
command: &CardCommand,
) -> Result<(), CliError> {
match command {
CardCommand::Get(command) => {
if command.extended {
let ResolvedClient {
client,
tenant,
capabilities,
} = resolve_client(cli, matches).await?;
capabilities.require(Capability::ExtendedCard)?;
let result = client
.get_extended_agent_card(&GetExtendedAgentCardRequest { tenant })
.await;
let card = finish_client_call(client, result).await?;
if command.validate {
validate_card_or_fail(&serde_json::to_value(&card)?)?;
}
print_output(&card, cli)?;
} else {
let (card, raw) = resolve_agent_card_with_raw(cli, matches).await?;
if command.validate {
validate_card_or_fail(&raw)?;
}
print_output(&card, cli)?;
}
}
}
Ok(())
}
fn validate_card_or_fail(card: &Value) -> Result<(), CliError> {
let violations = card_schema::validate_agent_card(card)
.map_err(|error| CliError::CardInvalid(format!("schema could not be loaded: {error}")))?;
if violations.is_empty() {
Ok(())
} else {
Err(CliError::CardSchemaInvalid { violations })
}
}
async fn run_task_command(
cli: &Cli,
matches: &ArgMatches,
command: &TaskCommand,
) -> Result<(), CliError> {
match command {
TaskCommand::Get(command) => {
let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
let result = client
.get_task(&GetTaskRequest {
id: command.id.clone(),
history_length: command.history_length,
tenant: tenant.clone(),
})
.await;
let task = match result {
Ok(task) => task,
Err(error) => {
let _ = client.destroy().await;
return Err(error.into());
}
};
let task = settle_task(
client,
task,
cli.wait,
tenant,
cli.poll_interval,
cli.timeout,
)
.await?;
task.warn_outcome();
print_output(&task, cli)?;
}
TaskCommand::List(command) => {
let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
let result = client
.list_tasks(&ListTasksRequest {
context_id: command.context_id.clone(),
status: command.status.map(TaskState::from),
page_size: command.page_size,
page_token: command.page_token.clone(),
history_length: command.history_length,
status_timestamp_after: None,
include_artifacts: command.include_artifacts.then_some(true),
tenant,
})
.await;
let response = finish_client_call(client, result).await?;
print_output(&response, cli)?;
}
TaskCommand::Cancel(command) => {
let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
let result = client
.cancel_task(&CancelTaskRequest {
id: command.id.clone(),
metadata: None,
tenant,
})
.await;
let task = finish_client_call(client, result).await?;
task.warn_outcome();
print_output(&task, cli)?;
}
TaskCommand::Subscribe(command) => {
let ResolvedClient {
client,
tenant,
capabilities,
} = resolve_client(cli, matches).await?;
capabilities.require(Capability::Streaming)?;
let stream = client
.subscribe_to_task(&SubscribeToTaskRequest {
id: command.id.clone(),
tenant: tenant.clone(),
})
.await?;
subscribe_with_resumption(client, stream, command.id.clone(), tenant, cli).await?;
}
TaskCommand::PushConfig { command } => {
run_push_config_command(cli, matches, command).await?;
}
}
Ok(())
}
fn resolve_message_parts(
matches: &ArgMatches,
command: &MessageCommand,
) -> Result<Vec<Part>, CliError> {
enum Kind {
Text,
File,
Data,
}
enum Token<'a> {
Part(Kind, &'a str),
MediaType(&'a str),
}
let mut tokens: Vec<(usize, Token<'_>)> = Vec::new();
if let Some(indices) = matches.indices_of("text_parts") {
tokens.extend(
indices
.zip(command.text_parts.iter())
.map(|(index, value)| (index, Token::Part(Kind::Text, value.as_str()))),
);
}
if let Some(indices) = matches.indices_of("file_parts") {
tokens.extend(
indices
.zip(command.file_parts.iter())
.map(|(index, value)| (index, Token::Part(Kind::File, value.as_str()))),
);
}
if let Some(indices) = matches.indices_of("data_parts") {
tokens.extend(
indices
.zip(command.data_parts.iter())
.map(|(index, value)| (index, Token::Part(Kind::Data, value.as_str()))),
);
}
if !command.media_types.is_empty() && tokens.is_empty() {
return Err(CliError::InvalidInput(
"--media-type must immediately follow a --text-part/--file-part/--data-part"
.to_string(),
));
}
if tokens.is_empty() {
return match command.text.as_deref() {
Some(text) => Ok(vec![Part::text(text)]),
None => Err(CliError::InvalidInput(
"message must have at least one part: pass text, or use \
--text-part/--file-part/--data-part"
.to_string(),
)),
};
}
if command.text.is_some() {
return Err(CliError::InvalidInput(
"the positional message text cannot be combined with \
--text-part/--file-part/--data-part; pass it as --text-part instead"
.to_string(),
));
}
if let Some(indices) = matches.indices_of("media_types") {
tokens.extend(
indices
.zip(command.media_types.iter())
.map(|(index, value)| (index, Token::MediaType(value.as_str()))),
);
}
tokens.sort_by_key(|(index, _)| *index);
let mut parts: Vec<Part> = Vec::new();
for (_, token) in tokens {
match token {
Token::Part(kind, value) => {
let part = match kind {
Kind::Text => Part::text(value),
Kind::File => build_file_part(value)?,
Kind::Data => build_data_part(value)?,
};
parts.push(part);
}
Token::MediaType(media_type) => match parts.last_mut() {
Some(part) => part.media_type = Some(media_type.to_string()),
None => {
return Err(CliError::InvalidInput(
"--media-type must immediately follow a --text-part/--file-part/--data-part"
.to_string(),
));
}
},
}
}
Ok(parts)
}
fn build_file_part(value: &str) -> Result<Part, CliError> {
if value.contains("://") {
return Ok(Part::url(value));
}
let bytes = std::fs::read(value).map_err(|source| CliError::ReadFile {
path: value.to_string(),
source,
})?;
let mut part = Part::raw(bytes);
part.filename = std::path::Path::new(value)
.file_name()
.map(|name| name.to_string_lossy().into_owned());
Ok(part)
}
fn means_not_a_readable_path(kind: std::io::ErrorKind) -> bool {
matches!(
kind,
std::io::ErrorKind::NotFound
| std::io::ErrorKind::InvalidFilename
| std::io::ErrorKind::InvalidInput
)
}
fn build_data_part(value: &str) -> Result<Part, CliError> {
if value == "-" {
use std::io::Read;
let mut buffer = String::new();
std::io::stdin()
.read_to_string(&mut buffer)
.map_err(|source| CliError::ReadFile {
path: "<stdin>".to_string(),
source,
})?;
return parse_data_part_json(&buffer, "<stdin>");
}
match std::fs::read_to_string(value) {
Ok(text) => return parse_data_part_json(&text, value),
Err(source) if !means_not_a_readable_path(source.kind()) => {
return Err(CliError::ReadFile {
path: value.to_string(),
source,
});
}
Err(_) => {} }
serde_json::from_str(value).map(Part::data).map_err(|_| {
CliError::InvalidInput(format!(
"--data-part must be a file path, \"-\" for stdin, or inline JSON: {value}"
))
})
}
fn parse_data_part_json(text: &str, source: &str) -> Result<Part, CliError> {
serde_json::from_str(text).map(Part::data).map_err(|error| {
CliError::InvalidInput(format!("--data-part: invalid JSON in {source}: {error}"))
})
}
fn parse_duration(input: &str) -> Result<Duration, String> {
let trimmed = input.trim();
let (magnitude, unit) = if let Some(value) = trimmed.strip_suffix("ms") {
(value, "ms")
} else if let Some(value) = trimmed.strip_suffix('s') {
(value, "s")
} else if let Some(value) = trimmed.strip_suffix('m') {
(value, "m")
} else {
(trimmed, "s")
};
let magnitude: f64 = magnitude
.trim()
.parse()
.map_err(|_| format!("invalid duration: {input}"))?;
if magnitude < 0.0 || !magnitude.is_finite() {
return Err(format!("duration must be a non-negative number: {input}"));
}
let seconds = match unit {
"ms" => magnitude / 1000.0,
"m" => magnitude * 60.0,
_ => magnitude,
};
Ok(Duration::from_secs_f64(seconds))
}
fn build_send_message_request(
command: &MessageCommand,
parts: Vec<Part>,
tenant: Option<String>,
) -> SendMessageRequest {
let mut message = Message::new(Role::User, parts);
message.context_id = command.context_id.clone();
message.task_id = command.task_id.clone();
let configuration = if command.history_length.is_some()
|| !command.accepted_output_modes.is_empty()
|| command.return_immediately
{
Some(SendMessageConfiguration {
accepted_output_modes: (!command.accepted_output_modes.is_empty())
.then_some(command.accepted_output_modes.clone()),
task_push_notification_config: None,
history_length: command.history_length,
return_immediately: command.return_immediately.then_some(true),
})
} else {
None
};
SendMessageRequest {
message,
configuration,
metadata: None,
tenant,
}
}
fn build_push_notification_config(
command: &CreatePushConfigCommand,
) -> Result<TaskPushNotificationConfig, CliError> {
if command.auth_credentials.is_some() && command.auth_scheme.is_none() {
return Err(CliError::InvalidInput(
"--auth-credentials requires --auth-scheme".to_string(),
));
}
let authentication = command
.auth_scheme
.clone()
.map(|scheme| AuthenticationInfo {
scheme,
credentials: command.auth_credentials.clone(),
});
Ok(TaskPushNotificationConfig {
task_id: String::new(),
url: command.url.clone(),
id: command.config_id.clone(),
token: command.token.clone(),
authentication,
tenant: None,
})
}
async fn run_push_config_command(
cli: &Cli,
matches: &ArgMatches,
command: &PushConfigCommand,
) -> Result<(), CliError> {
match command {
PushConfigCommand::Create(command) => {
let ResolvedClient {
client,
tenant,
capabilities,
} = resolve_client(cli, matches).await?;
capabilities.require(Capability::PushNotifications)?;
let mut config = build_push_notification_config(command)?;
config.task_id = command.task_id.clone();
config.tenant = tenant;
let result = client.create_push_config(&config).await;
let response = finish_client_call(client, result).await?;
print_output(&response, cli)?;
}
PushConfigCommand::Get(command) => {
let ResolvedClient {
client,
tenant,
capabilities,
} = resolve_client(cli, matches).await?;
capabilities.require(Capability::PushNotifications)?;
let result = client
.get_push_config(&GetTaskPushNotificationConfigRequest {
task_id: command.task_id.clone(),
id: command.id.clone(),
tenant,
})
.await;
let response = finish_client_call(client, result).await?;
print_output(&response, cli)?;
}
PushConfigCommand::List(command) => {
let ResolvedClient {
client,
tenant,
capabilities,
} = resolve_client(cli, matches).await?;
capabilities.require(Capability::PushNotifications)?;
let result = client
.list_push_configs(&ListTaskPushNotificationConfigsRequest {
task_id: command.task_id.clone(),
page_size: command.page_size,
page_token: command.page_token.clone(),
tenant,
})
.await;
let response = finish_client_call(client, result).await?;
print_output(&response, cli)?;
}
PushConfigCommand::Delete(command) => {
let ResolvedClient {
client,
tenant,
capabilities,
} = resolve_client(cli, matches).await?;
capabilities.require(Capability::PushNotifications)?;
let result = client
.delete_push_config(&DeleteTaskPushNotificationConfigRequest {
task_id: command.task_id.clone(),
id: command.id.clone(),
tenant,
})
.await;
finish_client_call(client, result).await?;
print_output(
&PushConfigDeleted {
deleted: true,
task_id: command.task_id.clone(),
id: command.id.clone(),
},
cli,
)?;
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSource {
Flag,
EnvVar,
LocalFile,
GlobalFile,
Default,
}
impl ConfigSource {
fn label(self) -> &'static str {
match self {
ConfigSource::Flag => "flag",
ConfigSource::EnvVar => "environment variable",
ConfigSource::LocalFile => "local .env file",
ConfigSource::GlobalFile => "global .env file",
ConfigSource::Default => "built-in default",
}
}
}
fn load_dotenv_config(args: &[OsString]) -> HashMap<String, ConfigSource> {
let real_env_keys: HashSet<String> = std::env::vars_os()
.filter_map(|(key, _)| key.into_string().ok())
.filter(|key| key.starts_with("A2ACLI_"))
.collect();
let mut provenance = HashMap::new();
if let Some(global_path) = global_config_path() {
apply_dotenv_file(
&global_path,
ConfigSource::GlobalFile,
&real_env_keys,
&mut provenance,
);
}
let local_path = find_flag_value(args, "--config")
.map(PathBuf::from)
.or_else(find_local_dotenv);
if let Some(local_path) = local_path {
apply_dotenv_file(
&local_path,
ConfigSource::LocalFile,
&real_env_keys,
&mut provenance,
);
}
provenance
}
fn find_flag_value(args: &[OsString], flag: &str) -> Option<String> {
let prefix = format!("{flag}=");
for (index, arg) in args.iter().enumerate() {
let Some(arg) = arg.to_str() else { continue };
if arg == flag {
return args.get(index + 1)?.to_str().map(str::to_string);
}
if let Some(value) = arg.strip_prefix(&prefix) {
return Some(value.to_string());
}
}
None
}
fn global_config_path() -> Option<PathBuf> {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(xdg).join("a2a-cli").join(".env"));
}
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(
PathBuf::from(home)
.join(".config")
.join("a2a-cli")
.join(".env"),
)
}
fn find_local_dotenv() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join(".env");
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
fn apply_dotenv_file(
path: &Path,
source: ConfigSource,
real_env_keys: &HashSet<String>,
provenance: &mut HashMap<String, ConfigSource>,
) {
let Ok(contents) = std::fs::read_to_string(path) else {
return;
};
warn_if_world_readable(path);
for (key, value) in parse_dotenv(&contents) {
if !key.starts_with("A2ACLI_") || real_env_keys.contains(&key) {
continue;
}
unsafe {
std::env::set_var(&key, &value);
}
provenance.insert(key, source);
}
}
#[cfg(unix)]
fn warn_if_world_readable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
let Ok(metadata) = std::fs::metadata(path) else {
return;
};
let mode = metadata.permissions().mode();
if mode & 0o077 != 0 {
eprintln!(
"warning: {} is readable by other users (mode {:o}); it may contain credentials — \
consider `chmod 600 {}`",
path.display(),
mode & 0o777,
path.display()
);
}
}
#[cfg(not(unix))]
fn warn_if_world_readable(_path: &Path) {}
fn parse_dotenv(contents: &str) -> Vec<(String, String)> {
contents
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, value) = line.split_once('=')?;
let key = key.trim().to_string();
let value = value.trim();
let value = ["\"", "'"]
.iter()
.find_map(|quote| {
value
.strip_prefix(quote)
.and_then(|rest| rest.strip_suffix(quote))
})
.unwrap_or(value);
Some((key, value.to_string()))
})
.collect()
}
#[derive(Debug, Clone, Serialize)]
struct ConfigSetting {
name: String,
value: String,
source: String,
}
#[derive(Debug, Clone, Serialize)]
struct ConfigSettings {
settings: Vec<ConfigSetting>,
}
impl TextRender for ConfigSettings {
fn render_text(&self) -> String {
let mut out = TextOutput::default();
for setting in &self.settings {
out.field(
&setting.name,
format!("{} (source: {})", setting.value, setting.source),
);
}
out.finish()
}
}
fn redact_secret(value: &Option<String>) -> String {
match value {
Some(_) => "(set, redacted)".to_string(),
None => "(not set)".to_string(),
}
}
fn run_config_command(
cli: &Cli,
matches: &ArgMatches,
dotenv_provenance: &HashMap<String, ConfigSource>,
command: &ConfigCommand,
) -> Result<(), CliError> {
match command {
ConfigCommand::Show => {
let settings = effective_config_settings(cli, matches, dotenv_provenance);
print_output(&settings, cli)?;
}
}
Ok(())
}
fn effective_config_settings(
cli: &Cli,
matches: &ArgMatches,
dotenv_provenance: &HashMap<String, ConfigSource>,
) -> ConfigSettings {
let mut settings = Vec::new();
let mut add = |id: &str, name: &str, value: String, env_key: &str| {
let source = match matches.value_source(id) {
Some(ValueSource::CommandLine) => ConfigSource::Flag,
Some(ValueSource::EnvVariable) => dotenv_provenance
.get(env_key)
.copied()
.unwrap_or(ConfigSource::EnvVar),
_ => ConfigSource::Default,
};
settings.push(ConfigSetting {
name: name.to_string(),
value,
source: source.label().to_string(),
});
};
add(
"agent_card",
"agent-card",
cli.agent_card
.clone()
.unwrap_or_else(|| "(not set)".to_string()),
"A2ACLI_AGENT_CARD",
);
add(
"endpoint",
"endpoint",
cli.endpoint
.clone()
.unwrap_or_else(|| "(not set)".to_string()),
"A2ACLI_ENDPOINT",
);
add(
"base_url",
"base-url",
cli.base_url.clone(),
"A2ACLI_BASE_URL",
);
add(
"transport",
"transport",
if cli.transport.is_empty() {
"(agent card's own order)".to_string()
} else {
cli.transport
.iter()
.map(|binding| format!("{binding:?}").to_lowercase())
.collect::<Vec<_>>()
.join(",")
},
"A2ACLI_TRANSPORT",
);
add(
"a2a_version",
"a2a-version",
cli.a2a_version.clone().unwrap_or_else(|| {
format!("(negotiated from the agent card; a2acli supports {VERSION})")
}),
"A2ACLI_A2A_VERSION",
);
add(
"bearer",
"bearer",
redact_secret(&cli.bearer),
"A2ACLI_BEARER",
);
add(
"api_key",
"api-key",
redact_secret(&cli.api_key),
"A2ACLI_API_KEY",
);
add(
"insecure",
"insecure",
cli.insecure.to_string(),
"A2ACLI_INSECURE",
);
add("debug", "debug", cli.debug.to_string(), "A2ACLI_DEBUG");
add(
"tenant",
"tenant",
cli.tenant
.clone()
.unwrap_or_else(|| "(not set)".to_string()),
"A2ACLI_TENANT",
);
add(
"output",
"output",
format!("{:?}", cli.output).to_lowercase(),
"A2ACLI_OUTPUT",
);
add(
"compact",
"compact",
cli.compact.to_string(),
"A2ACLI_COMPACT",
);
add(
"async_mode",
"async",
cli.async_mode.to_string(),
"A2ACLI_ASYNC",
);
add("wait", "wait", cli.wait.to_string(), "A2ACLI_WAIT");
add(
"poll_interval",
"poll-interval",
format!("{:?}", cli.poll_interval),
"A2ACLI_POLL_INTERVAL",
);
add(
"timeout",
"timeout",
format!("{:?}", cli.timeout),
"A2ACLI_TIMEOUT",
);
settings.push(ConfigSetting {
name: "resolved-card".to_string(),
value: match &cli.endpoint {
Some(endpoint) => format!("(none; --endpoint {endpoint})"),
None => {
match normalize_card_reference(cli.agent_card.as_deref().unwrap_or(&cli.base_url)) {
CardReference::File(path) => format!("file://{}", path.display()),
CardReference::Url(url) => url,
}
}
},
source: "derived".to_string(),
});
ConfigSettings { settings }
}
struct ResolvedClient {
client: A2AClient<Box<dyn a2a_client::Transport>>,
tenant: Option<String>,
capabilities: DeclaredCapabilities,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Capability {
Streaming,
PushNotifications,
ExtendedCard,
}
impl Capability {
fn declared_in(self, capabilities: &AgentCapabilities) -> bool {
match self {
Capability::Streaming => capabilities.streaming.unwrap_or(false),
Capability::PushNotifications => capabilities.push_notifications.unwrap_or(false),
Capability::ExtendedCard => capabilities.extended_agent_card.unwrap_or(false),
}
}
fn field(self) -> &'static str {
match self {
Capability::Streaming => "streaming",
Capability::PushNotifications => "pushNotifications",
Capability::ExtendedCard => "extendedAgentCard",
}
}
fn undeclared_error(self) -> A2AError {
let message = format!("the agent card does not declare {}", self.field());
match self {
Capability::PushNotifications => {
A2AError::new(a2a::error_code::PUSH_NOTIFICATION_NOT_SUPPORTED, message)
}
Capability::Streaming | Capability::ExtendedCard => {
A2AError::unsupported_operation(message)
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum DeclaredCapabilities {
Card(AgentCapabilities),
NoCard,
}
impl DeclaredCapabilities {
fn declares(&self, capability: Capability) -> bool {
match self {
DeclaredCapabilities::Card(capabilities) => capability.declared_in(capabilities),
DeclaredCapabilities::NoCard => true,
}
}
fn require(&self, capability: Capability) -> Result<(), CliError> {
if self.declares(capability) {
Ok(())
} else {
Err(CliError::A2A(capability.undeclared_error()))
}
}
}
fn parse_protocol_version(value: &str) -> Option<(u32, u32)> {
let mut parts = value.trim().split('.');
let major = parts.next()?.parse().ok()?;
let minor = match parts.next() {
Some(minor) => minor.parse().ok()?,
None => 0,
};
Some((major, minor))
}
fn supported_protocol_version() -> (u32, u32) {
parse_protocol_version(VERSION).unwrap_or((1, 0))
}
struct NegotiatedVersion {
version: String,
note: Option<String>,
}
fn negotiate_a2a_version(
explicit: Option<&str>,
declared: &str,
supported: (u32, u32),
) -> NegotiatedVersion {
let supported_label = format!("{}.{}", supported.0, supported.1);
if let Some(requested) = explicit {
let note = (parse_protocol_version(requested) != Some(supported)).then(|| {
format!("signaling A2A-Version {requested} (a2acli supports {supported_label})")
});
return NegotiatedVersion {
version: requested.to_string(),
note,
};
}
let Some(declared_version) = parse_protocol_version(declared) else {
return NegotiatedVersion {
version: supported_label.clone(),
note: Some(format!(
"agent card declares an unparseable protocol version ({declared}); \
signaling {supported_label}"
)),
};
};
if declared_version.0 != 1 {
return NegotiatedVersion {
version: supported_label.clone(),
note: Some(format!(
"agent card declares protocol version {declared}, which is outside 1.x; \
signaling {supported_label} rather than negotiating below 1.0"
)),
};
}
if declared_version < supported {
let version = format!("{}.{}", declared_version.0, declared_version.1);
return NegotiatedVersion {
note: Some(format!(
"negotiated A2A-Version down to {version} (agent card declares {version}, \
a2acli supports {supported_label})"
)),
version,
};
}
NegotiatedVersion {
version: supported_label,
note: None,
}
}
fn validate_a2a_version(value: Option<&str>) -> Result<(), CliError> {
let Some(requested) = value else {
return Ok(());
};
match parse_protocol_version(requested) {
Some((1, _)) => Ok(()),
Some(_) => Err(CliError::InvalidInput(format!(
"--a2a-version must be 1.x, got {requested}: A2A reads an empty or pre-1.0 \
version as 0.3, and a2acli never signals below 1.0"
))),
None => Err(CliError::InvalidInput(format!(
"--a2a-version must be a version like 1.0, got {requested}"
))),
}
}
struct VersionInterceptor {
version: String,
}
#[async_trait::async_trait]
impl a2a_client::middleware::CallInterceptor for VersionInterceptor {
async fn before(
&self,
_method: &str,
params: &mut a2a_client::ServiceParams,
) -> Result<(), A2AError> {
params.insert(SVC_PARAM_VERSION.to_string(), vec![self.version.clone()]);
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum AgentSelection {
Card(String),
Endpoint { url: String, binding: Binding },
}
fn resolve_agent_selection(cli: &Cli, matches: &ArgMatches) -> Result<AgentSelection, CliError> {
match (&cli.agent_card, &cli.endpoint) {
(Some(_), Some(_)) => Err(CliError::InvalidInput(
"--endpoint and --agent-card are mutually exclusive: --endpoint names an interface \
directly, so there is no card to resolve"
.to_string(),
)),
(None, Some(url)) => match cli.transport.as_slice() {
[binding] => Ok(AgentSelection::Endpoint {
url: url.clone(),
binding: *binding,
}),
transports => Err(CliError::InvalidInput(format!(
"--endpoint requires exactly one --transport (got {}): there is no agent card \
to declare the interface's protocol binding",
transports.len()
))),
},
(Some(reference), None) => Ok(AgentSelection::Card(reference.clone())),
(None, None) => Ok(AgentSelection::Card(base_url_card_reference(cli, matches))),
}
}
fn synthesized_endpoint_card(url: &str, binding: Binding) -> AgentCard {
AgentCard {
name: url.to_string(),
description: "synthesized from --endpoint; no agent card was resolved".to_string(),
version: VERSION.to_string(),
supported_interfaces: vec![AgentInterface::new(url, binding.protocol())],
capabilities: AgentCapabilities::default(),
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
provider: None,
documentation_url: None,
icon_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
}
}
async fn resolve_client(cli: &Cli, matches: &ArgMatches) -> Result<ResolvedClient, CliError> {
let card = resolve_agent_card(cli, matches).await?;
let capabilities = match resolve_agent_selection(cli, matches)? {
AgentSelection::Endpoint { .. } => DeclaredCapabilities::NoCard,
AgentSelection::Card(_) => DeclaredCapabilities::Card(card.capabilities.clone()),
};
let mut builder = A2AClientFactory::builder();
if !cli.transport.is_empty() {
let preferred = cli
.transport
.iter()
.map(|binding| binding.protocol().to_string())
.collect();
builder = builder.preferred_bindings(preferred);
}
if cli.insecure {
let insecure = build_insecure_reqwest_client()?;
builder = builder
.register(Arc::new(a2a_client::jsonrpc::JsonRpcTransportFactory::new(
Some(insecure.clone()),
)))
.register(Arc::new(a2a_client::rest::RestTransportFactory::new(Some(
insecure,
))));
}
let mut interceptors: Vec<Arc<dyn a2a_client::middleware::CallInterceptor>> = Vec::new();
if let Some(token) = &cli.bearer {
interceptors.push(Arc::new(AuthInterceptor::bearer(token.clone())));
}
if let Some(api_key) = &cli.api_key {
interceptors.push(Arc::new(AuthInterceptor::custom(
"X-API-Key",
api_key.clone(),
)));
}
for param in &cli.svc_params {
interceptors.push(Arc::new(AuthInterceptor::custom(
param.name.clone(),
param.value.clone(),
)));
}
if cli.debug {
interceptors.push(Arc::new(a2a_client::middleware::LoggingInterceptor));
}
let factory = builder.build();
let (client, interface) = factory.create_from_card_with_interface(&card).await?;
let negotiated = negotiate_a2a_version(
cli.a2a_version.as_deref(),
&interface.protocol_version,
supported_protocol_version(),
);
if let Some(note) = &negotiated.note {
eprintln!("warning: {note}");
}
interceptors.push(Arc::new(VersionInterceptor {
version: negotiated.version,
}));
let tenant = cli.tenant.clone();
Ok(ResolvedClient {
client: client.with_interceptors(interceptors),
tenant,
capabilities,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CardReference {
File(PathBuf),
Url(String),
}
fn looks_like_file_path(reference: &str) -> bool {
reference.starts_with('/')
|| reference.starts_with("./")
|| reference.starts_with("../")
|| reference.starts_with('\\')
|| reference.starts_with(".\\")
|| reference.starts_with("..\\")
|| has_windows_drive_prefix(reference)
|| std::fs::metadata(reference).is_ok()
}
fn has_windows_drive_prefix(reference: &str) -> bool {
let bytes = reference.as_bytes();
bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& (bytes[2] == b'\\' || bytes[2] == b'/')
}
fn is_loopback_host(host: &str) -> bool {
if let Ok(address) = host.parse::<std::net::IpAddr>() {
return address.is_loopback();
}
let host = match host.strip_prefix('[') {
Some(rest) => rest.split_once(']').map_or(rest, |(inside, _)| inside),
None => host.split_once(':').map_or(host, |(head, _)| head),
};
host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
fn normalize_card_reference(reference: &str) -> CardReference {
if let Some(rest) = reference.strip_prefix("file://") {
let path = match rest.strip_prefix('/') {
Some(without_slash) if has_windows_drive_prefix(without_slash) => without_slash,
_ => rest,
};
return CardReference::File(PathBuf::from(path));
}
if !reference.contains("://") {
if looks_like_file_path(reference) {
return CardReference::File(PathBuf::from(reference));
}
let scheme = if is_loopback_host(reference) {
"http"
} else {
"https"
};
return CardReference::Url(append_well_known_path(&format!("{scheme}://{reference}")));
}
CardReference::Url(append_well_known_path(reference))
}
fn append_well_known_path(url: &str) -> String {
let trimmed = url.trim_end_matches('/');
let has_path = trimmed
.split_once("://")
.is_some_and(|(_, rest)| rest.contains('/'));
if has_path {
url.to_string()
} else {
format!("{trimmed}{WELL_KNOWN_AGENT_CARD_PATH}")
}
}
fn base_url_card_reference(cli: &Cli, matches: &ArgMatches) -> String {
if matches!(
matches.value_source("base_url"),
Some(ValueSource::CommandLine)
) {
eprintln!(
"warning: --base-url is deprecated; use --agent-card, which also accepts a full \
card URL or a local file path"
);
}
cli.base_url.clone()
}
async fn resolve_agent_card(cli: &Cli, matches: &ArgMatches) -> Result<AgentCard, CliError> {
Ok(resolve_agent_card_with_raw(cli, matches).await?.0)
}
async fn resolve_agent_card_with_raw(
cli: &Cli,
matches: &ArgMatches,
) -> Result<(AgentCard, Value), CliError> {
warn_if_insecure_with_credentials(cli);
let reference = match resolve_agent_selection(cli, matches)? {
AgentSelection::Card(reference) => reference,
AgentSelection::Endpoint { url, binding } => {
let card = synthesized_endpoint_card(&url, binding);
let raw = serde_json::to_value(&card)?;
return Ok((card, raw));
}
};
match normalize_card_reference(&reference) {
CardReference::File(path) => read_agent_card_file(&path),
CardReference::Url(url) => {
let client = if cli.insecure {
build_insecure_reqwest_client()?
} else {
Client::new()
};
let request = apply_request_auth(client.get(&url), cli);
let response = request.send().await?.error_for_status()?;
let raw: Value = response.json().await?;
let card: AgentCard = serde_json::from_value(raw.clone())
.map_err(|error| CliError::CardInvalid(format!("{url}: {error}")))?;
Ok((card, raw))
}
}
}
fn read_agent_card_file(path: &Path) -> Result<(AgentCard, Value), CliError> {
let text = std::fs::read_to_string(path).map_err(|source| CliError::CardFile {
path: path.display().to_string(),
source,
})?;
let raw: Value = serde_json::from_str(&text)
.map_err(|error| CliError::CardInvalid(format!("{}: {error}", path.display())))?;
let card: AgentCard = serde_json::from_value(raw.clone())
.map_err(|error| CliError::CardInvalid(format!("{}: {error}", path.display())))?;
Ok((card, raw))
}
fn apply_request_auth(mut request: RequestBuilder, cli: &Cli) -> RequestBuilder {
if let Some(token) = &cli.bearer {
request = request.bearer_auth(token);
}
if let Some(api_key) = &cli.api_key {
request = request.header("X-API-Key", api_key);
}
for param in &cli.svc_params {
request = request.header(¶m.name, ¶m.value);
}
request
}
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
fn build_insecure_reqwest_client() -> Result<Client, CliError> {
Client::builder()
.danger_accept_invalid_certs(true)
.build()
.map_err(CliError::Http)
}
#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
fn build_insecure_reqwest_client() -> Result<Client, CliError> {
Client::builder().build().map_err(CliError::Http)
}
fn warn_if_insecure_with_credentials(cli: &Cli) {
if !cli.insecure {
return;
}
let sends_credentials = cli.bearer.is_some() || cli.api_key.is_some();
if sends_credentials {
eprintln!(
"warning: --insecure disables TLS certificate verification, and a credential \
(--bearer/--api-key) is being sent over this connection — use only against a \
trusted development endpoint"
);
} else {
eprintln!(
"warning: --insecure disables TLS certificate verification — use only against a \
trusted development endpoint"
);
}
}
trait TextRender {
fn render_text(&self) -> String;
}
#[derive(Default)]
struct TextOutput {
lines: Vec<String>,
}
impl TextOutput {
fn field(&mut self, label: &str, value: impl std::fmt::Display) -> &mut Self {
self.lines.push(format!("{label}: {value}"));
self
}
fn raw(&mut self, line: impl Into<String>) -> &mut Self {
self.lines.push(line.into());
self
}
fn block(&mut self, label: &str, content: &str) -> &mut Self {
self.lines.push(format!("{label}:"));
self.lines
.extend(content.lines().map(|line| line.to_string()));
self.lines.push(String::new());
self
}
fn finish(self) -> String {
let mut text = self.lines.join("\n");
while text.ends_with('\n') {
text.pop();
}
text
}
}
fn task_state_label(state: &TaskState) -> &'static str {
match state {
TaskState::Unspecified => "UNSPECIFIED",
TaskState::Submitted => "SUBMITTED",
TaskState::Working => "WORKING",
TaskState::Completed => "COMPLETED",
TaskState::Failed => "FAILED",
TaskState::Canceled => "CANCELED",
TaskState::InputRequired => "INPUT_REQUIRED",
TaskState::Rejected => "REJECTED",
TaskState::AuthRequired => "AUTH_REQUIRED",
}
}
fn render_part(out: &mut TextOutput, part: &Part) {
match &part.content {
PartContent::Text(text) => {
out.block("Text", text);
}
PartContent::Data(value) => {
let pretty = serde_json::to_string_pretty(value)
.unwrap_or_else(|_| serde_json::Value::Null.to_string());
out.block("Data", &pretty);
}
PartContent::Raw(bytes) => {
let name = part.filename.as_deref().unwrap_or("(unnamed)");
match &part.media_type {
Some(media_type) => out.field(
"File",
format!("{name} ({media_type}, {} bytes)", bytes.len()),
),
None => out.field("File", format!("{name} ({} bytes)", bytes.len())),
};
}
PartContent::Url(url) => {
match &part.media_type {
Some(media_type) => out.field("File", format!("{url} ({media_type})")),
None => out.field("File", url),
};
}
}
}
fn render_message_parts(out: &mut TextOutput, message: &Message) {
if message.parts.is_empty() {
out.field("Parts", "(none)");
return;
}
for part in &message.parts {
render_part(out, part);
}
}
impl TextRender for AgentCard {
fn render_text(&self) -> String {
let mut out = TextOutput::default();
out.field("Name", &self.name)
.field("Description", &self.description)
.field("Version", &self.version)
.field("Streaming", self.capabilities.streaming.unwrap_or(false))
.field(
"Push Notifications",
self.capabilities.push_notifications.unwrap_or(false),
)
.field(
"Extended Card",
self.capabilities.extended_agent_card.unwrap_or(false),
);
let interfaces = if self.supported_interfaces.is_empty() {
" (none)".to_string()
} else {
self.supported_interfaces
.iter()
.map(|interface| {
format!(
" - {} {} (v{})",
interface.protocol_binding, interface.url, interface.protocol_version
)
})
.collect::<Vec<_>>()
.join("\n")
};
out.block("Interfaces", &interfaces);
out.field("Skills", self.skills.len());
out.finish()
}
}
impl TextRender for Task {
fn render_text(&self) -> String {
let mut out = TextOutput::default();
out.field("Task ID", &self.id)
.field("Context ID", &self.context_id)
.field("State", task_state_label(&self.status.state));
if let Some(message) = &self.status.message {
render_message_parts(&mut out, message);
}
if let Some(artifacts) = &self.artifacts {
for artifact in artifacts {
let label = artifact.name.as_deref().unwrap_or(&artifact.artifact_id);
out.raw(format!("Artifact: {label}"));
for part in &artifact.parts {
render_part(&mut out, part);
}
}
}
if matches!(
self.status.state,
TaskState::InputRequired | TaskState::AuthRequired
) {
out.field(
"Resume with",
format!("a2acli send --task-id {} \"<reply>\"", self.id),
);
}
out.finish()
}
}
impl TextRender for Message {
fn render_text(&self) -> String {
let mut out = TextOutput::default();
if let Some(context_id) = &self.context_id {
out.field("Context ID", context_id);
}
if let Some(task_id) = &self.task_id {
out.field("Task ID", task_id);
}
render_message_parts(&mut out, self);
out.finish()
}
}
impl TextRender for SendMessageResponse {
fn render_text(&self) -> String {
match self {
SendMessageResponse::Task(task) => task.render_text(),
SendMessageResponse::Message(message) => message.render_text(),
}
}
}
impl TextRender for ListTasksResponse {
fn render_text(&self) -> String {
let mut out = TextOutput::default();
out.field("Total", self.total_size)
.field("Page Size", self.page_size);
if !self.next_page_token.is_empty() {
out.field("Next Page Token", &self.next_page_token);
}
if self.tasks.is_empty() {
out.field("Tasks", "(none)");
} else {
for task in &self.tasks {
out.block("Task", &task.render_text());
}
}
out.finish()
}
}
impl TextRender for TaskPushNotificationConfig {
fn render_text(&self) -> String {
let mut out = TextOutput::default();
if let Some(id) = &self.id {
out.field("Config ID", id);
}
out.field("Task ID", &self.task_id).field("URL", &self.url);
if let Some(token) = &self.token {
out.field("Token", token);
}
if let Some(auth) = &self.authentication {
out.field("Auth Scheme", &auth.scheme);
}
out.finish()
}
}
impl TextRender for ListTaskPushNotificationConfigsResponse {
fn render_text(&self) -> String {
let mut out = TextOutput::default();
if let Some(token) = &self.next_page_token {
out.field("Next Page Token", token);
}
if self.configs.is_empty() {
out.field("Configs", "(none)");
} else {
for config in &self.configs {
out.block("Push Config", &config.render_text());
}
}
out.finish()
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PushConfigDeleted {
deleted: bool,
task_id: String,
id: String,
}
impl TextRender for PushConfigDeleted {
fn render_text(&self) -> String {
let mut out = TextOutput::default();
out.field("Deleted", self.deleted)
.field("Task ID", &self.task_id)
.field("Config ID", &self.id);
out.finish()
}
}
impl TextRender for StreamResponse {
fn render_text(&self) -> String {
match self {
StreamResponse::Task(task) => task.render_text(),
StreamResponse::Message(message) => message.render_text(),
StreamResponse::StatusUpdate(event) => {
let mut out = TextOutput::default();
out.field("Task ID", &event.task_id)
.field("Context ID", &event.context_id)
.field("State", task_state_label(&event.status.state));
if matches!(
event.status.state,
TaskState::InputRequired | TaskState::AuthRequired
) {
out.field(
"Resume with",
format!("a2acli send --task-id {} \"<reply>\"", event.task_id),
);
}
out.finish()
}
StreamResponse::ArtifactUpdate(event) => {
let mut out = TextOutput::default();
out.field("Task ID", &event.task_id)
.field("Context ID", &event.context_id);
let label = event
.artifact
.name
.as_deref()
.unwrap_or(&event.artifact.artifact_id);
out.raw(format!("Artifact: {label}"));
for part in &event.artifact.parts {
render_part(&mut out, part);
}
out.finish()
}
}
}
}
fn print_output<T: Serialize + TextRender>(value: &T, cli: &Cli) -> Result<(), CliError> {
match cli.output {
OutputFormat::Text => {
println!("{}", value.render_text());
Ok(())
}
OutputFormat::Json => print_json(value, cli.compact),
}
}
fn print_json<T: Serialize>(value: &T, compact: bool) -> Result<(), CliError> {
if compact {
println!("{}", serde_json::to_string(value)?);
} else {
println!("{}", serde_json::to_string_pretty(value)?);
}
Ok(())
}
fn parse_header(input: &str) -> Result<HeaderArg, String> {
let (name, value) = input
.split_once(':')
.ok_or_else(|| "header must be in NAME:VALUE format".to_string())?;
let name = name.trim();
let value = value.trim();
if name.is_empty() {
return Err("header name cannot be empty".to_string());
}
Ok(HeaderArg {
name: name.to_string(),
value: value.to_string(),
})
}
async fn finish_client_call<T: a2a_client::Transport, V>(
client: A2AClient<T>,
result: Result<V, A2AError>,
) -> Result<V, CliError> {
match result {
Ok(value) => {
client.destroy().await?;
Ok(value)
}
Err(error) => {
let _ = client.destroy().await;
Err(error.into())
}
}
}
fn print_stream_value<V: Serialize + TextRender>(value: &V, cli: &Cli) -> Result<(), CliError> {
match cli.output {
OutputFormat::Text => {
println!("{}", value.render_text());
Ok(())
}
OutputFormat::Json => serde_json::to_string(value)
.map(|line| println!("{line}"))
.map_err(CliError::from),
}
}
async fn print_or_destroy<T: a2a_client::Transport, V: Serialize + TextRender + TaskOutcome>(
client: &A2AClient<T>,
value: &V,
cli: &Cli,
) -> Result<(), CliError> {
value.warn_outcome();
if let Err(error) = print_stream_value(value, cli) {
let _ = client.destroy().await;
return Err(error);
}
Ok(())
}
async fn consume_stream<T: a2a_client::Transport, V: Serialize + TextRender + TaskOutcome>(
client: A2AClient<T>,
mut stream: BoxStream<'static, Result<V, A2AError>>,
cli: &Cli,
) -> Result<(), CliError> {
loop {
match stream.next().await {
Some(Ok(value)) => {
print_or_destroy(&client, &value, cli).await?;
}
Some(Err(error)) => {
let _ = client.destroy().await;
return Err(error.into());
}
None => {
client.destroy().await?;
return Ok(());
}
}
}
}
fn stream_response_state(value: &StreamResponse) -> Option<TaskState> {
match value {
StreamResponse::Task(task) => Some(task.status.state.clone()),
StreamResponse::StatusUpdate(event) => Some(event.status.state.clone()),
StreamResponse::Message(_) | StreamResponse::ArtifactUpdate(_) => None,
}
}
async fn subscribe_with_resumption<T: a2a_client::Transport>(
client: A2AClient<T>,
mut stream: BoxStream<'static, Result<StreamResponse, A2AError>>,
task_id: String,
tenant: Option<String>,
cli: &Cli,
) -> Result<(), CliError> {
let deadline = tokio::time::Instant::now() + cli.timeout;
let mut last_state: Option<TaskState> = None;
let mut attempt: u32 = 0;
loop {
let state_before_this_attempt = last_state.clone();
let mut first_event = attempt > 0;
loop {
match stream.next().await {
Some(Ok(value)) => {
let state = stream_response_state(&value);
if let Some(state) = &state {
last_state = Some(state.clone());
}
let is_unchanged_reconciliation =
first_event && state.is_some() && state == state_before_this_attempt;
first_event = false;
if is_unchanged_reconciliation {
continue;
}
print_or_destroy(&client, &value, cli).await?;
}
Some(Err(error)) => {
let _ = client.destroy().await;
return Err(error.into());
}
None => break,
}
}
if last_state.as_ref().is_some_and(is_settled) {
client.destroy().await?;
return Ok(());
}
loop {
let now = tokio::time::Instant::now();
if now >= deadline {
let _ = client.destroy().await;
return Err(CliError::Timeout {
task_id: task_id.clone(),
timeout: cli.timeout,
});
}
attempt += 1;
eprintln!(
"warning: subscription to task {task_id} was interrupted before it settled; \
reconnecting (attempt {attempt})..."
);
tokio::time::sleep(
cli.poll_interval
.min(deadline.saturating_duration_since(now)),
)
.await;
match client
.subscribe_to_task(&SubscribeToTaskRequest {
id: task_id.clone(),
tenant: tenant.clone(),
})
.await
{
Ok(new_stream) => {
stream = new_stream;
break;
}
Err(_) => continue,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use a2a_client::{ServiceParams, Transport};
use a2a_server::jsonrpc::jsonrpc_router;
use a2a_server::rest::rest_router;
use a2a_server::{
RequestHandler, ServiceParams as HandlerServiceParams, WELL_KNOWN_AGENT_CARD_PATH,
};
use async_trait::async_trait;
use axum::routing::get;
use axum::{Json, Router};
use futures::stream;
use reqwest::header;
use serde::ser;
use std::collections::BTreeMap;
use std::sync::Mutex;
use tokio::net::TcpListener;
struct TestTransport {
destroy_error: Option<A2AError>,
}
#[async_trait]
impl Transport for TestTransport {
async fn send_message(
&self,
_params: &ServiceParams,
_req: &SendMessageRequest,
) -> Result<SendMessageResponse, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn send_streaming_message(
&self,
_params: &ServiceParams,
_req: &SendMessageRequest,
) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn get_task(
&self,
_params: &ServiceParams,
_req: &GetTaskRequest,
) -> Result<Task, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn list_tasks(
&self,
_params: &ServiceParams,
_req: &ListTasksRequest,
) -> Result<ListTasksResponse, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn cancel_task(
&self,
_params: &ServiceParams,
_req: &CancelTaskRequest,
) -> Result<Task, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn subscribe_to_task(
&self,
_params: &ServiceParams,
_req: &SubscribeToTaskRequest,
) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn create_push_config(
&self,
_params: &ServiceParams,
_req: &TaskPushNotificationConfig,
) -> Result<TaskPushNotificationConfig, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn get_push_config(
&self,
_params: &ServiceParams,
_req: &GetTaskPushNotificationConfigRequest,
) -> Result<TaskPushNotificationConfig, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn list_push_configs(
&self,
_params: &ServiceParams,
_req: &ListTaskPushNotificationConfigsRequest,
) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn delete_push_config(
&self,
_params: &ServiceParams,
_req: &DeleteTaskPushNotificationConfigRequest,
) -> Result<(), A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn get_extended_agent_card(
&self,
_params: &ServiceParams,
_req: &GetExtendedAgentCardRequest,
) -> Result<AgentCard, A2AError> {
Err(A2AError::unsupported_operation("unused"))
}
async fn destroy(&self) -> Result<(), A2AError> {
match &self.destroy_error {
Some(error) => Err(error.clone()),
None => Ok(()),
}
}
}
struct FailingSerialize;
impl Serialize for FailingSerialize {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Err(ser::Error::custom("serialize failed"))
}
}
impl TextRender for FailingSerialize {
fn render_text(&self) -> String {
String::new()
}
}
impl TaskOutcome for FailingSerialize {
fn warn_outcome(&self) {}
}
fn make_test_client(destroy_error: Option<A2AError>) -> A2AClient<TestTransport> {
A2AClient::new(TestTransport { destroy_error })
}
#[derive(Default)]
struct RunTestState {
tasks: Mutex<BTreeMap<String, Task>>,
push_configs: Mutex<BTreeMap<(String, String), TaskPushNotificationConfig>>,
}
struct RunTestHandler {
state: Arc<RunTestState>,
extended_card: AgentCard,
}
struct RunTestServer {
base_url: String,
handle: tokio::task::JoinHandle<()>,
}
impl Drop for RunTestServer {
fn drop(&mut self) {
self.handle.abort();
}
}
impl RunTestServer {
async fn spawn() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let state = Arc::new(RunTestState::default());
state.tasks.lock().unwrap().insert(
"task-1".to_string(),
make_fixture_task("task-1", "ctx-1", TaskState::Completed, "seeded result"),
);
let public_card = make_fixture_card(&base_url, "Fixture Agent");
let extended_card = make_fixture_card(&base_url, "Fixture Agent (extended)");
let handler = Arc::new(RunTestHandler {
state,
extended_card,
});
let card = public_card.clone();
let app = Router::new()
.route(
WELL_KNOWN_AGENT_CARD_PATH,
get(move || {
let card = card.clone();
async move { Json(card) }
}),
)
.nest("/jsonrpc", jsonrpc_router(handler.clone()))
.nest("/rest", rest_router(handler));
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
RunTestServer { base_url, handle }
}
}
#[async_trait]
impl RequestHandler for RunTestHandler {
async fn send_message(
&self,
_params: &HandlerServiceParams,
req: SendMessageRequest,
) -> Result<SendMessageResponse, A2AError> {
let task_id = req
.message
.task_id
.clone()
.unwrap_or_else(|| "task-send".to_string());
let context_id = req
.message
.context_id
.clone()
.unwrap_or_else(|| "ctx-send".to_string());
let text = req.message.text().unwrap_or_default();
if text == "send-error" {
return Err(A2AError::invalid_request("send failed"));
}
let task = make_fixture_task(
&task_id,
&context_id,
TaskState::Completed,
&format!("Echo: {text}"),
);
self.state
.tasks
.lock()
.unwrap()
.insert(task_id, task.clone());
Ok(SendMessageResponse::Task(task))
}
async fn send_streaming_message(
&self,
_params: &HandlerServiceParams,
req: SendMessageRequest,
) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
let task_id = req
.message
.task_id
.clone()
.unwrap_or_else(|| "task-stream".to_string());
let context_id = req
.message
.context_id
.clone()
.unwrap_or_else(|| "ctx-stream".to_string());
let text = req.message.text().unwrap_or_default();
if text == "stream-error" {
return Ok(Box::pin(stream::once(async {
Err(A2AError::internal("stream failed"))
})));
}
let task = make_fixture_task(
&task_id,
&context_id,
TaskState::Completed,
&format!("Echo: {text}"),
);
self.state
.tasks
.lock()
.unwrap()
.insert(task_id.clone(), task.clone());
Ok(Box::pin(stream::iter(vec![
Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id,
context_id,
status: TaskStatus {
state: TaskState::Working,
message: None,
timestamp: None,
},
metadata: None,
})),
Ok(StreamResponse::Task(task)),
])))
}
async fn get_task(
&self,
_params: &HandlerServiceParams,
req: GetTaskRequest,
) -> Result<Task, A2AError> {
self.state
.tasks
.lock()
.unwrap()
.get(&req.id)
.cloned()
.ok_or_else(|| A2AError::task_not_found(&req.id))
}
async fn list_tasks(
&self,
_params: &HandlerServiceParams,
req: ListTasksRequest,
) -> Result<ListTasksResponse, A2AError> {
if req.context_id.as_deref() == Some("error") {
return Err(A2AError::invalid_params("list failed"));
}
let tasks = self
.state
.tasks
.lock()
.unwrap()
.values()
.filter(|task| {
req.context_id
.as_ref()
.map(|context_id| task.context_id == *context_id)
.unwrap_or(true)
})
.filter(|task| {
req.status
.as_ref()
.map(|status| task.status.state == *status)
.unwrap_or(true)
})
.cloned()
.collect();
Ok(ListTasksResponse {
tasks,
next_page_token: String::new(),
page_size: 0,
total_size: 0,
})
}
async fn cancel_task(
&self,
_params: &HandlerServiceParams,
req: CancelTaskRequest,
) -> Result<Task, A2AError> {
let mut tasks = self.state.tasks.lock().unwrap();
let task = tasks
.get(&req.id)
.cloned()
.ok_or_else(|| A2AError::task_not_found(&req.id))?;
let canceled =
make_fixture_task(&task.id, &task.context_id, TaskState::Canceled, "canceled");
tasks.insert(req.id, canceled.clone());
Ok(canceled)
}
async fn subscribe_to_task(
&self,
_params: &HandlerServiceParams,
req: SubscribeToTaskRequest,
) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
if req.id == "stream-error" {
return Ok(Box::pin(stream::once(async {
Err(A2AError::internal("stream failed"))
})));
}
let task = self
.state
.tasks
.lock()
.unwrap()
.get(&req.id)
.cloned()
.ok_or_else(|| A2AError::task_not_found(&req.id))?;
Ok(Box::pin(stream::iter(vec![
Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: req.id.clone(),
context_id: task.context_id.clone(),
status: TaskStatus {
state: TaskState::Working,
message: None,
timestamp: None,
},
metadata: None,
})),
Ok(StreamResponse::Task(task)),
])))
}
async fn create_push_config(
&self,
_params: &HandlerServiceParams,
req: TaskPushNotificationConfig,
) -> Result<TaskPushNotificationConfig, A2AError> {
if !self.state.tasks.lock().unwrap().contains_key(&req.task_id) {
return Err(A2AError::task_not_found(&req.task_id));
}
let config_id = req.id.clone().unwrap_or_else(|| "generated".to_string());
self.state
.push_configs
.lock()
.unwrap()
.insert((req.task_id.clone(), config_id), req.clone());
Ok(req)
}
async fn get_push_config(
&self,
_params: &HandlerServiceParams,
req: GetTaskPushNotificationConfigRequest,
) -> Result<TaskPushNotificationConfig, A2AError> {
self.state
.push_configs
.lock()
.unwrap()
.get(&(req.task_id.clone(), req.id.clone()))
.cloned()
.ok_or_else(|| A2AError::task_not_found(&req.task_id))
}
async fn list_push_configs(
&self,
_params: &HandlerServiceParams,
req: ListTaskPushNotificationConfigsRequest,
) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
if req.task_id == "missing" {
return Err(A2AError::task_not_found(&req.task_id));
}
let configs = self
.state
.push_configs
.lock()
.unwrap()
.values()
.filter(|config| config.task_id == req.task_id)
.cloned()
.collect();
Ok(ListTaskPushNotificationConfigsResponse {
configs,
next_page_token: None,
})
}
async fn delete_push_config(
&self,
_params: &HandlerServiceParams,
req: DeleteTaskPushNotificationConfigRequest,
) -> Result<(), A2AError> {
let deleted = self
.state
.push_configs
.lock()
.unwrap()
.remove(&(req.task_id.clone(), req.id.clone()));
if deleted.is_none() {
return Err(A2AError::task_not_found(&req.task_id));
}
Ok(())
}
async fn get_extended_agent_card(
&self,
_params: &HandlerServiceParams,
req: GetExtendedAgentCardRequest,
) -> Result<AgentCard, A2AError> {
if req.tenant.as_deref() == Some("error") {
return Err(A2AError::unsupported_operation("extended card denied"));
}
Ok(self.extended_card.clone())
}
}
fn make_fixture_card(base_url: &str, name: &str) -> AgentCard {
AgentCard {
name: name.to_string(),
description: "CLI unit-test fixture".to_string(),
version: VERSION.to_string(),
supported_interfaces: vec![
AgentInterface::new(format!("{base_url}/jsonrpc"), TRANSPORT_PROTOCOL_JSONRPC),
AgentInterface::new(format!("{base_url}/rest"), TRANSPORT_PROTOCOL_HTTP_JSON),
],
capabilities: AgentCapabilities {
streaming: Some(true),
push_notifications: Some(true),
extensions: None,
extended_agent_card: Some(true),
},
default_input_modes: vec!["text/plain".to_string()],
default_output_modes: vec!["text/plain".to_string()],
skills: vec![],
provider: None,
documentation_url: None,
icon_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
}
}
fn make_fixture_task(task_id: &str, context_id: &str, state: TaskState, text: &str) -> Task {
Task {
id: task_id.to_string(),
context_id: context_id.to_string(),
status: TaskStatus {
state,
message: Some(Message {
message_id: format!("msg-{task_id}"),
context_id: Some(context_id.to_string()),
task_id: Some(task_id.to_string()),
role: Role::Agent,
parts: vec![Part::text(text)],
metadata: None,
extensions: None,
reference_task_ids: None,
}),
timestamp: None,
},
artifacts: None,
history: None,
metadata: None,
}
}
fn build_args(base_url: &str, args: &[&str]) -> Vec<String> {
let mut argv = vec![
"a2acli".to_string(),
"--agent-card".to_string(),
base_url.to_string(),
];
argv.extend(args.iter().map(|arg| (*arg).to_string()));
argv
}
async fn run_test_cli(base_url: &str, args: &[&str]) -> Result<(), CliError> {
run_args(build_args(base_url, args)).await
}
async fn unused_base_url() -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
format!("http://{addr}")
}
fn assert_unsupported_operation<T>(result: Result<T, A2AError>) {
match result {
Ok(_) => panic!("expected unsupported operation error"),
Err(err) => assert_eq!(err.code, a2a::error_code::UNSUPPORTED_OPERATION),
}
}
#[test]
fn test_parse_header() {
let header = parse_header("Authorization: Bearer token").unwrap();
assert_eq!(header.name, "Authorization");
assert_eq!(header.value, "Bearer token");
}
#[test]
fn test_parse_header_requires_separator() {
let err = parse_header("Authorization").unwrap_err();
assert_eq!(err, "header must be in NAME:VALUE format");
}
#[test]
fn test_parse_header_requires_name() {
let err = parse_header(": value").unwrap_err();
assert_eq!(err, "header name cannot be empty");
}
#[test]
fn test_build_send_message_request_populates_optional_fields() {
let request = build_send_message_request(
&MessageCommand {
text: Some("hello".to_string()),
text_parts: Vec::new(),
file_parts: Vec::new(),
data_parts: Vec::new(),
media_types: Vec::new(),
context_id: Some("ctx-1".to_string()),
task_id: Some("task-1".to_string()),
history_length: Some(4),
accepted_output_modes: vec!["text/plain".to_string()],
return_immediately: true,
stream: false,
},
vec![Part::text("hello")],
Some("tenant-1".to_string()),
);
assert_eq!(request.message.text(), Some("hello"));
assert_eq!(request.message.context_id.as_deref(), Some("ctx-1"));
assert_eq!(request.message.task_id.as_deref(), Some("task-1"));
assert_eq!(request.tenant.as_deref(), Some("tenant-1"));
assert_eq!(
request
.configuration
.as_ref()
.and_then(|config| config.history_length),
Some(4)
);
assert_eq!(
request
.configuration
.as_ref()
.and_then(|config| config.return_immediately),
Some(true)
);
}
#[test]
fn test_build_send_message_request_without_optional_fields() {
let request = build_send_message_request(
&MessageCommand {
text: Some("hello".to_string()),
text_parts: Vec::new(),
file_parts: Vec::new(),
data_parts: Vec::new(),
media_types: Vec::new(),
context_id: None,
task_id: None,
history_length: None,
accepted_output_modes: Vec::new(),
return_immediately: false,
stream: false,
},
vec![Part::text("hello")],
None,
);
assert_eq!(request.message.text(), Some("hello"));
assert!(request.configuration.is_none());
assert!(request.tenant.is_none());
}
#[test]
fn test_cli_parse_send_command() {
let cli = Cli::try_parse_from([
"a2acli",
"--transport",
"jsonrpc",
"--svc-param",
"X-Test:123",
"send",
"hello",
"--history-length",
"2",
])
.unwrap();
assert_eq!(cli.transport, vec![Binding::Jsonrpc]);
assert_eq!(cli.svc_params.len(), 1);
assert!(matches!(cli.command, Command::Send(_)));
}
#[test]
fn test_cli_parse_push_config_create_command() {
let cli = Cli::try_parse_from([
"a2acli",
"task",
"push-config",
"create",
"task-1",
"https://example.com/callback",
"--config-id",
"cfg-1",
"--token",
"tok-1",
"--auth-scheme",
"Bearer",
"--auth-credentials",
"secret",
])
.unwrap();
match cli.command {
Command::Task {
command:
TaskCommand::PushConfig {
command: PushConfigCommand::Create(command),
},
} => {
assert_eq!(command.task_id, "task-1");
assert_eq!(command.url, "https://example.com/callback");
assert_eq!(command.config_id.as_deref(), Some("cfg-1"));
assert_eq!(command.token.as_deref(), Some("tok-1"));
assert_eq!(command.auth_scheme.as_deref(), Some("Bearer"));
assert_eq!(command.auth_credentials.as_deref(), Some("secret"));
}
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn test_build_push_notification_config() {
let config = build_push_notification_config(&CreatePushConfigCommand {
task_id: "task-1".to_string(),
url: "https://example.com/callback".to_string(),
config_id: Some("cfg-1".to_string()),
token: Some("tok-1".to_string()),
auth_scheme: Some("Bearer".to_string()),
auth_credentials: Some("secret".to_string()),
})
.unwrap();
assert_eq!(config.id.as_deref(), Some("cfg-1"));
assert_eq!(config.token.as_deref(), Some("tok-1"));
assert_eq!(
config
.authentication
.as_ref()
.map(|auth| auth.scheme.as_str()),
Some("Bearer")
);
assert_eq!(
config
.authentication
.as_ref()
.and_then(|auth| auth.credentials.as_deref()),
Some("secret")
);
}
#[test]
fn test_build_push_notification_config_requires_auth_scheme() {
let err = build_push_notification_config(&CreatePushConfigCommand {
task_id: "task-1".to_string(),
url: "https://example.com/callback".to_string(),
config_id: None,
token: None,
auth_scheme: None,
auth_credentials: Some("secret".to_string()),
})
.unwrap_err();
assert!(matches!(err, CliError::InvalidInput(_)));
}
#[test]
fn test_build_push_notification_config_without_authentication() {
let config = build_push_notification_config(&CreatePushConfigCommand {
task_id: "task-1".to_string(),
url: "https://example.com/callback".to_string(),
config_id: None,
token: None,
auth_scheme: None,
auth_credentials: None,
})
.unwrap();
assert_eq!(config.url, "https://example.com/callback");
assert!(config.authentication.is_none());
}
#[test]
fn test_binding_protocols() {
assert_eq!(Binding::Jsonrpc.protocol(), TRANSPORT_PROTOCOL_JSONRPC);
assert_eq!(Binding::Rest.protocol(), TRANSPORT_PROTOCOL_HTTP_JSON);
}
#[test]
fn test_apply_request_auth_builds_headers() {
let cli = Cli::try_parse_from([
"a2acli",
"--bearer",
"secret",
"--api-key",
"key-123",
"--svc-param",
"X-Test: 123",
"card",
"get",
])
.unwrap();
let request = apply_request_auth(Client::new().get("http://example.com"), &cli)
.build()
.unwrap();
assert_eq!(
request.headers().get(header::AUTHORIZATION).unwrap(),
"Bearer secret"
);
assert_eq!(request.headers().get("X-API-Key").unwrap(), "key-123");
assert_eq!(request.headers().get("X-Test").unwrap(), "123");
}
#[tokio::test]
async fn test_finish_client_call_propagates_destroy_error() {
let err = finish_client_call(
make_test_client(Some(A2AError::internal("destroy failed"))),
Ok(serde_json::json!({ "ok": true })),
)
.await
.unwrap_err();
assert!(matches!(err, CliError::A2A(_)));
}
fn json_cli() -> Cli {
parse_with_matches(&["--output", "json", "task", "get", "unused"]).0
}
#[tokio::test]
async fn test_consume_stream_reports_json_error() {
let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
let err = consume_stream(make_test_client(None), stream, &json_cli())
.await
.unwrap_err();
assert!(matches!(err, CliError::Json(_)));
}
#[tokio::test]
async fn test_print_or_destroy_reports_json_error() {
let client = make_test_client(None);
let err = print_or_destroy(&client, &FailingSerialize, &json_cli())
.await
.unwrap_err();
assert!(matches!(err, CliError::Json(_)));
}
#[tokio::test]
async fn test_print_or_destroy_succeeds_in_text_mode() {
let client = make_test_client(None);
print_or_destroy(&client, &FailingSerialize, &text_cli())
.await
.unwrap();
}
#[tokio::test]
async fn test_consume_stream_propagates_destroy_error_on_completion() {
let stream = Box::pin(stream::empty::<Result<Task, A2AError>>());
let err = consume_stream(
make_test_client(Some(A2AError::internal("destroy failed"))),
stream,
&json_cli(),
)
.await
.unwrap_err();
assert!(matches!(err, CliError::A2A(_)));
}
#[tokio::test]
async fn test_test_transport_methods_return_unused_errors() {
let transport = TestTransport {
destroy_error: None,
};
let params = ServiceParams::new();
assert_unsupported_operation(
transport
.send_message(
¶ms,
&SendMessageRequest {
message: Message::new(Role::User, vec![Part::text("hello")]),
configuration: None,
metadata: None,
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.send_streaming_message(
¶ms,
&SendMessageRequest {
message: Message::new(Role::User, vec![Part::text("hello")]),
configuration: None,
metadata: None,
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.get_task(
¶ms,
&GetTaskRequest {
id: "task-1".to_string(),
history_length: None,
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.list_tasks(
¶ms,
&ListTasksRequest {
context_id: None,
status: None,
page_size: None,
page_token: None,
history_length: None,
status_timestamp_after: None,
include_artifacts: None,
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.cancel_task(
¶ms,
&CancelTaskRequest {
id: "task-1".to_string(),
metadata: None,
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.subscribe_to_task(
¶ms,
&SubscribeToTaskRequest {
id: "task-1".to_string(),
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.create_push_config(
¶ms,
&TaskPushNotificationConfig {
task_id: "task-1".to_string(),
url: "https://example.com/callback".to_string(),
id: Some("cfg-1".to_string()),
token: None,
authentication: None,
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.get_push_config(
¶ms,
&GetTaskPushNotificationConfigRequest {
task_id: "task-1".to_string(),
id: "cfg-1".to_string(),
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.list_push_configs(
¶ms,
&ListTaskPushNotificationConfigsRequest {
task_id: "task-1".to_string(),
page_size: None,
page_token: None,
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.delete_push_config(
¶ms,
&DeleteTaskPushNotificationConfigRequest {
task_id: "task-1".to_string(),
id: "cfg-1".to_string(),
tenant: None,
},
)
.await,
);
assert_unsupported_operation(
transport
.get_extended_agent_card(¶ms, &GetExtendedAgentCardRequest { tenant: None })
.await,
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_run_executes_all_commands_in_lib_tests() {
let server = RunTestServer::spawn().await;
run_test_cli(&server.base_url, &["card", "get"])
.await
.unwrap();
run_test_cli(
&server.base_url,
&["--compact", "card", "get", "--extended"],
)
.await
.unwrap();
run_test_cli(
&server.base_url,
&[
"--transport",
"jsonrpc",
"--bearer",
"secret",
"--svc-param",
"X-Test: 123",
"send",
"hello from unit test",
"--task-id",
"task-send",
"--context-id",
"ctx-send",
],
)
.await
.unwrap();
run_test_cli(
&server.base_url,
&[
"--compact",
"send",
"streaming request",
"--stream",
"--task-id",
"task-stream",
"--context-id",
"ctx-stream",
],
)
.await
.unwrap();
run_test_cli(&server.base_url, &["task", "get", "task-send"])
.await
.unwrap();
run_test_cli(
&server.base_url,
&[
"--compact",
"task",
"list",
"--context-id",
"ctx-send",
"--status",
"completed",
],
)
.await
.unwrap();
run_test_cli(&server.base_url, &["task", "cancel", "task-send"])
.await
.unwrap();
run_test_cli(
&server.base_url,
&["--compact", "task", "subscribe", "task-stream"],
)
.await
.unwrap();
run_test_cli(
&server.base_url,
&[
"task",
"push-config",
"create",
"task-1",
"https://example.com/callback",
"--config-id",
"cfg-1",
],
)
.await
.unwrap();
run_test_cli(
&server.base_url,
&["--compact", "task", "push-config", "get", "task-1", "cfg-1"],
)
.await
.unwrap();
run_test_cli(
&server.base_url,
&["--compact", "task", "push-config", "list", "task-1"],
)
.await
.unwrap();
run_test_cli(
&server.base_url,
&["task", "push-config", "delete", "task-1", "cfg-1"],
)
.await
.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_run_surfaces_errors_in_lib_tests() {
let server = RunTestServer::spawn().await;
let err = run_test_cli(
&server.base_url,
&["card", "get", "--extended", "--tenant", "error"],
)
.await
.unwrap_err();
assert!(matches!(
err,
CliError::A2A(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION
));
let err = run_test_cli(&server.base_url, &["send", "send-error"])
.await
.unwrap_err();
assert!(matches!(
err,
CliError::A2A(error) if error.code == a2a::error_code::INVALID_REQUEST
));
let err = run_test_cli(&server.base_url, &["task", "list", "--context-id", "error"])
.await
.unwrap_err();
assert!(matches!(
err,
CliError::A2A(error) if error.code == a2a::error_code::INVALID_PARAMS
));
let err = run_test_cli(
&server.base_url,
&["--compact", "send", "stream-error", "--stream"],
)
.await
.unwrap_err();
assert!(matches!(
err,
CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
));
let err = run_test_cli(
&server.base_url,
&["--compact", "task", "subscribe", "stream-error"],
)
.await
.unwrap_err();
assert!(matches!(
err,
CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
));
let err = run_test_cli(
&server.base_url,
&[
"task",
"push-config",
"create",
"missing",
"https://example.com/callback",
"--config-id",
"cfg-missing",
],
)
.await
.unwrap_err();
assert!(matches!(
err,
CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
));
let err = run_test_cli(
&server.base_url,
&["task", "push-config", "list", "missing"],
)
.await
.unwrap_err();
assert!(matches!(
err,
CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
));
let base_url = unused_base_url().await;
let err = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
assert!(matches!(err, CliError::Http(_)));
}
#[test]
fn test_task_state_conversion() {
let cases = [
(TaskStateArg::Unspecified, TaskState::Unspecified),
(TaskStateArg::Submitted, TaskState::Submitted),
(TaskStateArg::Working, TaskState::Working),
(TaskStateArg::Completed, TaskState::Completed),
(TaskStateArg::Failed, TaskState::Failed),
(TaskStateArg::Canceled, TaskState::Canceled),
(TaskStateArg::InputRequired, TaskState::InputRequired),
(TaskStateArg::Rejected, TaskState::Rejected),
(TaskStateArg::AuthRequired, TaskState::AuthRequired),
];
for (input, expected) in cases {
assert_eq!(TaskState::from(input), expected);
}
}
#[test]
fn test_means_not_a_readable_path_classification() {
use std::io::ErrorKind;
for kind in [
ErrorKind::NotFound,
ErrorKind::InvalidFilename,
ErrorKind::InvalidInput,
] {
assert!(
means_not_a_readable_path(kind),
"{kind:?} should fall through to inline-JSON parsing"
);
}
for kind in [
ErrorKind::PermissionDenied,
ErrorKind::IsADirectory,
ErrorKind::InvalidData,
] {
assert!(
!means_not_a_readable_path(kind),
"{kind:?} should be surfaced as a read error"
);
}
}
#[test]
fn test_parse_duration_variants() {
assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
assert_eq!(parse_duration("1.5s").unwrap(), Duration::from_millis(1500));
}
#[test]
fn test_parse_duration_rejects_invalid_input() {
assert!(parse_duration("banana").is_err());
assert!(parse_duration("-1s").is_err());
}
#[test]
fn test_is_settled_classifies_task_states() {
let settled = [
TaskState::Completed,
TaskState::Failed,
TaskState::Canceled,
TaskState::Rejected,
TaskState::InputRequired,
TaskState::AuthRequired,
];
for state in settled {
assert!(is_settled(&state), "{state:?} should be settled");
}
let unsettled = [
TaskState::Unspecified,
TaskState::Submitted,
TaskState::Working,
];
for state in unsettled {
assert!(!is_settled(&state), "{state:?} should not be settled");
}
}
fn parse_with_matches(args: &[&str]) -> (Cli, ArgMatches) {
let mut argv = vec!["a2acli".to_string()];
argv.extend(args.iter().map(|arg| (*arg).to_string()));
let matches = Cli::command().try_get_matches_from(argv).unwrap();
let cli = Cli::from_arg_matches(&matches).unwrap();
(cli, matches)
}
fn send_command(cli: &Cli) -> &MessageCommand {
match &cli.command {
Command::Send(command) => command,
other => panic!("expected Command::Send, got {other:?}"),
}
}
#[test]
fn test_resolve_message_parts_preserves_interleaved_order_and_media_type() {
let (cli, matches) = parse_with_matches(&[
"send",
"--text-part",
"hello",
"--file-part",
"https://example.com/doc.pdf",
"--media-type",
"application/pdf",
"--data-part",
r#"{"k":1}"#,
]);
let send_matches = matches.subcommand_matches("send").unwrap();
let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0].as_text(), Some("hello"));
assert!(parts[0].media_type.is_none());
assert!(matches!(
&parts[1].content,
PartContent::Url(url) if url == "https://example.com/doc.pdf"
));
assert_eq!(parts[1].media_type.as_deref(), Some("application/pdf"));
assert!(matches!(&parts[2].content, PartContent::Data(_)));
assert!(parts[2].media_type.is_none());
}
#[test]
fn test_resolve_message_parts_positional_text_is_shorthand() {
let (cli, matches) = parse_with_matches(&["send", "hello"]);
let send_matches = matches.subcommand_matches("send").unwrap();
let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].as_text(), Some("hello"));
}
#[test]
fn test_resolve_message_parts_rejects_media_type_without_preceding_part() {
let (cli, matches) =
parse_with_matches(&["send", "--media-type", "text/plain", "--text-part", "hi"]);
let send_matches = matches.subcommand_matches("send").unwrap();
let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
assert!(matches!(err, CliError::InvalidInput(_)));
}
#[test]
fn test_resolve_message_parts_rejects_positional_text_with_part_flags() {
let (cli, matches) = parse_with_matches(&["send", "hello", "--text-part", "world"]);
let send_matches = matches.subcommand_matches("send").unwrap();
let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
assert!(matches!(err, CliError::InvalidInput(_)));
}
#[test]
fn test_cli_error_exit_codes_and_envelope_codes() {
let a2a = CliError::A2A(A2AError::task_not_found("t-1"));
assert_eq!(a2a.exit_code(), 1);
let envelope = a2a.envelope();
assert_eq!(envelope.error.code, "TASK_NOT_FOUND");
assert_eq!(envelope.error.a2a_code, Some(-32001));
let usage = CliError::InvalidInput("bad flag".to_string());
assert_eq!(usage.exit_code(), 2);
assert_eq!(usage.envelope().error.code, "A2ACLI_ERR_USAGE");
assert!(usage.envelope().error.a2a_code.is_none());
let read_file = CliError::ReadFile {
path: "missing.bin".to_string(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
};
assert_eq!(read_file.exit_code(), 2);
assert_eq!(read_file.envelope().error.code, "A2ACLI_ERR_USAGE");
let timeout = CliError::Timeout {
task_id: "t-2".to_string(),
timeout: Duration::from_secs(30),
};
assert_eq!(timeout.exit_code(), 5);
assert_eq!(timeout.envelope().error.code, "A2ACLI_ERR_TIMEOUT");
let json = CliError::Json(serde_json::from_str::<serde_json::Value>("{").unwrap_err());
assert_eq!(json.exit_code(), 1);
assert_eq!(json.envelope().error.code, "A2ACLI_ERR_INTERNAL");
}
#[test]
fn test_cli_error_report_prints_one_json_line_to_stderr() {
let error = CliError::A2A(A2AError::unsupported_operation("nope"));
let json = serde_json::to_string(&error.envelope()).unwrap();
assert!(json.contains("\"code\":\"UNSUPPORTED_OPERATION\""));
assert!(!json.contains('\n'));
}
#[test]
fn test_agent_card_render_text() {
let card = AgentCard {
name: "Fixture".to_string(),
description: "desc".to_string(),
version: VERSION.to_string(),
supported_interfaces: vec![AgentInterface::new(
"http://host/jsonrpc",
TRANSPORT_PROTOCOL_JSONRPC,
)],
capabilities: AgentCapabilities {
streaming: Some(true),
push_notifications: Some(false),
extensions: None,
extended_agent_card: None,
},
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
provider: None,
documentation_url: None,
icon_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
};
let text = card.render_text();
assert!(text.contains("Name: Fixture"));
assert!(text.contains("Streaming: true"));
assert!(text.contains("Push Notifications: false"));
assert!(text.contains("Extended Card: false"));
assert!(text.contains("Interfaces:"));
assert!(text.contains("http://host/jsonrpc"));
assert!(text.contains("Skills: 0"));
}
#[test]
fn test_message_render_text_renders_every_part_kind() {
let mut file_part = Part::raw(vec![1, 2, 3]);
file_part.filename = Some("report.bin".to_string());
file_part.media_type = Some("application/octet-stream".to_string());
let message = Message::new(
Role::Agent,
vec![
Part::text("hello"),
Part::data(serde_json::json!({"k": 1})),
file_part,
],
);
let text = message.render_text();
assert!(text.contains("Text:\nhello"));
assert!(text.contains("Data:"));
assert!(text.contains("\"k\": 1"));
assert!(text.contains("File: report.bin (application/octet-stream, 3 bytes)"));
}
#[test]
fn test_render_part_without_media_type_falls_back_to_name_and_url() {
let message = Message::new(
Role::Agent,
vec![
Part::raw(vec![7; 12]),
Part::raw(vec![1, 2]).with_filename("notes.txt"),
Part::url("https://example.com/spec.pdf"),
Part::url("https://example.com/report.csv").with_media_type("text/csv"),
],
);
let text = message.render_text();
assert!(text.contains("File: (unnamed) (12 bytes)"), "{text}");
assert!(text.contains("File: notes.txt (2 bytes)"), "{text}");
assert!(
text.contains("File: https://example.com/spec.pdf"),
"{text}"
);
assert!(
text.contains("File: https://example.com/report.csv (text/csv)"),
"{text}"
);
}
#[test]
fn test_message_render_text_marks_an_empty_part_list() {
let message = Message {
message_id: "msg-1".to_string(),
context_id: Some("ctx-1".to_string()),
task_id: Some("task-1".to_string()),
role: Role::Agent,
parts: vec![],
metadata: None,
extensions: None,
reference_task_ids: None,
};
let text = message.render_text();
assert!(text.contains("Context ID: ctx-1"), "{text}");
assert!(text.contains("Task ID: task-1"), "{text}");
assert!(text.contains("Parts: (none)"), "{text}");
}
#[test]
fn test_send_message_response_renders_the_message_arm() {
let message = Message::new(Role::Agent, vec![Part::text("no task needed")]);
let response = SendMessageResponse::Message(message.clone());
assert_eq!(response.render_text(), message.render_text());
assert!(response.render_text().contains("no task needed"));
}
#[test]
fn test_agent_card_render_text_marks_an_empty_interface_list() {
let card = AgentCard {
name: "Bare".to_string(),
description: "no interfaces".to_string(),
version: VERSION.to_string(),
supported_interfaces: vec![],
capabilities: AgentCapabilities {
streaming: None,
push_notifications: None,
extensions: None,
extended_agent_card: None,
},
default_input_modes: vec![],
default_output_modes: vec![],
skills: vec![],
provider: None,
documentation_url: None,
icon_url: None,
security_schemes: None,
security_requirements: None,
signatures: None,
};
let text = card.render_text();
assert!(text.contains("Interfaces:\n (none)"), "{text}");
assert!(text.contains("Streaming: false"), "{text}");
}
#[test]
fn test_task_render_text_lists_artifacts_and_resume_hint() {
let mut task = make_fixture_task(
"task-art",
"ctx-art",
TaskState::InputRequired,
"which region?",
);
task.artifacts = Some(vec![
Artifact {
artifact_id: "art-1".to_string(),
name: Some("summary".to_string()),
description: None,
parts: vec![Part::text("all clear")],
metadata: None,
extensions: None,
},
Artifact {
artifact_id: "art-2".to_string(),
name: None,
description: None,
parts: vec![Part::data(serde_json::json!({"rows": 3}))],
metadata: None,
extensions: None,
},
]);
let text = task.render_text();
assert!(text.contains("Artifact: summary"), "{text}");
assert!(text.contains("all clear"), "{text}");
assert!(text.contains("Artifact: art-2"), "{text}");
assert!(text.contains("\"rows\": 3"), "{text}");
assert!(
text.contains("Resume with: a2acli send --task-id task-art \"<reply>\""),
"{text}"
);
}
#[test]
fn test_task_state_label_covers_every_state() {
let cases = [
(TaskState::Unspecified, "UNSPECIFIED"),
(TaskState::Submitted, "SUBMITTED"),
(TaskState::Working, "WORKING"),
(TaskState::Completed, "COMPLETED"),
(TaskState::Failed, "FAILED"),
(TaskState::Canceled, "CANCELED"),
(TaskState::InputRequired, "INPUT_REQUIRED"),
(TaskState::Rejected, "REJECTED"),
(TaskState::AuthRequired, "AUTH_REQUIRED"),
];
for (state, expected) in cases {
assert_eq!(task_state_label(&state), expected);
assert!(!expected.starts_with("TASK_STATE_"));
}
}
#[test]
fn test_list_tasks_render_text_empty_and_paged() {
let empty = ListTasksResponse {
tasks: vec![],
next_page_token: "next-token".to_string(),
page_size: 10,
total_size: 0,
};
let text = empty.render_text();
assert!(text.contains("Total: 0"), "{text}");
assert!(text.contains("Page Size: 10"), "{text}");
assert!(text.contains("Next Page Token: next-token"), "{text}");
assert!(text.contains("Tasks: (none)"), "{text}");
let populated = ListTasksResponse {
tasks: vec![make_fixture_task(
"task-1",
"ctx-1",
TaskState::Completed,
"done",
)],
next_page_token: String::new(),
page_size: 10,
total_size: 1,
};
let text = populated.render_text();
assert!(!text.contains("Next Page Token"), "{text}");
assert!(text.contains("Task:"), "{text}");
assert!(text.contains("Task ID: task-1"), "{text}");
}
#[test]
fn test_push_config_render_text_includes_token_and_auth_scheme() {
let config = TaskPushNotificationConfig {
url: "https://example.com/hook".to_string(),
id: Some("cfg-1".to_string()),
task_id: "task-1".to_string(),
token: Some("tok-1".to_string()),
authentication: Some(AuthenticationInfo {
scheme: "Bearer".to_string(),
credentials: Some("super-secret".to_string()),
}),
tenant: None,
};
let text = config.render_text();
assert!(text.contains("Config ID: cfg-1"), "{text}");
assert!(text.contains("Task ID: task-1"), "{text}");
assert!(text.contains("URL: https://example.com/hook"), "{text}");
assert!(text.contains("Token: tok-1"), "{text}");
assert!(text.contains("Auth Scheme: Bearer"), "{text}");
assert!(!text.contains("super-secret"), "{text}");
}
#[test]
fn test_list_push_configs_render_text_empty_and_paged() {
let empty = ListTaskPushNotificationConfigsResponse {
configs: vec![],
next_page_token: Some("next-token".to_string()),
};
let text = empty.render_text();
assert!(text.contains("Next Page Token: next-token"), "{text}");
assert!(text.contains("Configs: (none)"), "{text}");
let populated = ListTaskPushNotificationConfigsResponse {
configs: vec![TaskPushNotificationConfig {
url: "https://example.com/hook".to_string(),
id: Some("cfg-1".to_string()),
task_id: "task-1".to_string(),
token: None,
authentication: None,
tenant: None,
}],
next_page_token: None,
};
let text = populated.render_text();
assert!(!text.contains("Next Page Token"), "{text}");
assert!(text.contains("Push Config:"), "{text}");
assert!(text.contains("Config ID: cfg-1"), "{text}");
}
#[test]
fn test_push_config_deleted_render_text() {
let deleted = PushConfigDeleted {
deleted: true,
task_id: "task-1".to_string(),
id: "cfg-1".to_string(),
};
let text = deleted.render_text();
assert!(text.contains("Deleted: true"), "{text}");
assert!(text.contains("Task ID: task-1"), "{text}");
assert!(text.contains("Config ID: cfg-1"), "{text}");
}
#[test]
fn test_stream_response_render_text_covers_update_events() {
let status = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: "task-s".to_string(),
context_id: "ctx-s".to_string(),
status: TaskStatus {
state: TaskState::AuthRequired,
message: None,
timestamp: None,
},
metadata: None,
});
let text = status.render_text();
assert!(text.contains("Task ID: task-s"), "{text}");
assert!(text.contains("Context ID: ctx-s"), "{text}");
assert!(text.contains("State: AUTH_REQUIRED"), "{text}");
assert!(
text.contains("Resume with: a2acli send --task-id task-s \"<reply>\""),
"{text}"
);
let working = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: "task-s".to_string(),
context_id: "ctx-s".to_string(),
status: TaskStatus {
state: TaskState::Working,
message: None,
timestamp: None,
},
metadata: None,
});
assert!(!working.render_text().contains("Resume with"));
let artifact = StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
task_id: "task-s".to_string(),
context_id: "ctx-s".to_string(),
artifact: Artifact {
artifact_id: "art-9".to_string(),
name: None,
description: None,
parts: vec![Part::text("chunk one")],
metadata: None,
extensions: None,
},
append: None,
last_chunk: None,
metadata: None,
});
let text = artifact.render_text();
assert!(text.contains("Task ID: task-s"), "{text}");
assert!(text.contains("Artifact: art-9"), "{text}");
assert!(text.contains("chunk one"), "{text}");
let task = make_fixture_task("task-s", "ctx-s", TaskState::Completed, "done");
assert_eq!(
StreamResponse::Task(task.clone()).render_text(),
task.render_text()
);
let message = Message::new(Role::Agent, vec![Part::text("interim")]);
assert_eq!(
StreamResponse::Message(message.clone()).render_text(),
message.render_text()
);
}
fn text_cli() -> Cli {
parse_with_matches(&["task", "get", "unused"]).0
}
#[tokio::test]
async fn test_consume_stream_text_mode_does_not_serialize() {
let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
consume_stream(make_test_client(None), stream, &text_cli())
.await
.unwrap();
}
#[test]
fn test_parse_data_part_json_rejects_malformed_content() {
let error = parse_data_part_json("{not json", "payload.json").unwrap_err();
assert_eq!(error.exit_code(), 2);
let message = error.to_string();
assert!(message.contains("payload.json"), "{message}");
assert!(matches!(error, CliError::InvalidInput(_)));
}
struct CardOnlyServer {
base_url: String,
handle: tokio::task::JoinHandle<()>,
}
impl Drop for CardOnlyServer {
fn drop(&mut self) {
self.handle.abort();
}
}
impl CardOnlyServer {
async fn spawn(status: axum::http::StatusCode, body: &'static str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let app = Router::new().route(
WELL_KNOWN_AGENT_CARD_PATH,
get(move || async move {
(
status,
[(header::CONTENT_TYPE, "application/json")],
body.to_string(),
)
}),
);
let handle = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
CardOnlyServer { base_url, handle }
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_card_fetch_failures_are_classified_per_appendix_d() {
let not_found = CardOnlyServer::spawn(axum::http::StatusCode::NOT_FOUND, "{}").await;
let error = run_test_cli(¬_found.base_url, &["card", "get"])
.await
.unwrap_err();
assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
assert_eq!(error.exit_code(), 3);
assert!(error.envelope().error.hint.is_some());
for status in [
axum::http::StatusCode::UNAUTHORIZED,
axum::http::StatusCode::FORBIDDEN,
] {
let denied = CardOnlyServer::spawn(status, "{}").await;
let error = run_test_cli(&denied.base_url, &["card", "get"])
.await
.unwrap_err();
assert_eq!(
error.envelope().error.code,
"A2ACLI_ERR_AUTH_FAILED",
"status {status}"
);
assert_eq!(error.exit_code(), 4, "status {status}");
}
let invalid =
CardOnlyServer::spawn(axum::http::StatusCode::OK, r#"{"not":"a card"}"#).await;
let error = run_test_cli(&invalid.base_url, &["card", "get"])
.await
.unwrap_err();
assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
assert_eq!(error.exit_code(), 1);
let base_url = unused_base_url().await;
let error = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
assert_eq!(error.envelope().error.code, "A2ACLI_ERR_UNREACHABLE");
assert_eq!(error.exit_code(), 3);
}
#[test]
fn test_parse_dotenv_basic() {
let parsed = parse_dotenv("A2ACLI_BEARER=secret\nA2ACLI_TRANSPORT=jsonrpc,rest\n");
assert_eq!(
parsed,
vec![
("A2ACLI_BEARER".to_string(), "secret".to_string()),
("A2ACLI_TRANSPORT".to_string(), "jsonrpc,rest".to_string()),
]
);
}
#[test]
fn test_parse_dotenv_ignores_blank_lines_and_comments() {
let parsed = parse_dotenv("\n# a comment\n \nA2ACLI_TENANT=acme\n# another comment\n");
assert_eq!(
parsed,
vec![("A2ACLI_TENANT".to_string(), "acme".to_string())]
);
}
#[test]
fn test_parse_dotenv_tolerates_export_prefix() {
let parsed = parse_dotenv("export A2ACLI_BASE_URL=https://agent.example.com\n");
assert_eq!(
parsed,
vec![(
"A2ACLI_BASE_URL".to_string(),
"https://agent.example.com".to_string()
)]
);
}
#[test]
fn test_parse_dotenv_strips_one_layer_of_quotes() {
let parsed = parse_dotenv("A2ACLI_BEARER=\"Bearer token\"\nA2ACLI_TENANT='acme'\n");
assert_eq!(
parsed,
vec![
("A2ACLI_BEARER".to_string(), "Bearer token".to_string()),
("A2ACLI_TENANT".to_string(), "acme".to_string()),
]
);
}
#[test]
fn test_find_flag_value_supports_space_and_equals_forms() {
let space = vec![
OsString::from("a2acli"),
OsString::from("--config"),
OsString::from("prod.env"),
];
assert_eq!(
find_flag_value(&space, "--config"),
Some("prod.env".to_string())
);
let equals = vec![
OsString::from("a2acli"),
OsString::from("--config=prod.env"),
];
assert_eq!(
find_flag_value(&equals, "--config"),
Some("prod.env".to_string())
);
let absent = vec![OsString::from("a2acli"), OsString::from("send")];
assert_eq!(find_flag_value(&absent, "--config"), None);
}
#[test]
fn test_config_source_labels() {
assert_eq!(ConfigSource::Flag.label(), "flag");
assert_eq!(ConfigSource::EnvVar.label(), "environment variable");
assert_eq!(ConfigSource::LocalFile.label(), "local .env file");
assert_eq!(ConfigSource::GlobalFile.label(), "global .env file");
assert_eq!(ConfigSource::Default.label(), "built-in default");
}
#[test]
fn test_redact_secret() {
assert_eq!(
redact_secret(&Some("secret".to_string())),
"(set, redacted)"
);
assert_eq!(redact_secret(&None), "(not set)");
}
#[test]
fn test_normalize_card_reference_host_forms() {
let cases = [
(
"example.com",
"https://example.com/.well-known/agent-card.json",
),
(
"http://example.com",
"http://example.com/.well-known/agent-card.json",
),
(
"https://example.com/",
"https://example.com/.well-known/agent-card.json",
),
(
"localhost:3000",
"http://localhost:3000/.well-known/agent-card.json",
),
(
"127.0.0.1:8080",
"http://127.0.0.1:8080/.well-known/agent-card.json",
),
(
"[::1]:9000",
"http://[::1]:9000/.well-known/agent-card.json",
),
(
"https://example.com/custom/card.json",
"https://example.com/custom/card.json",
),
(
"http://example.com/agents/a/card",
"http://example.com/agents/a/card",
),
];
for (reference, expected) in cases {
assert_eq!(
normalize_card_reference(reference),
CardReference::Url(expected.to_string()),
"reference {reference}"
);
}
}
#[test]
fn test_normalize_card_reference_file_forms() {
assert_eq!(
normalize_card_reference("file:///tmp/card.json"),
CardReference::File(PathBuf::from("/tmp/card.json"))
);
for reference in ["/no/such/card.json", "./card.json", "../card.json"] {
assert_eq!(
normalize_card_reference(reference),
CardReference::File(PathBuf::from(reference)),
"reference {reference}"
);
}
}
#[test]
fn test_windows_path_shapes_are_recognized_as_files() {
for reference in [
r"C:\Users\me\card.json",
r"c:/Users/me/card.json",
r"\\server\share\card.json",
r".\card.json",
r"..\card.json",
] {
assert!(looks_like_file_path(reference), "{reference}");
assert_eq!(
normalize_card_reference(reference),
CardReference::File(PathBuf::from(reference)),
"{reference}"
);
}
for reference in ["localhost:3000", "a:3000", "example.com:443"] {
assert!(!has_windows_drive_prefix(reference), "{reference}");
assert!(!looks_like_file_path(reference), "{reference}");
}
assert_eq!(
normalize_card_reference("file:///C:/Users/me/card.json"),
CardReference::File(PathBuf::from("C:/Users/me/card.json"))
);
assert_eq!(
normalize_card_reference("file:///tmp/card.json"),
CardReference::File(PathBuf::from("/tmp/card.json"))
);
}
#[test]
fn test_normalize_card_reference_prefers_an_existing_file_over_a_host() {
let dir = std::env::temp_dir().join(format!("a2acli-norm-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("example.com");
std::fs::write(&path, "{}").unwrap();
let previous = std::env::current_dir().unwrap();
std::env::set_current_dir(&dir).unwrap();
let resolved = normalize_card_reference("example.com");
std::env::set_current_dir(previous).unwrap();
assert_eq!(resolved, CardReference::File(PathBuf::from("example.com")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_is_loopback_host() {
for host in [
"localhost",
"localhost:3000",
"127.0.0.1",
"127.0.0.1:80",
"::1",
"[::1]:9000",
] {
assert!(is_loopback_host(host), "expected loopback: {host}");
}
for host in [
"example.com",
"example.com:443",
"8.8.8.8",
"2606:4700::1111",
] {
assert!(!is_loopback_host(host), "expected non-loopback: {host}");
}
}
#[test]
fn test_card_file_errors_map_to_appendix_d_codes() {
let missing = CliError::CardFile {
path: "/no/such/card.json".to_string(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
};
assert_eq!(missing.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
assert_eq!(missing.exit_code(), 3);
let invalid = CliError::CardInvalid("card.json: missing field `name`".to_string());
assert_eq!(invalid.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
assert_eq!(invalid.exit_code(), 1);
}
#[test]
fn test_resolve_agent_selection_matrix() {
let (cli, matches) = parse_with_matches(&["--agent-card", "example.com", "card", "get"]);
assert_eq!(
resolve_agent_selection(&cli, &matches).unwrap(),
AgentSelection::Card("example.com".to_string())
);
let (cli, matches) = parse_with_matches(&["card", "get"]);
assert_eq!(
resolve_agent_selection(&cli, &matches).unwrap(),
AgentSelection::Card("http://localhost:3000".to_string())
);
let (cli, matches) = parse_with_matches(&["--base-url", "http://host:1", "card", "get"]);
assert_eq!(
resolve_agent_selection(&cli, &matches).unwrap(),
AgentSelection::Card("http://host:1".to_string())
);
let (cli, matches) = parse_with_matches(&[
"--endpoint",
"http://host/jsonrpc",
"--transport",
"jsonrpc",
"card",
"get",
]);
assert_eq!(
resolve_agent_selection(&cli, &matches).unwrap(),
AgentSelection::Endpoint {
url: "http://host/jsonrpc".to_string(),
binding: Binding::Jsonrpc,
}
);
for transports in [vec![], vec!["jsonrpc", "rest"]] {
let mut args = vec!["--endpoint", "http://host/jsonrpc"];
for transport in &transports {
args.push("--transport");
args.push(transport);
}
args.extend(["card", "get"]);
let (cli, matches) = parse_with_matches(&args);
let error = resolve_agent_selection(&cli, &matches).unwrap_err();
assert_eq!(
error.exit_code(),
2,
"with {} transport(s)",
transports.len()
);
assert!(error.to_string().contains("exactly one --transport"));
}
let (cli, matches) = parse_with_matches(&[
"--agent-card",
"example.com",
"--endpoint",
"http://host/jsonrpc",
"--transport",
"jsonrpc",
"card",
"get",
]);
let error = resolve_agent_selection(&cli, &matches).unwrap_err();
assert_eq!(error.exit_code(), 2);
assert!(error.to_string().contains("mutually exclusive"));
}
#[test]
fn test_synthesized_endpoint_card_carries_only_the_named_interface() {
let card = synthesized_endpoint_card("http://host/rest", Binding::Rest);
assert_eq!(card.supported_interfaces.len(), 1);
assert_eq!(card.supported_interfaces[0].url, "http://host/rest");
assert_eq!(
card.supported_interfaces[0].protocol_binding,
Binding::Rest.protocol()
);
assert!(card.description.contains("no agent card was resolved"));
assert_eq!(card.capabilities.streaming, None);
assert!(card.skills.is_empty());
}
#[test]
fn test_parse_protocol_version() {
assert_eq!(parse_protocol_version("1"), Some((1, 0)));
assert_eq!(parse_protocol_version("1.0"), Some((1, 0)));
assert_eq!(parse_protocol_version("1.2"), Some((1, 2)));
assert_eq!(parse_protocol_version("1.0.2"), Some((1, 0)));
assert_eq!(parse_protocol_version(" 1.1 "), Some((1, 1)));
assert_eq!(parse_protocol_version("nonsense"), None);
assert_eq!(parse_protocol_version(""), None);
assert_eq!(parse_protocol_version("1.x"), None);
}
#[test]
fn test_validate_a2a_version() {
for accepted in [None, Some("1"), Some("1.0"), Some("1.7")] {
assert!(
validate_a2a_version(accepted).is_ok(),
"should accept {accepted:?}"
);
}
for rejected in ["0.3", "0.9", "2.0", "nonsense", ""] {
let error = validate_a2a_version(Some(rejected)).unwrap_err();
assert_eq!(error.exit_code(), 2, "rejecting {rejected}");
assert_eq!(error.envelope().error.code, "A2ACLI_ERR_USAGE");
}
let error = validate_a2a_version(Some("0.3")).unwrap_err();
assert!(error.to_string().contains("must be 1.x"), "{error}");
}
#[test]
fn test_negotiate_a2a_version_uses_an_explicit_flag_verbatim() {
let negotiated = negotiate_a2a_version(Some("1.0"), "1.0", (1, 0));
assert_eq!(negotiated.version, "1.0");
assert!(negotiated.note.is_none());
let negotiated = negotiate_a2a_version(Some("1.3"), "1.0", (1, 0));
assert_eq!(negotiated.version, "1.3");
assert!(negotiated.note.unwrap().contains("1.3"));
}
#[test]
fn test_negotiate_a2a_version_takes_the_highest_shared_1x() {
let negotiated = negotiate_a2a_version(None, "1.1", (1, 3));
assert_eq!(negotiated.version, "1.1");
assert!(negotiated.note.unwrap().contains("negotiated"));
let negotiated = negotiate_a2a_version(None, "1.5", (1, 3));
assert_eq!(negotiated.version, "1.3");
assert!(negotiated.note.is_none());
let negotiated = negotiate_a2a_version(None, "1.3", (1, 3));
assert_eq!(negotiated.version, "1.3");
assert!(negotiated.note.is_none());
}
#[test]
fn test_negotiate_a2a_version_never_goes_below_the_1x_floor() {
for declared in ["0.3", "0.9", "2.0"] {
let negotiated = negotiate_a2a_version(None, declared, (1, 0));
assert_eq!(negotiated.version, "1.0", "declared {declared}");
let note = negotiated.note.unwrap_or_default();
assert!(note.contains("outside 1.x"), "declared {declared}: {note}");
}
let negotiated = negotiate_a2a_version(None, "not-a-version", (1, 0));
assert_eq!(negotiated.version, "1.0");
assert!(negotiated.note.unwrap().contains("unparseable"));
}
#[test]
fn test_task_outcome_note_names_only_non_success_and_paused_states() {
for (state, expected) in [
(TaskState::Failed, "FAILED"),
(TaskState::Rejected, "REJECTED"),
(TaskState::InputRequired, "INPUT_REQUIRED"),
(TaskState::AuthRequired, "AUTH_REQUIRED"),
] {
let note =
task_outcome_note(&state).unwrap_or_else(|| panic!("{state:?} should be named"));
assert!(note.contains(expected), "{state:?} -> {note}");
}
for state in [
TaskState::Completed,
TaskState::Canceled,
TaskState::Submitted,
TaskState::Working,
TaskState::Unspecified,
] {
assert!(
task_outcome_note(&state).is_none(),
"{state:?} should stay silent"
);
}
}
#[test]
fn test_task_outcome_is_read_from_the_reported_value() {
let message = Message::new(Role::Agent, vec![Part::text("hi")]);
assert!(
task_outcome_note(&TaskState::Completed).is_none(),
"sanity: COMPLETED is silent"
);
SendMessageResponse::Message(message.clone()).warn_outcome();
StreamResponse::Message(message).warn_outcome();
let task = make_fixture_task("t-1", "c-1", TaskState::Failed, "broke");
assert_eq!(
task_outcome_note(&task.status.state),
Some("the agent reported it FAILED")
);
SendMessageResponse::Task(task.clone()).warn_outcome();
StreamResponse::Task(task).warn_outcome();
StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
task_id: "t-2".to_string(),
context_id: "c-2".to_string(),
status: TaskStatus {
state: TaskState::AuthRequired,
message: None,
timestamp: None,
},
metadata: None,
})
.warn_outcome();
}
#[test]
fn test_flatten_clap_message() {
let rendered = "error: unexpected argument '--nope' found\n\nUsage: a2acli [OPTIONS]\n";
assert_eq!(
flatten_clap_message(rendered),
"unexpected argument '--nope' found"
);
let rendered = "error: the following required arguments were not provided:\n <ID>\n\nUsage: a2acli task get <ID>\n";
assert_eq!(
flatten_clap_message(rendered),
"the following required arguments were not provided: <ID>"
);
assert_eq!(flatten_clap_message("error: bare"), "bare");
assert_eq!(flatten_clap_message("\n\nUsage: a2acli"), "Usage: a2acli");
}
#[test]
fn test_usage_error_maps_to_the_appendix_d_usage_code() {
let error = CliError::Usage("unexpected argument '--nope' found".to_string());
assert_eq!(error.exit_code(), 2);
let envelope = error.envelope();
assert_eq!(envelope.error.code, "A2ACLI_ERR_USAGE");
assert_eq!(envelope.error.message, "unexpected argument '--nope' found");
assert!(envelope.error.hint.unwrap().contains("--help"));
}
}