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