Skip to main content

a2acli/
lib.rs

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