Skip to main content

a2acli/
lib.rs

1// Copyright AGNTCY Contributors (https://github.com/agntcy)
2// Copyright A2A Contributors (https://github.com/a2aproject)
3// SPDX-License-Identifier: Apache-2.0
4pub mod card_schema;
5
6use std::collections::{HashMap, HashSet};
7use std::ffi::OsString;
8use std::io::IsTerminal;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::Duration;
12
13use a2a::*;
14use a2a_client::auth::AuthInterceptor;
15use a2a_client::{A2AClient, A2AClientFactory, BoxStream};
16use clap::parser::ValueSource;
17use clap::{ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
18use futures::StreamExt;
19use reqwest::{Client, RequestBuilder};
20use serde::Serialize;
21use serde_json::Value;
22use thiserror::Error;
23
24#[derive(Debug, Clone, Parser, PartialEq, Eq)]
25#[command(
26    name = "a2acli",
27    version,
28    about = "Standalone A2A client CLI",
29    after_help = "\
30Default behavior (SPEC.md §6.5):
31  - Transport: the agent card's first supported interface (--transport overrides)
32  - Task completion: wait until terminal/interrupted state (--async returns immediately)
33  - Output: human-readable text (-o/--output json switches to protocol JSON)
34  - Protocol version: highest shared 1.x, from the agent card (--a2a-version pins it)
35  - Transport security: TLS verification on (--insecure disables it, with a warning)"
36)]
37pub struct Cli {
38    /// Load configuration from an explicit .env file in place of the local
39    /// .env (found by walking up from the working directory). Real
40    /// environment variables still take precedence over it (§6.5).
41    #[arg(long, global = true)]
42    pub config: Option<String>,
43
44    /// The agent to talk to, as an Agent Card reference (§7.2): a bare host
45    /// or origin (the well-known path is appended), a full card URL (used
46    /// as-is), or a local file path (`file://…` or a plain path).
47    #[arg(
48        short = 'a',
49        long = "agent-card",
50        global = true,
51        value_name = "REF",
52        env = "A2ACLI_AGENT_CARD"
53    )]
54    pub agent_card: Option<String>,
55
56    /// Connect straight to an agent interface URL, skipping Agent Card
57    /// resolution (§7.2). MUST be used with exactly one --transport, since
58    /// there is no card to name the binding. Mutually exclusive with
59    /// --agent-card.
60    #[arg(
61        short = 'e',
62        long,
63        global = true,
64        value_name = "REF",
65        env = "A2ACLI_ENDPOINT"
66    )]
67    pub endpoint: Option<String>,
68
69    /// Deprecated alias for the bare-origin form of --agent-card, kept so
70    /// pre-#178 invocations keep working. Hidden: `--agent-card` is the
71    /// flag the specification defines.
72    #[arg(
73        long,
74        global = true,
75        hide = true,
76        default_value = "http://localhost:3000",
77        env = "A2ACLI_BASE_URL"
78    )]
79    pub base_url: String,
80
81    /// Client transport preference, repeatable and ordered (highest first):
82    /// e.g. `--transport jsonrpc --transport rest`. Overrides the agent
83    /// card's own preference order; a binding the card doesn't offer is
84    /// skipped.
85    #[arg(
86        long,
87        global = true,
88        value_enum,
89        env = "A2ACLI_TRANSPORT",
90        value_delimiter = ','
91    )]
92    pub transport: Vec<Binding>,
93
94    /// Bearer token attached to the agent-card fetch and client calls.
95    /// Protocol version to signal to the server on every request (§13.2).
96    /// Absent this flag the version is negotiated down to the highest one
97    /// both a2acli and the agent's selected interface declare. Must be
98    /// 1.x: A2A reads an empty or pre-1.0 value as 0.3.
99    #[arg(long = "a2a-version", global = true, env = "A2ACLI_A2A_VERSION")]
100    pub a2a_version: Option<String>,
101
102    #[arg(long, global = true, env = "A2ACLI_BEARER")]
103    pub bearer: Option<String>,
104
105    /// API key attached to the agent-card fetch and client calls (as an
106    /// `X-API-Key` header — a2acli does not yet read the agent card's
107    /// declared security scheme to place it elsewhere per A2A §4.5.2).
108    #[arg(long = "api-key", global = true, env = "A2ACLI_API_KEY")]
109    pub api_key: Option<String>,
110
111    /// Add an A2A service parameter (a transport-level key-value pair, e.g.
112    /// a header or gRPC metadata) — general-purpose, not authentication-
113    /// specific. Distinct from `--metadata`, which travels in the request
114    /// payload.
115    #[arg(long = "svc-param", global = true, value_parser = parse_header)]
116    pub svc_params: Vec<HeaderArg>,
117
118    /// Disable TLS certificate verification for the negotiated transport.
119    /// Development only — always prints a warning, and never disables
120    /// verification silently.
121    #[arg(long, global = true, env = "A2ACLI_INSECURE")]
122    pub insecure: bool,
123
124    /// Verbose diagnostics to stderr: the outcome of each call plus the raw
125    /// protocol messages exchanged on the wire — request and response
126    /// bodies, and each streamed event (§7.2). Never includes credential
127    /// material (bearer token, API key, or any --svc-param value): header
128    /// names stay visible so their attachment can be confirmed, but values
129    /// are replaced, and that redaction cannot be defeated by this or any
130    /// other verbosity flag.
131    #[arg(long, global = true, env = "A2ACLI_DEBUG")]
132    pub debug: bool,
133
134    /// Optional tenant forwarded to A2A requests that support it. Overrides
135    /// the routing tenant the selected Agent Card interface may itself
136    /// declare (A2A §8.3.2); omit this to use the interface's own value,
137    /// if it has one.
138    #[arg(long, global = true, env = "A2ACLI_TENANT")]
139    pub tenant: Option<String>,
140
141    /// Output format: human-readable `text` (default) or the protocol's own
142    /// `json` types. Cardinality (one document vs. JSONL) follows `--stream`,
143    /// not this flag (§11.3).
144    #[arg(
145        short = 'o',
146        long,
147        global = true,
148        value_enum,
149        default_value_t = OutputFormat::Text,
150        env = "A2ACLI_OUTPUT"
151    )]
152    pub output: OutputFormat,
153
154    /// With `-o json` (and no `--stream`), emit compact JSON instead of
155    /// pretty-printed JSON. Has no effect on `-o text` or on `--stream`
156    /// JSONL, which is always one compact object per line regardless of
157    /// this flag (§11.3).
158    #[arg(long, global = true, env = "A2ACLI_COMPACT")]
159    pub compact: bool,
160
161    /// Do not wait: return the task identifiers immediately instead of blocking
162    /// until the task reaches a terminal or interrupted state (the default).
163    ///
164    /// Note: the spec lists `--return-immediately` as an OPTIONAL alias for
165    /// this flag, but that name is already this CLI's flag for the distinct
166    /// protocol-level `SendMessageConfiguration.return_immediately` (a
167    /// request to the *agent* to return early on queued work, not a
168    /// client-side "don't poll" instruction) — so it is deliberately not
169    /// reused here to avoid conflating the two.
170    #[arg(
171        long = "async",
172        alias = "no-wait",
173        global = true,
174        conflicts_with = "wait",
175        env = "A2ACLI_ASYNC"
176    )]
177    pub async_mode: bool,
178
179    /// Explicitly block until the task reaches a terminal or interrupted state.
180    /// This is already the default for `send`; on `task get` it turns the
181    /// one-shot read into a poll loop.
182    #[arg(
183        long,
184        global = true,
185        conflicts_with = "async_mode",
186        env = "A2ACLI_WAIT"
187    )]
188    pub wait: bool,
189
190    /// Delay between polls while waiting for a task to settle (e.g. "2s", "500ms").
191    #[arg(long, global = true, value_parser = parse_duration, default_value = "2s", env = "A2ACLI_POLL_INTERVAL")]
192    pub poll_interval: Duration,
193
194    /// Overall time budget for a blocking wait before reporting a timeout (e.g. "30s", "2m").
195    #[arg(long, global = true, value_parser = parse_duration, default_value = "30s", env = "A2ACLI_TIMEOUT")]
196    pub timeout: Duration,
197
198    #[command(subcommand)]
199    pub command: Command,
200}
201
202#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
203pub enum Command {
204    /// Agent Card operations.
205    Card {
206        #[command(subcommand)]
207        command: CardCommand,
208    },
209    /// Configuration inspection.
210    Config {
211        #[command(subcommand)]
212        command: ConfigCommand,
213    },
214    /// Send a message to start or continue an interaction.
215    Send(MessageCommand),
216    /// Task operations.
217    Task {
218        #[command(subcommand)]
219        command: TaskCommand,
220    },
221    /// Emit a shell completion script for the named shell on stdout (§7.1).
222    Completion {
223        /// Shell to emit the script for. An unrecognised name is a usage
224        /// error naming the accepted values.
225        #[arg(value_name = "SHELL")]
226        shell: clap_complete::Shell,
227    },
228}
229
230#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
231pub enum CardCommand {
232    /// Fetch and print the Agent Card. Pass --extended for the authenticated extended card.
233    Get(CardGetCommand),
234}
235
236#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
237pub enum ConfigCommand {
238    /// Print each effective setting and the source it resolved from
239    /// (flag, environment variable, local .env file, global .env file, or
240    /// built-in default). Read-only: credential values are redacted, and
241    /// this command never edits configuration itself (§8.3).
242    Show,
243}
244
245#[derive(Debug, Clone, Args, PartialEq, Eq)]
246pub struct CardGetCommand {
247    /// Fetch the authenticated extended agent card instead of the public one.
248    #[arg(long)]
249    pub extended: bool,
250
251    /// Validate the fetched card against the A2A JSON schema (§10.1) and
252    /// report every violation, rather than only the type check
253    /// deserialization already performs on every `card get`.
254    #[arg(long)]
255    pub validate: bool,
256}
257
258#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
259pub enum TaskCommand {
260    /// Fetch a task by ID.
261    Get(TaskLookupCommand),
262    /// List tasks with optional filters.
263    List(ListTasksCommand),
264    /// Cancel a task by ID.
265    Cancel(TaskIdCommand),
266    /// Subscribe to task updates and print each event as it arrives.
267    Subscribe(TaskIdCommand),
268    /// Manage push notification configs for a task.
269    PushConfig {
270        #[command(subcommand)]
271        command: PushConfigCommand,
272    },
273}
274
275#[derive(Debug, Clone, Args, PartialEq, Eq)]
276pub struct MessageCommand {
277    /// Text payload to send as the user message. Shorthand for a single
278    /// --text-part when no other part flags are given; mutually exclusive
279    /// with --text-part/--file-part/--data-part.
280    pub text: Option<String>,
281
282    /// Add a text part. Repeatable and order-preserving alongside
283    /// --file-part/--data-part.
284    #[arg(long = "text-part")]
285    pub text_parts: Vec<String>,
286
287    /// Add a file part. A local path is inlined as bytes; a URL is carried
288    /// by reference and never fetched by the CLI. Repeatable.
289    #[arg(long = "file-part")]
290    pub file_parts: Vec<String>,
291
292    /// Add a structured JSON data part, read from a file path, parsed from
293    /// an inline JSON string, or "-" to read JSON from stdin. Repeatable.
294    #[arg(long = "data-part")]
295    pub data_parts: Vec<String>,
296
297    /// Media type for the part flag immediately preceding it (--file-part or
298    /// --data-part). Usage error if it follows no part flag.
299    #[arg(long = "media-type")]
300    pub media_types: Vec<String>,
301
302    /// Optional context identifier to continue an existing conversation.
303    #[arg(long, env = "A2ACLI_CONTEXT_ID")]
304    pub context_id: Option<String>,
305
306    /// Optional task identifier to continue an existing task.
307    #[arg(long, env = "A2ACLI_TASK_ID")]
308    pub task_id: Option<String>,
309
310    /// Ask the server to include up to this many history items in task responses.
311    #[arg(long)]
312    pub history_length: Option<i32>,
313
314    /// Accepted output mode, for example text/plain or application/json.
315    #[arg(long = "accept-output")]
316    pub accepted_output_modes: Vec<String>,
317
318    /// Ask the server to return immediately when it supports queued work.
319    #[arg(long)]
320    pub return_immediately: bool,
321
322    /// Use the streaming send operation and print each event as it arrives.
323    #[arg(long)]
324    pub stream: bool,
325}
326
327#[derive(Debug, Clone, Args, PartialEq, Eq)]
328pub struct TaskLookupCommand {
329    /// Task identifier.
330    pub id: String,
331
332    /// Ask the server to include up to this many history items.
333    #[arg(long)]
334    pub history_length: Option<i32>,
335}
336
337#[derive(Debug, Clone, Args, PartialEq, Eq)]
338pub struct ListTasksCommand {
339    /// Filter by context identifier.
340    #[arg(long)]
341    pub context_id: Option<String>,
342
343    /// Filter by task state.
344    #[arg(long, value_enum)]
345    pub status: Option<TaskStateArg>,
346
347    /// Requested page size.
348    #[arg(long)]
349    pub page_size: Option<i32>,
350
351    /// Page token from a previous response.
352    #[arg(long)]
353    pub page_token: Option<String>,
354
355    /// Ask the server to include up to this many history items per task.
356    #[arg(long)]
357    pub history_length: Option<i32>,
358
359    /// Ask the server to include artifacts in the listed tasks.
360    #[arg(long)]
361    pub include_artifacts: bool,
362}
363
364#[derive(Debug, Clone, Args, PartialEq, Eq)]
365pub struct TaskIdCommand {
366    /// Task identifier.
367    pub id: String,
368}
369
370#[derive(Debug, Clone, Subcommand, PartialEq, Eq)]
371pub enum PushConfigCommand {
372    /// Create a push notification config for a task.
373    Create(CreatePushConfigCommand),
374    /// Fetch a push notification config by ID.
375    Get(PushConfigIdCommand),
376    /// List push notification configs for a task.
377    List(ListPushConfigsCommand),
378    /// Delete a push notification config by ID.
379    Delete(PushConfigIdCommand),
380}
381
382#[derive(Debug, Clone, Args, PartialEq, Eq)]
383pub struct CreatePushConfigCommand {
384    /// Task identifier.
385    pub task_id: String,
386
387    /// Callback URL that will receive push notifications.
388    pub url: String,
389
390    /// Optional push config identifier.
391    #[arg(long = "config-id")]
392    pub config_id: Option<String>,
393
394    /// Optional push notification token.
395    #[arg(long)]
396    pub token: Option<String>,
397
398    /// Optional authentication scheme, for example Bearer.
399    #[arg(long = "auth-scheme")]
400    pub auth_scheme: Option<String>,
401
402    /// Optional authentication credentials.
403    #[arg(long = "auth-credentials")]
404    pub auth_credentials: Option<String>,
405}
406
407#[derive(Debug, Clone, Args, PartialEq, Eq)]
408pub struct PushConfigIdCommand {
409    /// Task identifier.
410    pub task_id: String,
411
412    /// Push config identifier.
413    pub id: String,
414}
415
416#[derive(Debug, Clone, Args, PartialEq, Eq)]
417pub struct ListPushConfigsCommand {
418    /// Task identifier.
419    pub task_id: String,
420
421    /// Requested page size.
422    #[arg(long)]
423    pub page_size: Option<i32>,
424
425    /// Page token from a previous response.
426    #[arg(long)]
427    pub page_token: Option<String>,
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct HeaderArg {
432    pub name: String,
433    pub value: String,
434}
435
436/// Output format (§6.5, §11.2, §11.3). `Text` is the default, human-readable
437/// floor; `Json` emits the protocol's own response types, one document or
438/// JSONL depending on `--stream`.
439#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
440pub enum OutputFormat {
441    Text,
442    Json,
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
446pub enum Binding {
447    Jsonrpc,
448    /// The A2A HTTP+JSON binding — spelled `rest` on the command line to
449    /// match the reference implementation's `--transport` vocabulary.
450    #[value(name = "rest")]
451    Rest,
452}
453
454impl Binding {
455    fn protocol(self) -> &'static str {
456        match self {
457            Binding::Jsonrpc => TRANSPORT_PROTOCOL_JSONRPC,
458            Binding::Rest => TRANSPORT_PROTOCOL_HTTP_JSON,
459        }
460    }
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
464pub enum TaskStateArg {
465    Unspecified,
466    Submitted,
467    Working,
468    Completed,
469    Failed,
470    Canceled,
471    InputRequired,
472    Rejected,
473    AuthRequired,
474}
475
476impl From<TaskStateArg> for TaskState {
477    fn from(value: TaskStateArg) -> Self {
478        match value {
479            TaskStateArg::Unspecified => TaskState::Unspecified,
480            TaskStateArg::Submitted => TaskState::Submitted,
481            TaskStateArg::Working => TaskState::Working,
482            TaskStateArg::Completed => TaskState::Completed,
483            TaskStateArg::Failed => TaskState::Failed,
484            TaskStateArg::Canceled => TaskState::Canceled,
485            TaskStateArg::InputRequired => TaskState::InputRequired,
486            TaskStateArg::Rejected => TaskState::Rejected,
487            TaskStateArg::AuthRequired => TaskState::AuthRequired,
488        }
489    }
490}
491
492#[derive(Debug, Error)]
493pub enum CliError {
494    #[error(transparent)]
495    A2A(#[from] A2AError),
496    #[error("http request failed: {0}")]
497    Http(#[from] reqwest::Error),
498    #[error("failed to serialize output: {0}")]
499    Json(#[from] serde_json::Error),
500    #[error("invalid input: {0}")]
501    InvalidInput(String),
502    #[error("failed to read {path}: {source}")]
503    ReadFile {
504        path: String,
505        #[source]
506        source: std::io::Error,
507    },
508    #[error("timed out after {timeout:?} waiting for task {task_id} to settle")]
509    Timeout { task_id: String, timeout: Duration },
510    /// A local Agent Card file that couldn't be read — the file-path
511    /// counterpart of a non-2xx card fetch.
512    #[error("failed to read agent card file {path}: {source}")]
513    CardFile {
514        path: String,
515        #[source]
516        source: std::io::Error,
517    },
518    /// A local Agent Card file that was read but isn't an Agent Card — the
519    /// counterpart of a response body that won't deserialize.
520    #[error("agent card file is not a valid agent card: {0}")]
521    CardInvalid(String),
522    /// `card get --validate`: the card deserialized fine (so `CardInvalid`
523    /// does not apply) but fails the A2A JSON schema (§10.1) — distinct
524    /// from a type-check failure, and carrying every violation rather than
525    /// only the first.
526    #[error("agent card failed schema validation ({} violation(s))", violations.len())]
527    CardSchemaInvalid {
528        violations: Vec<card_schema::Violation>,
529    },
530    /// A malformed invocation, as clap describes it. §11.4 counts a
531    /// malformed flag among the CLI-local failures that must still be
532    /// machine-readable, so clap's prose becomes the envelope's message
533    /// rather than being printed in its place.
534    #[error("{0}")]
535    Usage(String),
536}
537
538/// A2A §5.3: where an agent publishes its card, appended to a reference
539/// that names only a host or origin.
540const WELL_KNOWN_AGENT_CARD_PATH: &str = "/.well-known/agent-card.json";
541
542/// The Appendix B error envelope: the one result shape this specification
543/// defines of its own, for a failure that never reached the protocol.
544#[derive(Debug, Clone, Serialize)]
545struct ErrorEnvelope {
546    error: ErrorDetail,
547}
548
549#[derive(Debug, Clone, Serialize)]
550struct ErrorDetail {
551    code: String,
552    message: String,
553    #[serde(skip_serializing_if = "Option::is_none")]
554    hint: Option<String>,
555    #[serde(rename = "a2aCode", skip_serializing_if = "Option::is_none")]
556    a2a_code: Option<i64>,
557    /// Every schema violation on `CardSchemaInvalid`; absent otherwise. Its
558    /// own field rather than folded into `message`, so `-o json` can walk
559    /// each violation's path without parsing prose.
560    #[serde(skip_serializing_if = "Option::is_none")]
561    details: Option<Vec<card_schema::Violation>>,
562}
563
564impl CliError {
565    /// The exit status this failure maps to (§11.6, Appendix D). `0` is
566    /// never returned here — success never constructs a `CliError`.
567    pub fn exit_code(&self) -> i32 {
568        match self {
569            CliError::A2A(_) => 1,
570            CliError::Http(error) => http_error_exit_code(error),
571            CliError::Json(_) => 1,
572            CliError::InvalidInput(_) => 2,
573            CliError::ReadFile { .. } => 2,
574            CliError::Timeout { .. } => 5,
575            // Appendix D: the same statuses the HTTP card path uses, so a
576            // caller branching on the exit code needn't know whether the
577            // card came from a URL or a file.
578            CliError::CardFile { .. } => 3,
579            CliError::CardInvalid(_) => 1,
580            CliError::CardSchemaInvalid { .. } => 1,
581            CliError::Usage(_) => 2,
582        }
583    }
584
585    /// The Appendix B error envelope for this failure: a protocol failure
586    /// carries the A2A error name unchanged (§11.4); a CLI-local failure
587    /// carries an `A2ACLI_ERR_*` symbol from Appendix D.
588    fn envelope(&self) -> ErrorEnvelope {
589        let (code, hint, a2a_code) = match self {
590            CliError::A2A(error) => (
591                error_reason(error.code).to_string(),
592                None,
593                Some(error.code as i64),
594            ),
595            CliError::Http(error) => http_error_code_and_hint(error),
596            CliError::Json(_) => ("A2ACLI_ERR_INTERNAL".to_string(), None, None),
597            CliError::InvalidInput(_) => ("A2ACLI_ERR_USAGE".to_string(), None, None),
598            CliError::ReadFile { .. } => (
599                "A2ACLI_ERR_USAGE".to_string(),
600                Some(
601                    "check that the --file-part/--data-part path exists and is readable"
602                        .to_string(),
603                ),
604                None,
605            ),
606            CliError::CardFile { .. } => (
607                "A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
608                Some("check that the --agent-card path exists and is readable".to_string()),
609                None,
610            ),
611            CliError::Usage(_) => (
612                "A2ACLI_ERR_USAGE".to_string(),
613                Some("run `a2acli --help`, or `a2acli <command> --help`".to_string()),
614                None,
615            ),
616            CliError::CardInvalid(_) => (
617                "A2ACLI_ERR_CARD_INVALID".to_string(),
618                Some("the agent card did not match the expected schema".to_string()),
619                None,
620            ),
621            CliError::CardSchemaInvalid { .. } => (
622                "A2ACLI_ERR_CARD_INVALID".to_string(),
623                Some(format!(
624                    "against the A2A JSON schema, version {}; see `details`",
625                    card_schema::SCHEMA_A2A_VERSION
626                )),
627                None,
628            ),
629            CliError::Timeout { .. } => (
630                "A2ACLI_ERR_TIMEOUT".to_string(),
631                Some(
632                    "increase --timeout/--poll-interval, or check the task later with `task get <id> --wait`"
633                        .to_string(),
634                ),
635                None,
636            ),
637        };
638
639        let details = match self {
640            CliError::CardSchemaInvalid { violations } => Some(violations.clone()),
641            _ => None,
642        };
643
644        ErrorEnvelope {
645            error: ErrorDetail {
646                code,
647                message: self.to_string(),
648                hint,
649                a2a_code,
650                details,
651            },
652        }
653    }
654
655    /// Print the Appendix B error envelope to stderr as one compact JSON
656    /// line, in every output mode: §11.4 requires errors to be
657    /// machine-readable unconditionally, not only under `-o json`.
658    pub fn report(&self) {
659        match serde_json::to_string(&self.envelope()) {
660            Ok(json) => eprintln!("{json}"),
661            Err(_) => eprintln!(
662                "{{\"error\":{{\"code\":\"A2ACLI_ERR_INTERNAL\",\"message\":{:?}}}}}",
663                self.to_string()
664            ),
665        }
666    }
667}
668
669/// `reqwest::Error` from resolving the Agent Card (the only place it's
670/// produced) classified against Appendix D: a connection/DNS/TLS failure is
671/// `UNREACHABLE`; a non-2xx response is `CARD_NOT_FOUND` (or `AUTH_FAILED`
672/// for 401/403); a body that failed to deserialize as an `AgentCard` is
673/// `CARD_INVALID`.
674fn http_error_code_and_hint(error: &reqwest::Error) -> (String, Option<String>, Option<i64>) {
675    if error.is_decode() {
676        return (
677            "A2ACLI_ERR_CARD_INVALID".to_string(),
678            Some("the agent card did not match the expected schema".to_string()),
679            None,
680        );
681    }
682
683    if let Some(status) = error.status() {
684        if status.as_u16() == 401 || status.as_u16() == 403 {
685            return (
686                "A2ACLI_ERR_AUTH_FAILED".to_string(),
687                Some("credentials were rejected fetching the agent card".to_string()),
688                None,
689            );
690        }
691        return (
692            "A2ACLI_ERR_CARD_NOT_FOUND".to_string(),
693            Some("check --agent-card/--base-url resolves to a valid agent".to_string()),
694            None,
695        );
696    }
697
698    (
699        "A2ACLI_ERR_UNREACHABLE".to_string(),
700        Some("check --agent-card/--endpoint and network connectivity".to_string()),
701        None,
702    )
703}
704
705fn http_error_exit_code(error: &reqwest::Error) -> i32 {
706    if error.is_decode() {
707        return 1;
708    }
709    if let Some(status) = error.status() {
710        return if status.as_u16() == 401 || status.as_u16() == 403 {
711            4
712        } else {
713            3
714        };
715    }
716    3
717}
718
719/// Parse `args` the same way the real binary parses `std::env::args_os()`, then
720/// run the resulting command. Kept separate from [`run`] because recovering
721/// the interleaved order of `send`'s repeatable part flags
722/// (`--text-part`/`--file-part`/`--data-part`/`--media-type`) needs the raw
723/// [`ArgMatches`], which `Cli::parse()` alone discards.
724pub async fn run_args<I, T>(args: I) -> Result<(), CliError>
725where
726    I: IntoIterator<Item = T>,
727    T: Into<OsString>,
728{
729    let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
730    // Must happen before parsing: this is how a local/global .env value
731    // becomes visible to clap's own `env = "A2ACLI_..."` resolution (§8.3).
732    let dotenv_provenance = load_dotenv_config(&args);
733    // `try_get_matches_from` rather than `get_matches_from`: the latter
734    // prints clap's prose and exits inside clap, which §11.4 forbids for a
735    // malformed flag — that is a CLI-local failure and must still be
736    // machine-readable.
737    let matches = match Cli::command().try_get_matches_from(args) {
738        Ok(matches) => matches,
739        Err(error) => return Err(clap_error_to_cli_error(error)),
740    };
741    let cli = Cli::from_arg_matches(&matches).expect("matches were produced by Cli::command()");
742    run(cli, &matches, &dotenv_provenance).await
743}
744
745/// clap reports `--help` and `--version` through the same `Err` channel as a
746/// parse failure, so they have to be told apart: those are successful
747/// requests for output (`A2ACLI_CLI_001`) and keep going to stdout with exit
748/// `0`, while everything else is a usage error carrying the Appendix B
749/// envelope.
750fn clap_error_to_cli_error(error: clap::Error) -> CliError {
751    use clap::error::ErrorKind;
752
753    if matches!(
754        error.kind(),
755        ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
756    ) {
757        // Not failures: the caller asked for this output (A2ACLI_CLI_001).
758        // Delegating to clap's own `exit()` keeps them byte-for-byte what
759        // `get_matches_from` produced, since that is just
760        // `try_get_matches_from(..).unwrap_or_else(|e| e.exit())`.
761        error.exit();
762    }
763
764    // `DisplayHelpOnMissingArgumentOrSubcommand` — a bare `a2acli`, or a
765    // group like `a2acli task` with no subcommand — is *not* in
766    // A2ACLI_CLI_001's list of output requests: clap already treats it as a
767    // usage failure and exits 2. So it carries the envelope like every
768    // other usage error rather than being the one that prints prose, and
769    // the hint points at `--help` for the usage text it replaces.
770
771    CliError::Usage(flatten_clap_message(&error.render().to_string()))
772}
773
774/// clap renders a multi-line message: the error line, a blank line, then a
775/// usage block. Keep the part that names what was wrong, as one line, since
776/// §11.4 wants a single JSON object and the usage block is guidance the
777/// `hint` already points at.
778fn flatten_clap_message(rendered: &str) -> String {
779    // Each line is trimmed on both sides before joining: clap indents
780    // continuation lines, and keeping that indentation would leave runs of
781    // spaces inside the JSON message.
782    let first_block: Vec<&str> = rendered
783        .lines()
784        .map(str::trim)
785        .take_while(|line| !line.is_empty())
786        .collect();
787    let joined = first_block.join(" ");
788    let message = if joined.is_empty() {
789        rendered.trim()
790    } else {
791        joined.as_str()
792    };
793    message
794        .strip_prefix("error: ")
795        .unwrap_or(message)
796        .to_string()
797}
798
799pub async fn run(
800    cli: Cli,
801    matches: &ArgMatches,
802    dotenv_provenance: &HashMap<String, ConfigSource>,
803) -> Result<(), CliError> {
804    if cli.debug {
805        // --debug: verbose diagnostics to stderr (§7.2). try_init() rather
806        // than init() because a process embedding `run` more than once
807        // (e.g. this crate's own in-process tests) would otherwise panic
808        // on a second global-subscriber install.
809        let _ = tracing_subscriber::fmt()
810            .with_writer(std::io::stderr)
811            // DEBUG for this project's crates, because the raw wire
812            // messages §7.2 asks for at Tier 2 are emitted at that level —
813            // but not for dependencies, whose connection-pool chatter is
814            // also DEBUG and would bury the protocol exchange it is meant
815            // to show.
816            .with_env_filter(tracing_subscriber::EnvFilter::new(
817                "warn,a2a_client=debug,a2a_cli=debug",
818            ))
819            // Colour only when a person is watching: these diagnostics are
820            // routinely redirected to a file or piped into a grep, and
821            // escape codes there are noise rather than emphasis.
822            .with_ansi(std::io::stderr().is_terminal())
823            .with_target(false)
824            .try_init();
825    }
826
827    // §11.6: a bad flag is reported before any network work, so a usage
828    // error never depends on a reachable agent.
829    validate_a2a_version(cli.a2a_version.as_deref())?;
830
831    match &cli.command {
832        Command::Card { command } => run_card_command(&cli, matches, command).await?,
833        Command::Config { command } => {
834            run_config_command(&cli, matches, dotenv_provenance, command)?
835        }
836        Command::Completion { shell } => {
837            // Deliberately resolves no Agent Card and builds no client: the
838            // command has to work with no agent configured at all. The script
839            // is meant to be redirected to a file or eval'd, so stdout
840            // carries it and nothing else, and -o json does not wrap it
841            // (§11.1).
842            let mut command = Cli::command();
843            let binary = command.get_name().to_string();
844            clap_complete::generate(*shell, &mut command, binary, &mut std::io::stdout());
845        }
846        Command::Send(command) => {
847            let send_matches = matches
848                .subcommand_matches("send")
849                .expect("send subcommand matches present when cli.command is Command::Send");
850            let parts = resolve_message_parts(send_matches, command)?;
851            let ResolvedClient {
852                client,
853                tenant,
854                capabilities,
855            } = resolve_client(&cli, matches).await?;
856            let request = build_send_message_request(command, parts, tenant.clone());
857
858            // §13.3 pre-flight: the card is the contract, so a card that
859            // does not declare streaming settles the question without a
860            // round trip. The caller asked for a result rather than
861            // specifically for a stream, so this takes the same fallback
862            // #173 gave an UNSUPPORTED_OPERATION from the agent
863            // (TASK_POLL_004) instead of failing.
864            let streaming = capabilities.declares(Capability::Streaming);
865            if command.stream && !streaming {
866                eprintln!(
867                    "warning: the agent card does not declare streaming; \
868                     sending without it and polling for the result instead"
869                );
870            }
871
872            if command.stream && streaming {
873                match client.send_streaming_message(&request).await {
874                    Ok(stream) => {
875                        consume_stream(client, stream, &cli).await?;
876                    }
877                    Err(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION => {
878                        // The agent doesn't advertise the streaming
879                        // capability — fall back to a one-shot send plus
880                        // polling rather than hanging (TASK_POLL_004). Any
881                        // *other* error opening the stream is a real
882                        // failure and must be reported as such, not masked
883                        // by a silent retry.
884                        let response = send_and_maybe_wait(
885                            client,
886                            &request,
887                            tenant,
888                            cli.async_mode,
889                            cli.poll_interval,
890                            cli.timeout,
891                        )
892                        .await?;
893                        response.warn_outcome();
894                        print_output(&response, &cli)?;
895                    }
896                    Err(error) => {
897                        let _ = client.destroy().await;
898                        return Err(error.into());
899                    }
900                }
901            } else {
902                let response = send_and_maybe_wait(
903                    client,
904                    &request,
905                    tenant,
906                    cli.async_mode,
907                    cli.poll_interval,
908                    cli.timeout,
909                )
910                .await?;
911                response.warn_outcome();
912                print_output(&response, &cli)?;
913            }
914        }
915        Command::Task { command } => run_task_command(&cli, matches, command).await?,
916    }
917
918    Ok(())
919}
920
921/// Send a message and, unless `--async` was given, block until the resulting
922/// task reaches a terminal or interrupted state (§6.5/§9.3 default-wait
923/// behavior). A `Message`-only response has no task to wait on and is
924/// returned as soon as it arrives. Always destroys `client` before returning.
925async fn send_and_maybe_wait<T: a2a_client::Transport>(
926    client: A2AClient<T>,
927    request: &SendMessageRequest,
928    tenant: Option<String>,
929    async_mode: bool,
930    poll_interval: Duration,
931    timeout: Duration,
932) -> Result<SendMessageResponse, CliError> {
933    let result = client.send_message(request).await;
934    let response = match result {
935        Ok(response) => response,
936        Err(error) => {
937            let _ = client.destroy().await;
938            return Err(error.into());
939        }
940    };
941
942    match response {
943        SendMessageResponse::Task(task) => {
944            let task =
945                settle_task(client, task, !async_mode, tenant, poll_interval, timeout).await?;
946            Ok(SendMessageResponse::Task(task))
947        }
948        SendMessageResponse::Message(message) => {
949            client.destroy().await?;
950            Ok(SendMessageResponse::Message(message))
951        }
952    }
953}
954
955/// If `wait` is true and `task` has not already settled, poll until it
956/// reaches a terminal or interrupted state (or `timeout` expires). Destroys
957/// `client` before returning either way.
958async fn settle_task<T: a2a_client::Transport>(
959    client: A2AClient<T>,
960    task: Task,
961    wait: bool,
962    tenant: Option<String>,
963    poll_interval: Duration,
964    timeout: Duration,
965) -> Result<Task, CliError> {
966    let outcome = if wait && !is_settled(&task.status.state) {
967        wait_for_task_settled(&client, &task.id, tenant, poll_interval, timeout).await
968    } else {
969        Ok(task)
970    };
971
972    match outcome {
973        Ok(task) => {
974            client.destroy().await?;
975            Ok(task)
976        }
977        Err(error) => {
978            let _ = client.destroy().await;
979            Err(error)
980        }
981    }
982}
983
984/// Poll `task get` until the task reaches a terminal or interrupted state
985/// (§9.1/§9.3), sleeping `poll_interval` between attempts and giving up with
986/// [`CliError::Timeout`] once `timeout` has elapsed. `TASK_STATE_UNSPECIFIED`
987/// is treated as neither terminal nor interrupted, so it keeps polling.
988async fn wait_for_task_settled<T: a2a_client::Transport>(
989    client: &A2AClient<T>,
990    task_id: &str,
991    tenant: Option<String>,
992    poll_interval: Duration,
993    timeout: Duration,
994) -> Result<Task, CliError> {
995    let deadline = tokio::time::Instant::now() + timeout;
996    loop {
997        let task = client
998            .get_task(&GetTaskRequest {
999                id: task_id.to_string(),
1000                history_length: None,
1001                tenant: tenant.clone(),
1002            })
1003            .await?;
1004
1005        if is_settled(&task.status.state) {
1006            return Ok(task);
1007        }
1008
1009        let now = tokio::time::Instant::now();
1010        if now >= deadline {
1011            return Err(CliError::Timeout {
1012                task_id: task_id.to_string(),
1013                timeout,
1014            });
1015        }
1016
1017        tokio::time::sleep(poll_interval.min(deadline.saturating_duration_since(now))).await;
1018    }
1019}
1020
1021/// §6.6/§11.6: a turn the CLI conducted and reported exits `0` even when the
1022/// agent did not succeed, so the exit status alone cannot tell a caller
1023/// that. Name a non-success or paused outcome on stderr instead — in every
1024/// output mode, like the error envelope (§11.4), and without touching
1025/// stdout, which stays the payload (§11.1).
1026///
1027/// The four states named are exactly the ones `A2ACLI_EXIT_002` enumerates.
1028/// `CANCELED` is deliberately not among them: after `task cancel` it is the
1029/// outcome the caller asked for, so warning about it would be noise.
1030fn task_outcome_note(state: &TaskState) -> Option<&'static str> {
1031    match state {
1032        TaskState::Failed => Some("the agent reported it FAILED"),
1033        TaskState::Rejected => Some("the agent REJECTED it"),
1034        TaskState::InputRequired => {
1035            Some("it is paused at INPUT_REQUIRED and needs a reply to continue")
1036        }
1037        TaskState::AuthRequired => {
1038            Some("it is paused at AUTH_REQUIRED and needs credentials to continue")
1039        }
1040        _ => None,
1041    }
1042}
1043
1044fn warn_task_outcome(task_id: &str, state: &TaskState) {
1045    if let Some(note) = task_outcome_note(state) {
1046        eprintln!("warning: task {task_id}: {note}");
1047    }
1048}
1049
1050/// Something the CLI reports that may carry a task outcome. A trait rather
1051/// than a call at each print site so a new reporting path cannot quietly
1052/// skip the warning — it has to satisfy the bound.
1053trait TaskOutcome {
1054    fn warn_outcome(&self);
1055}
1056
1057impl TaskOutcome for Task {
1058    fn warn_outcome(&self) {
1059        warn_task_outcome(&self.id, &self.status.state);
1060    }
1061}
1062
1063impl TaskOutcome for SendMessageResponse {
1064    fn warn_outcome(&self) {
1065        match self {
1066            SendMessageResponse::Task(task) => task.warn_outcome(),
1067            // A `Message`-only reply created no task, so there is no task
1068            // outcome to report (SEND_003).
1069            SendMessageResponse::Message(_) => {}
1070        }
1071    }
1072}
1073
1074impl TaskOutcome for StreamResponse {
1075    fn warn_outcome(&self) {
1076        match self {
1077            StreamResponse::Task(task) => task.warn_outcome(),
1078            StreamResponse::StatusUpdate(event) => {
1079                warn_task_outcome(&event.task_id, &event.status.state);
1080            }
1081            StreamResponse::Message(_) | StreamResponse::ArtifactUpdate(_) => {}
1082        }
1083    }
1084}
1085
1086/// A task in a terminal (`COMPLETED`/`FAILED`/`CANCELED`/`REJECTED`) or
1087/// interrupted (`INPUT_REQUIRED`/`AUTH_REQUIRED`) state needs no more polling.
1088fn is_settled(state: &TaskState) -> bool {
1089    state.is_terminal() || matches!(state, TaskState::InputRequired | TaskState::AuthRequired)
1090}
1091
1092async fn run_card_command(
1093    cli: &Cli,
1094    matches: &ArgMatches,
1095    command: &CardCommand,
1096) -> Result<(), CliError> {
1097    match command {
1098        CardCommand::Get(command) => {
1099            if command.extended {
1100                let ResolvedClient {
1101                    client,
1102                    tenant,
1103                    capabilities,
1104                } = resolve_client(cli, matches).await?;
1105                // No equivalent path exists for an extended card, so this
1106                // fails with the reason instead of asking anyway.
1107                capabilities.require(Capability::ExtendedCard)?;
1108                let result = client
1109                    .get_extended_agent_card(&GetExtendedAgentCardRequest { tenant })
1110                    .await;
1111                let card = finish_client_call(client, result).await?;
1112                if command.validate {
1113                    // Best effort: get_extended_agent_card comes back
1114                    // through a2a-client's typed protojson pipeline, which
1115                    // has already discarded anything the schema would flag
1116                    // as an unrecognised property, the same way AgentCard's
1117                    // own Deserialize would. Re-serializing the typed value
1118                    // still catches a wrong type or a bad enum value; it
1119                    // cannot catch that one shape of violation on this path.
1120                    validate_card_or_fail(&serde_json::to_value(&card)?)?;
1121                }
1122                print_output(&card, cli)?;
1123            } else {
1124                let (card, raw) = resolve_agent_card_with_raw(cli, matches).await?;
1125                if command.validate {
1126                    validate_card_or_fail(&raw)?;
1127                }
1128                print_output(&card, cli)?;
1129            }
1130        }
1131    }
1132
1133    Ok(())
1134}
1135
1136/// `card get --validate` (§10.1, `A2ACLI_CARD_GET_002`). Every violation is
1137/// collected before failing, rather than stopping at the first — a card with
1138/// three problems should take one run to diagnose.
1139fn validate_card_or_fail(card: &Value) -> Result<(), CliError> {
1140    let violations = card_schema::validate_agent_card(card)
1141        .map_err(|error| CliError::CardInvalid(format!("schema could not be loaded: {error}")))?;
1142    if violations.is_empty() {
1143        Ok(())
1144    } else {
1145        Err(CliError::CardSchemaInvalid { violations })
1146    }
1147}
1148
1149async fn run_task_command(
1150    cli: &Cli,
1151    matches: &ArgMatches,
1152    command: &TaskCommand,
1153) -> Result<(), CliError> {
1154    match command {
1155        TaskCommand::Get(command) => {
1156            let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
1157            let result = client
1158                .get_task(&GetTaskRequest {
1159                    id: command.id.clone(),
1160                    history_length: command.history_length,
1161                    tenant: tenant.clone(),
1162                })
1163                .await;
1164            let task = match result {
1165                Ok(task) => task,
1166                Err(error) => {
1167                    let _ = client.destroy().await;
1168                    return Err(error.into());
1169                }
1170            };
1171            let task = settle_task(
1172                client,
1173                task,
1174                cli.wait,
1175                tenant,
1176                cli.poll_interval,
1177                cli.timeout,
1178            )
1179            .await?;
1180            task.warn_outcome();
1181            print_output(&task, cli)?;
1182        }
1183        TaskCommand::List(command) => {
1184            let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
1185            let result = client
1186                .list_tasks(&ListTasksRequest {
1187                    context_id: command.context_id.clone(),
1188                    status: command.status.map(TaskState::from),
1189                    page_size: command.page_size,
1190                    page_token: command.page_token.clone(),
1191                    history_length: command.history_length,
1192                    status_timestamp_after: None,
1193                    include_artifacts: command.include_artifacts.then_some(true),
1194                    tenant,
1195                })
1196                .await;
1197            let response = finish_client_call(client, result).await?;
1198            print_output(&response, cli)?;
1199        }
1200        TaskCommand::Cancel(command) => {
1201            let ResolvedClient { client, tenant, .. } = resolve_client(cli, matches).await?;
1202            let result = client
1203                .cancel_task(&CancelTaskRequest {
1204                    id: command.id.clone(),
1205                    metadata: None,
1206                    tenant,
1207                })
1208                .await;
1209            let task = finish_client_call(client, result).await?;
1210            task.warn_outcome();
1211            print_output(&task, cli)?;
1212        }
1213        TaskCommand::Subscribe(command) => {
1214            let ResolvedClient {
1215                client,
1216                tenant,
1217                capabilities,
1218            } = resolve_client(cli, matches).await?;
1219            // Unlike `send --stream`, subscribing *is* the request: there is
1220            // no non-streaming way to satisfy it, so an undeclared
1221            // capability is a failure rather than a fallback.
1222            capabilities.require(Capability::Streaming)?;
1223            let stream = client
1224                .subscribe_to_task(&SubscribeToTaskRequest {
1225                    id: command.id.clone(),
1226                    tenant: tenant.clone(),
1227                })
1228                .await?;
1229            subscribe_with_resumption(client, stream, command.id.clone(), tenant, cli).await?;
1230        }
1231        TaskCommand::PushConfig { command } => {
1232            run_push_config_command(cli, matches, command).await?;
1233        }
1234    }
1235
1236    Ok(())
1237}
1238
1239/// Reconstruct `send`'s message parts in the order the caller typed them,
1240/// binding each `--media-type` to the part flag immediately preceding it.
1241///
1242/// `clap`'s derived `MessageCommand` struct only exposes each repeatable flag
1243/// (`--text-part`, `--file-part`, `--data-part`, `--media-type`) as its own
1244/// `Vec<String>`, which loses the relative order *across* flags. Recovering
1245/// that order needs the raw [`ArgMatches`] for the `send` subcommand:
1246/// `indices_of` reports, for a given arg id, the token index of each value in
1247/// the original command line, which sorts back into the order the caller
1248/// wrote them (§10.2: "the part flags are repeatable and order-preserving").
1249fn resolve_message_parts(
1250    matches: &ArgMatches,
1251    command: &MessageCommand,
1252) -> Result<Vec<Part>, CliError> {
1253    enum Kind {
1254        Text,
1255        File,
1256        Data,
1257    }
1258    enum Token<'a> {
1259        Part(Kind, &'a str),
1260        MediaType(&'a str),
1261    }
1262
1263    let mut tokens: Vec<(usize, Token<'_>)> = Vec::new();
1264    if let Some(indices) = matches.indices_of("text_parts") {
1265        tokens.extend(
1266            indices
1267                .zip(command.text_parts.iter())
1268                .map(|(index, value)| (index, Token::Part(Kind::Text, value.as_str()))),
1269        );
1270    }
1271    if let Some(indices) = matches.indices_of("file_parts") {
1272        tokens.extend(
1273            indices
1274                .zip(command.file_parts.iter())
1275                .map(|(index, value)| (index, Token::Part(Kind::File, value.as_str()))),
1276        );
1277    }
1278    if let Some(indices) = matches.indices_of("data_parts") {
1279        tokens.extend(
1280            indices
1281                .zip(command.data_parts.iter())
1282                .map(|(index, value)| (index, Token::Part(Kind::Data, value.as_str()))),
1283        );
1284    }
1285
1286    if !command.media_types.is_empty() && tokens.is_empty() {
1287        // A lone --media-type with no other part flag at all must not be
1288        // silently dropped by the "no part flags given" branch below.
1289        return Err(CliError::InvalidInput(
1290            "--media-type must immediately follow a --text-part/--file-part/--data-part"
1291                .to_string(),
1292        ));
1293    }
1294
1295    if tokens.is_empty() {
1296        // No part flags given: the positional text (if any) is shorthand for
1297        // a single text part. A message with no content at all is a usage
1298        // error, not a silently empty parts array.
1299        return match command.text.as_deref() {
1300            Some(text) => Ok(vec![Part::text(text)]),
1301            None => Err(CliError::InvalidInput(
1302                "message must have at least one part: pass text, or use \
1303                 --text-part/--file-part/--data-part"
1304                    .to_string(),
1305            )),
1306        };
1307    }
1308
1309    if command.text.is_some() {
1310        return Err(CliError::InvalidInput(
1311            "the positional message text cannot be combined with \
1312             --text-part/--file-part/--data-part; pass it as --text-part instead"
1313                .to_string(),
1314        ));
1315    }
1316
1317    if let Some(indices) = matches.indices_of("media_types") {
1318        tokens.extend(
1319            indices
1320                .zip(command.media_types.iter())
1321                .map(|(index, value)| (index, Token::MediaType(value.as_str()))),
1322        );
1323    }
1324    tokens.sort_by_key(|(index, _)| *index);
1325
1326    let mut parts: Vec<Part> = Vec::new();
1327    for (_, token) in tokens {
1328        match token {
1329            Token::Part(kind, value) => {
1330                let part = match kind {
1331                    Kind::Text => Part::text(value),
1332                    Kind::File => build_file_part(value)?,
1333                    Kind::Data => build_data_part(value)?,
1334                };
1335                parts.push(part);
1336            }
1337            Token::MediaType(media_type) => match parts.last_mut() {
1338                Some(part) => part.media_type = Some(media_type.to_string()),
1339                None => {
1340                    return Err(CliError::InvalidInput(
1341                        "--media-type must immediately follow a --text-part/--file-part/--data-part"
1342                            .to_string(),
1343                    ));
1344                }
1345            },
1346        }
1347    }
1348
1349    Ok(parts)
1350}
1351
1352/// A local filesystem path becomes an inline, base64-encoded file part; a URL
1353/// is carried by reference and never fetched by the CLI (§10.2).
1354fn build_file_part(value: &str) -> Result<Part, CliError> {
1355    if value.contains("://") {
1356        return Ok(Part::url(value));
1357    }
1358
1359    let bytes = std::fs::read(value).map_err(|source| CliError::ReadFile {
1360        path: value.to_string(),
1361        source,
1362    })?;
1363    let mut part = Part::raw(bytes);
1364    part.filename = std::path::Path::new(value)
1365        .file_name()
1366        .map(|name| name.to_string_lossy().into_owned());
1367    Ok(part)
1368}
1369
1370/// `--data-part <path|->` reads JSON from a file path or, for `-`, from
1371/// stdin; anything else is parsed as an inline JSON string (§10.2).
1372/// Whether a `read_to_string` failure means "this string doesn't name a
1373/// readable file here" — so `--data-part` should go on to try parsing it as
1374/// inline JSON — rather than "a real file failed to read", which must be
1375/// reported.
1376///
1377/// This can't just test for `NotFound`: Windows rejects a path containing
1378/// characters it doesn't allow (`{`, `"`, `:` — precisely what inline JSON
1379/// looks like) with `InvalidFilename` *before* ever looking for the file, so
1380/// matching only `NotFound` would turn every inline-JSON `--data-part` into
1381/// a read error there while working fine on Unix, where those are all legal
1382/// filename characters.
1383fn means_not_a_readable_path(kind: std::io::ErrorKind) -> bool {
1384    matches!(
1385        kind,
1386        std::io::ErrorKind::NotFound
1387            | std::io::ErrorKind::InvalidFilename
1388            | std::io::ErrorKind::InvalidInput
1389    )
1390}
1391
1392fn build_data_part(value: &str) -> Result<Part, CliError> {
1393    if value == "-" {
1394        use std::io::Read;
1395        let mut buffer = String::new();
1396        std::io::stdin()
1397            .read_to_string(&mut buffer)
1398            .map_err(|source| CliError::ReadFile {
1399                path: "<stdin>".to_string(),
1400                source,
1401            })?;
1402        return parse_data_part_json(&buffer, "<stdin>");
1403    }
1404
1405    match std::fs::read_to_string(value) {
1406        Ok(text) => return parse_data_part_json(&text, value),
1407        Err(source) if !means_not_a_readable_path(source.kind()) => {
1408            // A real file that couldn't be read (permission denied, a
1409            // directory, invalid UTF-8, ...) — that's a failure to
1410            // surface, not a signal to fall back to inline-JSON parsing.
1411            return Err(CliError::ReadFile {
1412                path: value.to_string(),
1413                source,
1414            });
1415        }
1416        Err(_) => {} // doesn't name a readable file — try `value` as JSON below
1417    }
1418
1419    serde_json::from_str(value).map(Part::data).map_err(|_| {
1420        CliError::InvalidInput(format!(
1421            "--data-part must be a file path, \"-\" for stdin, or inline JSON: {value}"
1422        ))
1423    })
1424}
1425
1426/// Parse `text` (the content of a `--data-part` file or stdin) as JSON,
1427/// mapping a parse failure to a usage error naming `source` rather than the
1428/// generic internal `CliError::Json` — the caller supplied this content, so
1429/// a malformed payload is their input to fix, not the tool's own failure.
1430fn parse_data_part_json(text: &str, source: &str) -> Result<Part, CliError> {
1431    serde_json::from_str(text).map(Part::data).map_err(|error| {
1432        CliError::InvalidInput(format!("--data-part: invalid JSON in {source}: {error}"))
1433    })
1434}
1435
1436/// Parse a duration like "2s", "500ms", "1m", or a bare number of seconds.
1437fn parse_duration(input: &str) -> Result<Duration, String> {
1438    let trimmed = input.trim();
1439    let (magnitude, unit) = if let Some(value) = trimmed.strip_suffix("ms") {
1440        (value, "ms")
1441    } else if let Some(value) = trimmed.strip_suffix('s') {
1442        (value, "s")
1443    } else if let Some(value) = trimmed.strip_suffix('m') {
1444        (value, "m")
1445    } else {
1446        (trimmed, "s")
1447    };
1448
1449    let magnitude: f64 = magnitude
1450        .trim()
1451        .parse()
1452        .map_err(|_| format!("invalid duration: {input}"))?;
1453    if magnitude < 0.0 || !magnitude.is_finite() {
1454        return Err(format!("duration must be a non-negative number: {input}"));
1455    }
1456
1457    let seconds = match unit {
1458        "ms" => magnitude / 1000.0,
1459        "m" => magnitude * 60.0,
1460        _ => magnitude,
1461    };
1462    Ok(Duration::from_secs_f64(seconds))
1463}
1464
1465fn build_send_message_request(
1466    command: &MessageCommand,
1467    parts: Vec<Part>,
1468    tenant: Option<String>,
1469) -> SendMessageRequest {
1470    let mut message = Message::new(Role::User, parts);
1471    message.context_id = command.context_id.clone();
1472    message.task_id = command.task_id.clone();
1473
1474    let configuration = if command.history_length.is_some()
1475        || !command.accepted_output_modes.is_empty()
1476        || command.return_immediately
1477    {
1478        Some(SendMessageConfiguration {
1479            accepted_output_modes: (!command.accepted_output_modes.is_empty())
1480                .then_some(command.accepted_output_modes.clone()),
1481            task_push_notification_config: None,
1482            history_length: command.history_length,
1483            return_immediately: command.return_immediately.then_some(true),
1484        })
1485    } else {
1486        None
1487    };
1488
1489    SendMessageRequest {
1490        message,
1491        configuration,
1492        metadata: None,
1493        tenant,
1494    }
1495}
1496
1497fn build_push_notification_config(
1498    command: &CreatePushConfigCommand,
1499) -> Result<TaskPushNotificationConfig, CliError> {
1500    if command.auth_credentials.is_some() && command.auth_scheme.is_none() {
1501        return Err(CliError::InvalidInput(
1502            "--auth-credentials requires --auth-scheme".to_string(),
1503        ));
1504    }
1505
1506    let authentication = command
1507        .auth_scheme
1508        .clone()
1509        .map(|scheme| AuthenticationInfo {
1510            scheme,
1511            credentials: command.auth_credentials.clone(),
1512        });
1513
1514    Ok(TaskPushNotificationConfig {
1515        task_id: String::new(),
1516        url: command.url.clone(),
1517        id: command.config_id.clone(),
1518        token: command.token.clone(),
1519        authentication,
1520        tenant: None,
1521    })
1522}
1523
1524/// Every push-notification subcommand is gated on the card declaring
1525/// `pushNotifications` (§13.3). None of them has a non-push equivalent, so
1526/// an undeclared capability fails with the reason rather than spending a
1527/// round trip to be told the same thing less clearly.
1528async fn run_push_config_command(
1529    cli: &Cli,
1530    matches: &ArgMatches,
1531    command: &PushConfigCommand,
1532) -> Result<(), CliError> {
1533    match command {
1534        PushConfigCommand::Create(command) => {
1535            let ResolvedClient {
1536                client,
1537                tenant,
1538                capabilities,
1539            } = resolve_client(cli, matches).await?;
1540            capabilities.require(Capability::PushNotifications)?;
1541            let mut config = build_push_notification_config(command)?;
1542            config.task_id = command.task_id.clone();
1543            config.tenant = tenant;
1544            let result = client.create_push_config(&config).await;
1545            let response = finish_client_call(client, result).await?;
1546            print_output(&response, cli)?;
1547        }
1548        PushConfigCommand::Get(command) => {
1549            let ResolvedClient {
1550                client,
1551                tenant,
1552                capabilities,
1553            } = resolve_client(cli, matches).await?;
1554            capabilities.require(Capability::PushNotifications)?;
1555            let result = client
1556                .get_push_config(&GetTaskPushNotificationConfigRequest {
1557                    task_id: command.task_id.clone(),
1558                    id: command.id.clone(),
1559                    tenant,
1560                })
1561                .await;
1562            let response = finish_client_call(client, result).await?;
1563            print_output(&response, cli)?;
1564        }
1565        PushConfigCommand::List(command) => {
1566            let ResolvedClient {
1567                client,
1568                tenant,
1569                capabilities,
1570            } = resolve_client(cli, matches).await?;
1571            capabilities.require(Capability::PushNotifications)?;
1572            let result = client
1573                .list_push_configs(&ListTaskPushNotificationConfigsRequest {
1574                    task_id: command.task_id.clone(),
1575                    page_size: command.page_size,
1576                    page_token: command.page_token.clone(),
1577                    tenant,
1578                })
1579                .await;
1580            let response = finish_client_call(client, result).await?;
1581            print_output(&response, cli)?;
1582        }
1583        PushConfigCommand::Delete(command) => {
1584            let ResolvedClient {
1585                client,
1586                tenant,
1587                capabilities,
1588            } = resolve_client(cli, matches).await?;
1589            capabilities.require(Capability::PushNotifications)?;
1590            let result = client
1591                .delete_push_config(&DeleteTaskPushNotificationConfigRequest {
1592                    task_id: command.task_id.clone(),
1593                    id: command.id.clone(),
1594                    tenant,
1595                })
1596                .await;
1597            finish_client_call(client, result).await?;
1598            print_output(
1599                &PushConfigDeleted {
1600                    deleted: true,
1601                    task_id: command.task_id.clone(),
1602                    id: command.id.clone(),
1603                },
1604                cli,
1605            )?;
1606        }
1607    }
1608
1609    Ok(())
1610}
1611
1612// ---------------------------------------------------------------------
1613// Configuration (§8.3): .env discovery/precedence and `config show`.
1614// ---------------------------------------------------------------------
1615
1616/// Where an effective setting's value came from (§8.3's precedence order,
1617/// highest first): an explicit flag, a real environment variable, a local
1618/// `.env` file, a global `.env` file, or the tool's built-in default.
1619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1620pub enum ConfigSource {
1621    Flag,
1622    EnvVar,
1623    LocalFile,
1624    GlobalFile,
1625    Default,
1626}
1627
1628impl ConfigSource {
1629    fn label(self) -> &'static str {
1630        match self {
1631            ConfigSource::Flag => "flag",
1632            ConfigSource::EnvVar => "environment variable",
1633            ConfigSource::LocalFile => "local .env file",
1634            ConfigSource::GlobalFile => "global .env file",
1635            ConfigSource::Default => "built-in default",
1636        }
1637    }
1638}
1639
1640/// Load `.env` configuration and inject any `A2ACLI_*` key that isn't
1641/// already a real environment variable, so clap's own `env = "..."`
1642/// resolution on [`Cli`]'s fields sees it — this MUST run before
1643/// `Cli::command().get_matches_from(args)`. Returns which keys were
1644/// injected from which file, for `config show` to report accurately.
1645///
1646/// Precedence (§6.5, §8.3): a real environment variable always wins over
1647/// either file; a local `.env` (or the file named by `--config`) wins over
1648/// the global `~/.config/a2a-cli/.env`.
1649fn load_dotenv_config(args: &[OsString]) -> HashMap<String, ConfigSource> {
1650    // Snapshot which A2ACLI_* keys are *real* environment variables before
1651    // any file-based injection, so a lower-precedence file's value is never
1652    // mistaken for "already set" once a higher-precedence file has run.
1653    let real_env_keys: HashSet<String> = std::env::vars_os()
1654        .filter_map(|(key, _)| key.into_string().ok())
1655        .filter(|key| key.starts_with("A2ACLI_"))
1656        .collect();
1657
1658    let mut provenance = HashMap::new();
1659
1660    if let Some(global_path) = global_config_path() {
1661        apply_dotenv_file(
1662            &global_path,
1663            ConfigSource::GlobalFile,
1664            &real_env_keys,
1665            &mut provenance,
1666        );
1667    }
1668
1669    let local_path = find_flag_value(args, "--config")
1670        .map(PathBuf::from)
1671        .or_else(find_local_dotenv);
1672    if let Some(local_path) = local_path {
1673        apply_dotenv_file(
1674            &local_path,
1675            ConfigSource::LocalFile,
1676            &real_env_keys,
1677            &mut provenance,
1678        );
1679    }
1680
1681    provenance
1682}
1683
1684/// Look for `--flag value` or `--flag=value` in a raw, unparsed argv —
1685/// needed to find `--config` before `Cli` itself has been parsed.
1686fn find_flag_value(args: &[OsString], flag: &str) -> Option<String> {
1687    let prefix = format!("{flag}=");
1688    for (index, arg) in args.iter().enumerate() {
1689        let Some(arg) = arg.to_str() else { continue };
1690        if arg == flag {
1691            return args.get(index + 1)?.to_str().map(str::to_string);
1692        }
1693        if let Some(value) = arg.strip_prefix(&prefix) {
1694            return Some(value.to_string());
1695        }
1696    }
1697    None
1698}
1699
1700/// `~/.config/a2a-cli/.env`, honoring `$XDG_CONFIG_HOME`.
1701fn global_config_path() -> Option<PathBuf> {
1702    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
1703        return Some(PathBuf::from(xdg).join("a2a-cli").join(".env"));
1704    }
1705    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
1706    Some(
1707        PathBuf::from(home)
1708            .join(".config")
1709            .join("a2a-cli")
1710            .join(".env"),
1711    )
1712}
1713
1714/// A local `.env`, discovered the way `git` discovers its configuration:
1715/// walking up from the working directory (§8.3).
1716fn find_local_dotenv() -> Option<PathBuf> {
1717    let mut dir = std::env::current_dir().ok()?;
1718    loop {
1719        let candidate = dir.join(".env");
1720        if candidate.is_file() {
1721            return Some(candidate);
1722        }
1723        if !dir.pop() {
1724            return None;
1725        }
1726    }
1727}
1728
1729fn apply_dotenv_file(
1730    path: &Path,
1731    source: ConfigSource,
1732    real_env_keys: &HashSet<String>,
1733    provenance: &mut HashMap<String, ConfigSource>,
1734) {
1735    let Ok(contents) = std::fs::read_to_string(path) else {
1736        return;
1737    };
1738    warn_if_world_readable(path);
1739
1740    for (key, value) in parse_dotenv(&contents) {
1741        if !key.starts_with("A2ACLI_") || real_env_keys.contains(&key) {
1742            continue;
1743        }
1744        // SAFETY: this runs synchronously, at the very start of `run_args`,
1745        // before any async work or additional threads have been spawned by
1746        // this process — nothing else can be concurrently reading or
1747        // writing the environment at this point.
1748        unsafe {
1749            std::env::set_var(&key, &value);
1750        }
1751        provenance.insert(key, source);
1752    }
1753}
1754
1755/// §8.3: "MUST NOT store secrets in world-readable files". `a2acli` never
1756/// writes a `.env` file itself, but it can at least warn when one it reads
1757/// is readable by users other than its owner.
1758#[cfg(unix)]
1759fn warn_if_world_readable(path: &Path) {
1760    use std::os::unix::fs::PermissionsExt;
1761    let Ok(metadata) = std::fs::metadata(path) else {
1762        return;
1763    };
1764    let mode = metadata.permissions().mode();
1765    if mode & 0o077 != 0 {
1766        eprintln!(
1767            "warning: {} is readable by other users (mode {:o}); it may contain credentials — \
1768             consider `chmod 600 {}`",
1769            path.display(),
1770            mode & 0o777,
1771            path.display()
1772        );
1773    }
1774}
1775
1776#[cfg(not(unix))]
1777fn warn_if_world_readable(_path: &Path) {}
1778
1779/// Parse `.env` (dotenv) syntax: `KEY=value`, one per line. Blank lines and
1780/// `#` comments are ignored, a leading `export ` is tolerated, and one
1781/// layer of surrounding single or double quotes is stripped (§8.3).
1782fn parse_dotenv(contents: &str) -> Vec<(String, String)> {
1783    contents
1784        .lines()
1785        .filter_map(|line| {
1786            let line = line.trim();
1787            if line.is_empty() || line.starts_with('#') {
1788                return None;
1789            }
1790            let line = line.strip_prefix("export ").unwrap_or(line);
1791            let (key, value) = line.split_once('=')?;
1792            let key = key.trim().to_string();
1793            let value = value.trim();
1794            let value = ["\"", "'"]
1795                .iter()
1796                .find_map(|quote| {
1797                    value
1798                        .strip_prefix(quote)
1799                        .and_then(|rest| rest.strip_suffix(quote))
1800                })
1801                .unwrap_or(value);
1802            Some((key, value.to_string()))
1803        })
1804        .collect()
1805}
1806
1807#[derive(Debug, Clone, Serialize)]
1808struct ConfigSetting {
1809    name: String,
1810    value: String,
1811    source: String,
1812}
1813
1814#[derive(Debug, Clone, Serialize)]
1815struct ConfigSettings {
1816    settings: Vec<ConfigSetting>,
1817}
1818
1819impl TextRender for ConfigSettings {
1820    fn render_text(&self) -> String {
1821        let mut out = TextOutput::default();
1822        for setting in &self.settings {
1823            out.field(
1824                &setting.name,
1825                format!("{} (source: {})", setting.value, setting.source),
1826            );
1827        }
1828        out.finish()
1829    }
1830}
1831
1832fn redact_secret(value: &Option<String>) -> String {
1833    match value {
1834        Some(_) => "(set, redacted)".to_string(),
1835        None => "(not set)".to_string(),
1836    }
1837}
1838
1839fn run_config_command(
1840    cli: &Cli,
1841    matches: &ArgMatches,
1842    dotenv_provenance: &HashMap<String, ConfigSource>,
1843    command: &ConfigCommand,
1844) -> Result<(), CliError> {
1845    match command {
1846        ConfigCommand::Show => {
1847            let settings = effective_config_settings(cli, matches, dotenv_provenance);
1848            print_output(&settings, cli)?;
1849        }
1850    }
1851    Ok(())
1852}
1853
1854/// Enumerate every §6.5/§8.3 configurable setting with its current value
1855/// and the source it resolved from, for `config show`.
1856fn effective_config_settings(
1857    cli: &Cli,
1858    matches: &ArgMatches,
1859    dotenv_provenance: &HashMap<String, ConfigSource>,
1860) -> ConfigSettings {
1861    let mut settings = Vec::new();
1862    let mut add = |id: &str, name: &str, value: String, env_key: &str| {
1863        let source = match matches.value_source(id) {
1864            Some(ValueSource::CommandLine) => ConfigSource::Flag,
1865            Some(ValueSource::EnvVariable) => dotenv_provenance
1866                .get(env_key)
1867                .copied()
1868                .unwrap_or(ConfigSource::EnvVar),
1869            _ => ConfigSource::Default,
1870        };
1871        settings.push(ConfigSetting {
1872            name: name.to_string(),
1873            value,
1874            source: source.label().to_string(),
1875        });
1876    };
1877
1878    add(
1879        "agent_card",
1880        "agent-card",
1881        cli.agent_card
1882            .clone()
1883            .unwrap_or_else(|| "(not set)".to_string()),
1884        "A2ACLI_AGENT_CARD",
1885    );
1886    add(
1887        "endpoint",
1888        "endpoint",
1889        cli.endpoint
1890            .clone()
1891            .unwrap_or_else(|| "(not set)".to_string()),
1892        "A2ACLI_ENDPOINT",
1893    );
1894    add(
1895        "base_url",
1896        "base-url",
1897        cli.base_url.clone(),
1898        "A2ACLI_BASE_URL",
1899    );
1900    add(
1901        "transport",
1902        "transport",
1903        if cli.transport.is_empty() {
1904            "(agent card's own order)".to_string()
1905        } else {
1906            cli.transport
1907                .iter()
1908                .map(|binding| format!("{binding:?}").to_lowercase())
1909                .collect::<Vec<_>>()
1910                .join(",")
1911        },
1912        "A2ACLI_TRANSPORT",
1913    );
1914    add(
1915        "a2a_version",
1916        "a2a-version",
1917        cli.a2a_version.clone().unwrap_or_else(|| {
1918            format!("(negotiated from the agent card; a2acli supports {VERSION})")
1919        }),
1920        "A2ACLI_A2A_VERSION",
1921    );
1922    add(
1923        "bearer",
1924        "bearer",
1925        redact_secret(&cli.bearer),
1926        "A2ACLI_BEARER",
1927    );
1928    add(
1929        "api_key",
1930        "api-key",
1931        redact_secret(&cli.api_key),
1932        "A2ACLI_API_KEY",
1933    );
1934    add(
1935        "insecure",
1936        "insecure",
1937        cli.insecure.to_string(),
1938        "A2ACLI_INSECURE",
1939    );
1940    add("debug", "debug", cli.debug.to_string(), "A2ACLI_DEBUG");
1941    add(
1942        "tenant",
1943        "tenant",
1944        cli.tenant
1945            .clone()
1946            .unwrap_or_else(|| "(not set)".to_string()),
1947        "A2ACLI_TENANT",
1948    );
1949    add(
1950        "output",
1951        "output",
1952        format!("{:?}", cli.output).to_lowercase(),
1953        "A2ACLI_OUTPUT",
1954    );
1955    add(
1956        "compact",
1957        "compact",
1958        cli.compact.to_string(),
1959        "A2ACLI_COMPACT",
1960    );
1961    add(
1962        "async_mode",
1963        "async",
1964        cli.async_mode.to_string(),
1965        "A2ACLI_ASYNC",
1966    );
1967    add("wait", "wait", cli.wait.to_string(), "A2ACLI_WAIT");
1968    add(
1969        "poll_interval",
1970        "poll-interval",
1971        format!("{:?}", cli.poll_interval),
1972        "A2ACLI_POLL_INTERVAL",
1973    );
1974    add(
1975        "timeout",
1976        "timeout",
1977        format!("{:?}", cli.timeout),
1978        "A2ACLI_TIMEOUT",
1979    );
1980
1981    // Where the card is actually fetched from, given the above: the one
1982    // line that answers "which agent am I talking to?" without the reader
1983    // having to apply the precedence rules themselves.
1984    settings.push(ConfigSetting {
1985        name: "resolved-card".to_string(),
1986        value: match &cli.endpoint {
1987            Some(endpoint) => format!("(none; --endpoint {endpoint})"),
1988            None => {
1989                match normalize_card_reference(cli.agent_card.as_deref().unwrap_or(&cli.base_url)) {
1990                    CardReference::File(path) => format!("file://{}", path.display()),
1991                    CardReference::Url(url) => url,
1992                }
1993            }
1994        },
1995        source: "derived".to_string(),
1996    });
1997
1998    ConfigSettings { settings }
1999}
2000
2001/// A negotiated client, plus the tenant to attach to every subsequent
2002/// request on it: the caller's explicit `--tenant` when given, otherwise
2003/// (per A2A §8.3.2) the routing tenant the *selected* Agent Card interface
2004/// itself declares, if any (TX_003).
2005struct ResolvedClient {
2006    client: A2AClient<Box<dyn a2a_client::Transport>>,
2007    tenant: Option<String>,
2008    capabilities: DeclaredCapabilities,
2009}
2010
2011/// A capability the specification gates an operation on (§13.3).
2012#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2013enum Capability {
2014    Streaming,
2015    PushNotifications,
2016    ExtendedCard,
2017}
2018
2019impl Capability {
2020    /// Read the way the card renderer reads it: an absent field is not a
2021    /// declaration, so `None` and `Some(false)` are the same answer.
2022    fn declared_in(self, capabilities: &AgentCapabilities) -> bool {
2023        match self {
2024            Capability::Streaming => capabilities.streaming.unwrap_or(false),
2025            Capability::PushNotifications => capabilities.push_notifications.unwrap_or(false),
2026            Capability::ExtendedCard => capabilities.extended_agent_card.unwrap_or(false),
2027        }
2028    }
2029
2030    /// The card field's name, as it is spelled in the card.
2031    fn field(self) -> &'static str {
2032        match self {
2033            Capability::Streaming => "streaming",
2034            Capability::PushNotifications => "pushNotifications",
2035            Capability::ExtendedCard => "extendedAgentCard",
2036        }
2037    }
2038
2039    /// The protocol error the agent itself would have returned, so that
2040    /// answering locally changes only the round trip and the message, never
2041    /// the code a caller branches on.
2042    fn undeclared_error(self) -> A2AError {
2043        let message = format!("the agent card does not declare {}", self.field());
2044        match self {
2045            // A2A gives this case its own code, so use it rather than the
2046            // generic one.
2047            Capability::PushNotifications => {
2048                A2AError::new(a2a::error_code::PUSH_NOTIFICATION_NOT_SUPPORTED, message)
2049            }
2050            // Deliberately not EXTENDED_CARD_NOT_CONFIGURED: that means the
2051            // agent offers extended cards and this deployment has none. A
2052            // card that does not declare the capability is saying the agent
2053            // does not offer them at all, which is UNSUPPORTED_OPERATION.
2054            Capability::Streaming | Capability::ExtendedCard => {
2055                A2AError::unsupported_operation(message)
2056            }
2057        }
2058    }
2059}
2060
2061/// What the resolved card is able to say about capabilities.
2062#[derive(Debug, Clone, PartialEq)]
2063enum DeclaredCapabilities {
2064    /// Read from an Agent Card, so its declarations are the contract.
2065    Card(AgentCapabilities),
2066    /// `--endpoint` was used, so no card was read. The synthesized card
2067    /// declares nothing precisely because nothing was read, and gating on
2068    /// that would refuse operations the agent may well support — so the
2069    /// pre-flight stands down and the agent answers for itself.
2070    NoCard,
2071}
2072
2073impl DeclaredCapabilities {
2074    fn declares(&self, capability: Capability) -> bool {
2075        match self {
2076            DeclaredCapabilities::Card(capabilities) => capability.declared_in(capabilities),
2077            DeclaredCapabilities::NoCard => true,
2078        }
2079    }
2080
2081    /// §13.3: refuse a capability-gated call the card says will not work,
2082    /// rather than spending a round trip to be told so.
2083    fn require(&self, capability: Capability) -> Result<(), CliError> {
2084        if self.declares(capability) {
2085            Ok(())
2086        } else {
2087            Err(CliError::A2A(capability.undeclared_error()))
2088        }
2089    }
2090}
2091
2092/// §13.2: the A2A protocol version, as a `(major, minor)` pair. A2A
2093/// versions are `major.minor`; a trailing patch component is tolerated and
2094/// ignored so a card declaring `1.0.2` still negotiates.
2095fn parse_protocol_version(value: &str) -> Option<(u32, u32)> {
2096    let mut parts = value.trim().split('.');
2097    let major = parts.next()?.parse().ok()?;
2098    let minor = match parts.next() {
2099        Some(minor) => minor.parse().ok()?,
2100        None => 0,
2101    };
2102    Some((major, minor))
2103}
2104
2105/// The version this build of a2acli speaks.
2106fn supported_protocol_version() -> (u32, u32) {
2107    parse_protocol_version(VERSION).unwrap_or((1, 0))
2108}
2109
2110/// What the negotiation settled on, and why — kept together so the caller
2111/// can report a downgrade rather than applying one silently (§13.2).
2112struct NegotiatedVersion {
2113    version: String,
2114    /// Set when the effective version is not simply this build's own, so
2115    /// the reason can be surfaced on stderr.
2116    note: Option<String>,
2117}
2118
2119/// §13.2: an explicit `--a2a-version` is signaled as given — the caller
2120/// asked for it. Absent one, negotiate down to the highest version both
2121/// a2acli and the agent's selected interface declare, bounded to 1.x and
2122/// never below 1.0, since A2A reads a pre-1.0 value as 0.3 and a downgrade
2123/// into 0.x semantics is exactly what §13.2 forbids.
2124fn negotiate_a2a_version(
2125    explicit: Option<&str>,
2126    declared: &str,
2127    supported: (u32, u32),
2128) -> NegotiatedVersion {
2129    // An explicit version is signaled exactly as the caller wrote it. It has
2130    // already been rejected unless 1.x by `validate_a2a_version`, which runs
2131    // before any network work so a bad flag needs no reachable agent.
2132    let supported_label = format!("{}.{}", supported.0, supported.1);
2133
2134    if let Some(requested) = explicit {
2135        let note = (parse_protocol_version(requested) != Some(supported)).then(|| {
2136            format!("signaling A2A-Version {requested} (a2acli supports {supported_label})")
2137        });
2138        return NegotiatedVersion {
2139            version: requested.to_string(),
2140            note,
2141        };
2142    }
2143
2144    let Some(declared_version) = parse_protocol_version(declared) else {
2145        return NegotiatedVersion {
2146            version: supported_label.clone(),
2147            note: Some(format!(
2148                "agent card declares an unparseable protocol version ({declared}); \
2149                 signaling {supported_label}"
2150            )),
2151        };
2152    };
2153
2154    // Below 1.0, or a different major: there is nothing to negotiate within
2155    // 1.x, so hold the floor rather than following the card down.
2156    if declared_version.0 != 1 {
2157        return NegotiatedVersion {
2158            version: supported_label.clone(),
2159            note: Some(format!(
2160                "agent card declares protocol version {declared}, which is outside 1.x; \
2161                 signaling {supported_label} rather than negotiating below 1.0"
2162            )),
2163        };
2164    }
2165
2166    if declared_version < supported {
2167        let version = format!("{}.{}", declared_version.0, declared_version.1);
2168        return NegotiatedVersion {
2169            note: Some(format!(
2170                "negotiated A2A-Version down to {version} (agent card declares {version}, \
2171                 a2acli supports {supported_label})"
2172            )),
2173            version,
2174        };
2175    }
2176
2177    NegotiatedVersion {
2178        version: supported_label,
2179        note: None,
2180    }
2181}
2182
2183/// §13.2 / §11.6: reject a `--a2a-version` a2acli will not signal, before
2184/// any network work — a bad flag is a usage error and must not require a
2185/// reachable agent to report. A2A reads an empty or pre-1.0 value as 0.3, so
2186/// anything outside 1.x is refused rather than quietly downgraded.
2187fn validate_a2a_version(value: Option<&str>) -> Result<(), CliError> {
2188    let Some(requested) = value else {
2189        return Ok(());
2190    };
2191    match parse_protocol_version(requested) {
2192        Some((1, _)) => Ok(()),
2193        Some(_) => Err(CliError::InvalidInput(format!(
2194            "--a2a-version must be 1.x, got {requested}: A2A reads an empty or pre-1.0 \
2195             version as 0.3, and a2acli never signals below 1.0"
2196        ))),
2197        None => Err(CliError::InvalidInput(format!(
2198            "--a2a-version must be a version like 1.0, got {requested}"
2199        ))),
2200    }
2201}
2202
2203/// Sets `A2A-Version` to exactly the negotiated version, replacing the
2204/// client library's default rather than appending to it: §13.2 requires one
2205/// explicit version per request, and two values would leave which one
2206/// applies undefined.
2207struct VersionInterceptor {
2208    version: String,
2209}
2210
2211#[async_trait::async_trait]
2212impl a2a_client::middleware::CallInterceptor for VersionInterceptor {
2213    async fn before(
2214        &self,
2215        _method: &str,
2216        params: &mut a2a_client::ServiceParams,
2217    ) -> Result<(), A2AError> {
2218        params.insert(SVC_PARAM_VERSION.to_string(), vec![self.version.clone()]);
2219        Ok(())
2220    }
2221}
2222
2223/// How the caller named the agent (§7.2), validated once so the two forms
2224/// can't disagree further down: an Agent Card reference to resolve, or a
2225/// single interface to connect to directly.
2226#[derive(Debug, Clone, PartialEq, Eq)]
2227enum AgentSelection {
2228    Card(String),
2229    Endpoint { url: String, binding: Binding },
2230}
2231
2232fn resolve_agent_selection(cli: &Cli, matches: &ArgMatches) -> Result<AgentSelection, CliError> {
2233    match (&cli.agent_card, &cli.endpoint) {
2234        (Some(_), Some(_)) => Err(CliError::InvalidInput(
2235            "--endpoint and --agent-card are mutually exclusive: --endpoint names an interface \
2236             directly, so there is no card to resolve"
2237                .to_string(),
2238        )),
2239        // §7.2: with no card to declare the binding, the caller must name
2240        // exactly one transport — zero leaves the protocol ambiguous, and
2241        // more than one asks for a preference order over a single interface.
2242        (None, Some(url)) => match cli.transport.as_slice() {
2243            [binding] => Ok(AgentSelection::Endpoint {
2244                url: url.clone(),
2245                binding: *binding,
2246            }),
2247            transports => Err(CliError::InvalidInput(format!(
2248                "--endpoint requires exactly one --transport (got {}): there is no agent card \
2249                 to declare the interface's protocol binding",
2250                transports.len()
2251            ))),
2252        },
2253        (Some(reference), None) => Ok(AgentSelection::Card(reference.clone())),
2254        (None, None) => Ok(AgentSelection::Card(base_url_card_reference(cli, matches))),
2255    }
2256}
2257
2258/// A one-interface card standing in for the interface `--endpoint` named.
2259/// Synthesizing keeps transport selection, tenant handling and credential
2260/// attachment on a single path rather than growing a second card-less one.
2261fn synthesized_endpoint_card(url: &str, binding: Binding) -> AgentCard {
2262    AgentCard {
2263        name: url.to_string(),
2264        description: "synthesized from --endpoint; no agent card was resolved".to_string(),
2265        version: VERSION.to_string(),
2266        supported_interfaces: vec![AgentInterface::new(url, binding.protocol())],
2267        capabilities: AgentCapabilities::default(),
2268        default_input_modes: vec![],
2269        default_output_modes: vec![],
2270        skills: vec![],
2271        provider: None,
2272        documentation_url: None,
2273        icon_url: None,
2274        security_schemes: None,
2275        security_requirements: None,
2276        signatures: None,
2277    }
2278}
2279
2280async fn resolve_client(cli: &Cli, matches: &ArgMatches) -> Result<ResolvedClient, CliError> {
2281    let card = resolve_agent_card(cli, matches).await?;
2282    // Re-reading the selection is cheap (it only inspects flags) and avoids
2283    // a second card fetch: what matters is whether a card was read at all,
2284    // which `resolve_agent_card` does not report.
2285    let capabilities = match resolve_agent_selection(cli, matches)? {
2286        AgentSelection::Endpoint { .. } => DeclaredCapabilities::NoCard,
2287        AgentSelection::Card(_) => DeclaredCapabilities::Card(card.capabilities.clone()),
2288    };
2289
2290    let mut builder = A2AClientFactory::builder();
2291    if !cli.transport.is_empty() {
2292        let preferred = cli
2293            .transport
2294            .iter()
2295            .map(|binding| binding.protocol().to_string())
2296            .collect();
2297        builder = builder.preferred_bindings(preferred);
2298    }
2299    if cli.insecure {
2300        let insecure = build_insecure_reqwest_client()?;
2301        builder = builder
2302            .register(Arc::new(a2a_client::jsonrpc::JsonRpcTransportFactory::new(
2303                Some(insecure.clone()),
2304            )))
2305            .register(Arc::new(a2a_client::rest::RestTransportFactory::new(Some(
2306                insecure,
2307            ))));
2308    }
2309    // Collected rather than handed to the builder, because the version
2310    // interceptor can only be built once the selected interface is known
2311    // (§13.2 negotiates against what *that* interface declares) and
2312    // `with_interceptors` sets the whole list.
2313    let mut interceptors: Vec<Arc<dyn a2a_client::middleware::CallInterceptor>> = Vec::new();
2314    if let Some(token) = &cli.bearer {
2315        interceptors.push(Arc::new(AuthInterceptor::bearer(token.clone())));
2316    }
2317    if let Some(api_key) = &cli.api_key {
2318        interceptors.push(Arc::new(AuthInterceptor::custom(
2319            "X-API-Key",
2320            api_key.clone(),
2321        )));
2322    }
2323    for param in &cli.svc_params {
2324        interceptors.push(Arc::new(AuthInterceptor::custom(
2325            param.name.clone(),
2326            param.value.clone(),
2327        )));
2328    }
2329    if cli.debug {
2330        interceptors.push(Arc::new(a2a_client::middleware::LoggingInterceptor));
2331    }
2332
2333    let factory = builder.build();
2334    let (client, interface) = factory.create_from_card_with_interface(&card).await?;
2335
2336    let negotiated = negotiate_a2a_version(
2337        cli.a2a_version.as_deref(),
2338        &interface.protocol_version,
2339        supported_protocol_version(),
2340    );
2341    // §13.2: no silent downgrade. Whenever the effective version isn't
2342    // simply this build's own, say so.
2343    if let Some(note) = &negotiated.note {
2344        eprintln!("warning: {note}");
2345    }
2346    interceptors.push(Arc::new(VersionInterceptor {
2347        version: negotiated.version,
2348    }));
2349
2350    // Not `cli.tenant.or(interface.tenant)`: A2A §8.3.2 rule 4 and SPEC.md
2351    // §13.1 require the tenant to be "exactly the value declared in the
2352    // selected AgentInterface entry", so an explicit --tenant must not
2353    // displace a declared one. a2a-client applies the declared value itself,
2354    // overriding what is passed here, so --tenant reaches the wire only
2355    // where the card declares none (#199).
2356    //
2357    // `create_from_card_with_interface` stays: #191's version negotiation
2358    // needs the selected interface's protocol_version.
2359    let tenant = cli.tenant.clone();
2360    Ok(ResolvedClient {
2361        client: client.with_interceptors(interceptors),
2362        tenant,
2363        capabilities,
2364    })
2365}
2366
2367/// An `--agent-card` reference resolved to where the card actually lives
2368/// (§7.2, §10.1). The three surface forms collapse to two destinations: a
2369/// local file, or an HTTP(S) URL.
2370#[derive(Debug, Clone, PartialEq, Eq)]
2371enum CardReference {
2372    File(PathBuf),
2373    Url(String),
2374}
2375
2376/// Whether `reference` names a local file rather than a host or URL, using
2377/// the same rule as the reference implementation: an explicitly
2378/// path-shaped prefix, or any string that happens to name something on
2379/// disk. Checked before the host forms so a relative path never gets an
2380/// `https://` prefix stapled to it.
2381fn looks_like_file_path(reference: &str) -> bool {
2382    reference.starts_with('/')
2383        || reference.starts_with("./")
2384        || reference.starts_with("../")
2385        // Windows shapes. Without these a path that doesn't exist *yet*
2386        // (a typo, a file not written) matches no prefix, falls through to
2387        // the host forms, and is fetched as `https://C:\…` — so the error
2388        // reported is "unreachable" rather than "no such card file".
2389        || reference.starts_with('\\')
2390        || reference.starts_with(".\\")
2391        || reference.starts_with("..\\")
2392        || has_windows_drive_prefix(reference)
2393        || std::fs::metadata(reference).is_ok()
2394}
2395
2396/// Whether `reference` starts with a Windows drive-letter root (`C:\`,
2397/// `C:/`). The drive letter must be exactly one character before the colon,
2398/// so a `host:port` is never mistaken for a path — not `localhost:3000`,
2399/// and not even a single-letter host like `a:3000`, whose third byte is a
2400/// digit rather than a separator.
2401fn has_windows_drive_prefix(reference: &str) -> bool {
2402    let bytes = reference.as_bytes();
2403    bytes.len() >= 3
2404        && bytes[0].is_ascii_alphabetic()
2405        && bytes[1] == b':'
2406        && (bytes[2] == b'\\' || bytes[2] == b'/')
2407}
2408
2409/// Whether `host` (with or without a port) is loopback, which decides the
2410/// scheme a bare host gets: `http://` for loopback, `https://` otherwise —
2411/// a local development agent is rarely served over TLS, and defaulting it
2412/// to `https://` would make the common case fail.
2413fn is_loopback_host(host: &str) -> bool {
2414    // An unbracketed IPv6 literal's colons are not port separators, so try
2415    // the whole reference as an address before splitting on `:` — otherwise
2416    // `::1` would be truncated to `::` and read as non-loopback.
2417    if let Ok(address) = host.parse::<std::net::IpAddr>() {
2418        return address.is_loopback();
2419    }
2420
2421    let host = match host.strip_prefix('[') {
2422        // Bracketed IPv6, with or without a port: `[::1]`, `[::1]:9000`.
2423        Some(rest) => rest.split_once(']').map_or(rest, |(inside, _)| inside),
2424        // Anything else: a trailing `:…` is a port.
2425        None => host.split_once(':').map_or(host, |(head, _)| head),
2426    };
2427
2428    host == "localhost"
2429        || host
2430            .parse::<std::net::IpAddr>()
2431            .is_ok_and(|address| address.is_loopback())
2432}
2433
2434/// Normalize an Agent Card reference to the place the card is fetched from
2435/// (§10.1): a bare host or origin gets the well-known path appended, a full
2436/// card URL is used as-is, and a local path (`file://…` or plain) resolves
2437/// to a file on disk.
2438fn normalize_card_reference(reference: &str) -> CardReference {
2439    if let Some(rest) = reference.strip_prefix("file://") {
2440        let path = match rest.strip_prefix('/') {
2441            // `file:///C:/card.json` is the canonical Windows form: the
2442            // slash before the drive letter belongs to the URL, not to the
2443            // path. On Unix there is no drive letter and the leading slash
2444            // is kept, since it is the root.
2445            Some(without_slash) if has_windows_drive_prefix(without_slash) => without_slash,
2446            _ => rest,
2447        };
2448        return CardReference::File(PathBuf::from(path));
2449    }
2450
2451    if !reference.contains("://") {
2452        if looks_like_file_path(reference) {
2453            return CardReference::File(PathBuf::from(reference));
2454        }
2455        let scheme = if is_loopback_host(reference) {
2456            "http"
2457        } else {
2458            "https"
2459        };
2460        return CardReference::Url(append_well_known_path(&format!("{scheme}://{reference}")));
2461    }
2462
2463    CardReference::Url(append_well_known_path(reference))
2464}
2465
2466/// A reference that names only a host or origin gets the well-known path;
2467/// one that already carries a path is a full card URL and is left alone.
2468fn append_well_known_path(url: &str) -> String {
2469    let trimmed = url.trim_end_matches('/');
2470    let has_path = trimmed
2471        .split_once("://")
2472        .is_some_and(|(_, rest)| rest.contains('/'));
2473    if has_path {
2474        url.to_string()
2475    } else {
2476        format!("{trimmed}{WELL_KNOWN_AGENT_CARD_PATH}")
2477    }
2478}
2479
2480/// The reference from the deprecated `--base-url`, used only when
2481/// `--agent-card` was not given. Warns when `--base-url` was passed
2482/// explicitly, so the alias is discoverable as deprecated without breaking
2483/// anything; leaving it at its built-in default is not a deprecated
2484/// invocation and says nothing.
2485fn base_url_card_reference(cli: &Cli, matches: &ArgMatches) -> String {
2486    if matches!(
2487        matches.value_source("base_url"),
2488        Some(ValueSource::CommandLine)
2489    ) {
2490        eprintln!(
2491            "warning: --base-url is deprecated; use --agent-card, which also accepts a full \
2492             card URL or a local file path"
2493        );
2494    }
2495    cli.base_url.clone()
2496}
2497
2498async fn resolve_agent_card(cli: &Cli, matches: &ArgMatches) -> Result<AgentCard, CliError> {
2499    Ok(resolve_agent_card_with_raw(cli, matches).await?.0)
2500}
2501
2502/// Like [`resolve_agent_card`], but also returns the raw JSON the card was
2503/// parsed from.
2504///
2505/// `card get --validate` needs those actual bytes rather than the typed
2506/// `AgentCard`: `AgentCard`'s `Deserialize` has no `deny_unknown_fields`, so
2507/// an extra property serde tolerated is already gone by the time a typed
2508/// value exists to re-serialize — exactly the shape of violation §10.1
2509/// schema validation exists to catch that the type check does not.
2510async fn resolve_agent_card_with_raw(
2511    cli: &Cli,
2512    matches: &ArgMatches,
2513) -> Result<(AgentCard, Value), CliError> {
2514    warn_if_insecure_with_credentials(cli);
2515
2516    let reference = match resolve_agent_selection(cli, matches)? {
2517        AgentSelection::Card(reference) => reference,
2518        AgentSelection::Endpoint { url, binding } => {
2519            let card = synthesized_endpoint_card(&url, binding);
2520            // Nothing was fetched, so there is nothing "extra" a typed
2521            // round trip could have lost; the two forms coincide.
2522            let raw = serde_json::to_value(&card)?;
2523            return Ok((card, raw));
2524        }
2525    };
2526
2527    match normalize_card_reference(&reference) {
2528        CardReference::File(path) => read_agent_card_file(&path),
2529        CardReference::Url(url) => {
2530            let client = if cli.insecure {
2531                build_insecure_reqwest_client()?
2532            } else {
2533                Client::new()
2534            };
2535            let request = apply_request_auth(client.get(&url), cli);
2536            let response = request.send().await?.error_for_status()?;
2537            let raw: Value = response.json().await?;
2538            // Classified the same way the old single-step `response.json::
2539            // <AgentCard>()` was: a body that parses as JSON but not as the
2540            // expected shape is CARD_INVALID, not a bare serde_json::Error
2541            // (-> INTERNAL) now that fetching and typing are two steps.
2542            let card: AgentCard = serde_json::from_value(raw.clone())
2543                .map_err(|error| CliError::CardInvalid(format!("{url}: {error}")))?;
2544            Ok((card, raw))
2545        }
2546    }
2547}
2548
2549/// Read an Agent Card from disk, classified per Appendix D: a file that
2550/// isn't there or can't be read is `CARD_NOT_FOUND` (the card was not
2551/// found where the caller pointed), while a file that is there but isn't a
2552/// card is `CARD_INVALID` — the same split the HTTP path makes between a
2553/// non-2xx response and a body that won't deserialize.
2554fn read_agent_card_file(path: &Path) -> Result<(AgentCard, Value), CliError> {
2555    let text = std::fs::read_to_string(path).map_err(|source| CliError::CardFile {
2556        path: path.display().to_string(),
2557        source,
2558    })?;
2559    let raw: Value = serde_json::from_str(&text)
2560        .map_err(|error| CliError::CardInvalid(format!("{}: {error}", path.display())))?;
2561    let card: AgentCard = serde_json::from_value(raw.clone())
2562        .map_err(|error| CliError::CardInvalid(format!("{}: {error}", path.display())))?;
2563    Ok((card, raw))
2564}
2565
2566fn apply_request_auth(mut request: RequestBuilder, cli: &Cli) -> RequestBuilder {
2567    if let Some(token) = &cli.bearer {
2568        request = request.bearer_auth(token);
2569    }
2570    if let Some(api_key) = &cli.api_key {
2571        request = request.header("X-API-Key", api_key);
2572    }
2573    for param in &cli.svc_params {
2574        request = request.header(&param.name, &param.value);
2575    }
2576    request
2577}
2578
2579/// `--insecure` disables TLS certificate verification (development only).
2580#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
2581fn build_insecure_reqwest_client() -> Result<Client, CliError> {
2582    Client::builder()
2583        .danger_accept_invalid_certs(true)
2584        .build()
2585        .map_err(CliError::Http)
2586}
2587
2588/// No TLS backend compiled in: there is no certificate verification to
2589/// disable, so `--insecure` is a no-op rather than a build error.
2590#[cfg(not(any(feature = "rustls-tls", feature = "native-tls")))]
2591fn build_insecure_reqwest_client() -> Result<Client, CliError> {
2592    Client::builder().build().map_err(CliError::Http)
2593}
2594
2595/// §12.1/AUTH_003: never disable TLS verification silently. Warns whenever
2596/// `--insecure` is set, and names the specific risk when a credential would
2597/// also be sent over that connection.
2598fn warn_if_insecure_with_credentials(cli: &Cli) {
2599    if !cli.insecure {
2600        return;
2601    }
2602    let sends_credentials = cli.bearer.is_some() || cli.api_key.is_some();
2603    if sends_credentials {
2604        eprintln!(
2605            "warning: --insecure disables TLS certificate verification, and a credential \
2606             (--bearer/--api-key) is being sent over this connection — use only against a \
2607             trusted development endpoint"
2608        );
2609    } else {
2610        eprintln!(
2611            "warning: --insecure disables TLS certificate verification — use only against a \
2612             trusted development endpoint"
2613        );
2614    }
2615}
2616
2617/// Renders `text` mode (§11.2): one `Label: value` field per line, stable
2618/// labels across invocations, no terminal control sequences. Content the
2619/// field form can't carry on one line (a rendered artifact, a formatted data
2620/// part) is a *block*: its own `Label:` line, the content, then a blank
2621/// line, never interleaved with field lines.
2622trait TextRender {
2623    fn render_text(&self) -> String;
2624}
2625
2626/// Accumulates field lines and blocks for [`TextRender`] impls, then joins
2627/// them with a single trailing newline trimmed off (`println!` adds it back).
2628#[derive(Default)]
2629struct TextOutput {
2630    lines: Vec<String>,
2631}
2632
2633impl TextOutput {
2634    fn field(&mut self, label: &str, value: impl std::fmt::Display) -> &mut Self {
2635        self.lines.push(format!("{label}: {value}"));
2636        self
2637    }
2638
2639    fn raw(&mut self, line: impl Into<String>) -> &mut Self {
2640        self.lines.push(line.into());
2641        self
2642    }
2643
2644    /// A block: its own `Label:` line, `content` (each of its lines emitted
2645    /// as-is), then a blank line closing it.
2646    fn block(&mut self, label: &str, content: &str) -> &mut Self {
2647        self.lines.push(format!("{label}:"));
2648        self.lines
2649            .extend(content.lines().map(|line| line.to_string()));
2650        self.lines.push(String::new());
2651        self
2652    }
2653
2654    fn finish(self) -> String {
2655        let mut text = self.lines.join("\n");
2656        while text.ends_with('\n') {
2657            text.pop();
2658        }
2659        text
2660    }
2661}
2662
2663/// Short-form label for a task state (§9.1); the wire form
2664/// (`TASK_STATE_COMPLETED`, ...) is what `-o json` emits, not this.
2665fn task_state_label(state: &TaskState) -> &'static str {
2666    match state {
2667        TaskState::Unspecified => "UNSPECIFIED",
2668        TaskState::Submitted => "SUBMITTED",
2669        TaskState::Working => "WORKING",
2670        TaskState::Completed => "COMPLETED",
2671        TaskState::Failed => "FAILED",
2672        TaskState::Canceled => "CANCELED",
2673        TaskState::InputRequired => "INPUT_REQUIRED",
2674        TaskState::Rejected => "REJECTED",
2675        TaskState::AuthRequired => "AUTH_REQUIRED",
2676    }
2677}
2678
2679/// Render one message part: a text part as readable text, a data part as
2680/// formatted JSON, a file part by name/media type/size (§10.2, §10.3) —
2681/// never dumped as raw structure, and never silently discarded.
2682fn render_part(out: &mut TextOutput, part: &Part) {
2683    match &part.content {
2684        PartContent::Text(text) => {
2685            out.block("Text", text);
2686        }
2687        PartContent::Data(value) => {
2688            let pretty = serde_json::to_string_pretty(value)
2689                .unwrap_or_else(|_| serde_json::Value::Null.to_string());
2690            out.block("Data", &pretty);
2691        }
2692        PartContent::Raw(bytes) => {
2693            let name = part.filename.as_deref().unwrap_or("(unnamed)");
2694            match &part.media_type {
2695                Some(media_type) => out.field(
2696                    "File",
2697                    format!("{name} ({media_type}, {} bytes)", bytes.len()),
2698                ),
2699                None => out.field("File", format!("{name} ({} bytes)", bytes.len())),
2700            };
2701        }
2702        PartContent::Url(url) => {
2703            match &part.media_type {
2704                Some(media_type) => out.field("File", format!("{url} ({media_type})")),
2705                None => out.field("File", url),
2706            };
2707        }
2708    }
2709}
2710
2711fn render_message_parts(out: &mut TextOutput, message: &Message) {
2712    if message.parts.is_empty() {
2713        out.field("Parts", "(none)");
2714        return;
2715    }
2716    for part in &message.parts {
2717        render_part(out, part);
2718    }
2719}
2720
2721impl TextRender for AgentCard {
2722    fn render_text(&self) -> String {
2723        let mut out = TextOutput::default();
2724        out.field("Name", &self.name)
2725            .field("Description", &self.description)
2726            .field("Version", &self.version)
2727            .field("Streaming", self.capabilities.streaming.unwrap_or(false))
2728            .field(
2729                "Push Notifications",
2730                self.capabilities.push_notifications.unwrap_or(false),
2731            )
2732            .field(
2733                "Extended Card",
2734                self.capabilities.extended_agent_card.unwrap_or(false),
2735            );
2736
2737        let interfaces = if self.supported_interfaces.is_empty() {
2738            "  (none)".to_string()
2739        } else {
2740            self.supported_interfaces
2741                .iter()
2742                .map(|interface| {
2743                    format!(
2744                        "  - {} {} (v{})",
2745                        interface.protocol_binding, interface.url, interface.protocol_version
2746                    )
2747                })
2748                .collect::<Vec<_>>()
2749                .join("\n")
2750        };
2751        out.block("Interfaces", &interfaces);
2752        out.field("Skills", self.skills.len());
2753        out.finish()
2754    }
2755}
2756
2757impl TextRender for Task {
2758    fn render_text(&self) -> String {
2759        let mut out = TextOutput::default();
2760        out.field("Task ID", &self.id)
2761            .field("Context ID", &self.context_id)
2762            .field("State", task_state_label(&self.status.state));
2763
2764        if let Some(message) = &self.status.message {
2765            render_message_parts(&mut out, message);
2766        }
2767
2768        if let Some(artifacts) = &self.artifacts {
2769            for artifact in artifacts {
2770                let label = artifact.name.as_deref().unwrap_or(&artifact.artifact_id);
2771                out.raw(format!("Artifact: {label}"));
2772                for part in &artifact.parts {
2773                    render_part(&mut out, part);
2774                }
2775            }
2776        }
2777
2778        if matches!(
2779            self.status.state,
2780            TaskState::InputRequired | TaskState::AuthRequired
2781        ) {
2782            out.field(
2783                "Resume with",
2784                format!("a2acli send --task-id {} \"<reply>\"", self.id),
2785            );
2786        }
2787
2788        out.finish()
2789    }
2790}
2791
2792impl TextRender for Message {
2793    fn render_text(&self) -> String {
2794        let mut out = TextOutput::default();
2795        if let Some(context_id) = &self.context_id {
2796            out.field("Context ID", context_id);
2797        }
2798        if let Some(task_id) = &self.task_id {
2799            out.field("Task ID", task_id);
2800        }
2801        render_message_parts(&mut out, self);
2802        out.finish()
2803    }
2804}
2805
2806impl TextRender for SendMessageResponse {
2807    fn render_text(&self) -> String {
2808        match self {
2809            SendMessageResponse::Task(task) => task.render_text(),
2810            SendMessageResponse::Message(message) => message.render_text(),
2811        }
2812    }
2813}
2814
2815impl TextRender for ListTasksResponse {
2816    fn render_text(&self) -> String {
2817        let mut out = TextOutput::default();
2818        out.field("Total", self.total_size)
2819            .field("Page Size", self.page_size);
2820        if !self.next_page_token.is_empty() {
2821            out.field("Next Page Token", &self.next_page_token);
2822        }
2823        if self.tasks.is_empty() {
2824            out.field("Tasks", "(none)");
2825        } else {
2826            for task in &self.tasks {
2827                out.block("Task", &task.render_text());
2828            }
2829        }
2830        out.finish()
2831    }
2832}
2833
2834impl TextRender for TaskPushNotificationConfig {
2835    fn render_text(&self) -> String {
2836        let mut out = TextOutput::default();
2837        if let Some(id) = &self.id {
2838            out.field("Config ID", id);
2839        }
2840        out.field("Task ID", &self.task_id).field("URL", &self.url);
2841        if let Some(token) = &self.token {
2842            out.field("Token", token);
2843        }
2844        if let Some(auth) = &self.authentication {
2845            out.field("Auth Scheme", &auth.scheme);
2846        }
2847        out.finish()
2848    }
2849}
2850
2851impl TextRender for ListTaskPushNotificationConfigsResponse {
2852    fn render_text(&self) -> String {
2853        let mut out = TextOutput::default();
2854        if let Some(token) = &self.next_page_token {
2855            out.field("Next Page Token", token);
2856        }
2857        if self.configs.is_empty() {
2858            out.field("Configs", "(none)");
2859        } else {
2860            for config in &self.configs {
2861                out.block("Push Config", &config.render_text());
2862            }
2863        }
2864        out.finish()
2865    }
2866}
2867
2868/// The result of `task push-config delete`, replacing an ad hoc
2869/// `serde_json::json!` value so it can carry both a `-o json` shape and a
2870/// `-o text` rendering like every other response type.
2871#[derive(Debug, Clone, Serialize)]
2872#[serde(rename_all = "camelCase")]
2873struct PushConfigDeleted {
2874    deleted: bool,
2875    task_id: String,
2876    id: String,
2877}
2878
2879impl TextRender for PushConfigDeleted {
2880    fn render_text(&self) -> String {
2881        let mut out = TextOutput::default();
2882        out.field("Deleted", self.deleted)
2883            .field("Task ID", &self.task_id)
2884            .field("Config ID", &self.id);
2885        out.finish()
2886    }
2887}
2888
2889impl TextRender for StreamResponse {
2890    fn render_text(&self) -> String {
2891        match self {
2892            StreamResponse::Task(task) => task.render_text(),
2893            StreamResponse::Message(message) => message.render_text(),
2894            StreamResponse::StatusUpdate(event) => {
2895                let mut out = TextOutput::default();
2896                out.field("Task ID", &event.task_id)
2897                    .field("Context ID", &event.context_id)
2898                    .field("State", task_state_label(&event.status.state));
2899                if matches!(
2900                    event.status.state,
2901                    TaskState::InputRequired | TaskState::AuthRequired
2902                ) {
2903                    out.field(
2904                        "Resume with",
2905                        format!("a2acli send --task-id {} \"<reply>\"", event.task_id),
2906                    );
2907                }
2908                out.finish()
2909            }
2910            StreamResponse::ArtifactUpdate(event) => {
2911                let mut out = TextOutput::default();
2912                out.field("Task ID", &event.task_id)
2913                    .field("Context ID", &event.context_id);
2914                let label = event
2915                    .artifact
2916                    .name
2917                    .as_deref()
2918                    .unwrap_or(&event.artifact.artifact_id);
2919                out.raw(format!("Artifact: {label}"));
2920                for part in &event.artifact.parts {
2921                    render_part(&mut out, part);
2922                }
2923                out.finish()
2924            }
2925        }
2926    }
2927}
2928
2929/// Print `value` in the caller-selected format: `text` (default, §11.2) or
2930/// `-o json` as one pretty/compact document per §11.3 (never JSONL — that
2931/// form is only used by [`consume_stream`] under `--stream`).
2932fn print_output<T: Serialize + TextRender>(value: &T, cli: &Cli) -> Result<(), CliError> {
2933    match cli.output {
2934        OutputFormat::Text => {
2935            println!("{}", value.render_text());
2936            Ok(())
2937        }
2938        OutputFormat::Json => print_json(value, cli.compact),
2939    }
2940}
2941
2942fn print_json<T: Serialize>(value: &T, compact: bool) -> Result<(), CliError> {
2943    if compact {
2944        println!("{}", serde_json::to_string(value)?);
2945    } else {
2946        println!("{}", serde_json::to_string_pretty(value)?);
2947    }
2948    Ok(())
2949}
2950
2951fn parse_header(input: &str) -> Result<HeaderArg, String> {
2952    let (name, value) = input
2953        .split_once(':')
2954        .ok_or_else(|| "header must be in NAME:VALUE format".to_string())?;
2955
2956    let name = name.trim();
2957    let value = value.trim();
2958
2959    if name.is_empty() {
2960        return Err("header name cannot be empty".to_string());
2961    }
2962
2963    Ok(HeaderArg {
2964        name: name.to_string(),
2965        value: value.to_string(),
2966    })
2967}
2968
2969async fn finish_client_call<T: a2a_client::Transport, V>(
2970    client: A2AClient<T>,
2971    result: Result<V, A2AError>,
2972) -> Result<V, CliError> {
2973    match result {
2974        Ok(value) => {
2975            client.destroy().await?;
2976            Ok(value)
2977        }
2978        Err(error) => {
2979            let _ = client.destroy().await;
2980            Err(error.into())
2981        }
2982    }
2983}
2984
2985/// Prints one streamed value per §11.1/§11.2's output-mode rules.
2986fn print_stream_value<V: Serialize + TextRender>(value: &V, cli: &Cli) -> Result<(), CliError> {
2987    match cli.output {
2988        OutputFormat::Text => {
2989            println!("{}", value.render_text());
2990            Ok(())
2991        }
2992        OutputFormat::Json => serde_json::to_string(value)
2993            .map(|line| println!("{line}"))
2994            .map_err(CliError::from),
2995    }
2996}
2997
2998/// Warns, prints, and destroys `client` first if printing fails.
2999async fn print_or_destroy<T: a2a_client::Transport, V: Serialize + TextRender + TaskOutcome>(
3000    client: &A2AClient<T>,
3001    value: &V,
3002    cli: &Cli,
3003) -> Result<(), CliError> {
3004    value.warn_outcome();
3005    if let Err(error) = print_stream_value(value, cli) {
3006        let _ = client.destroy().await;
3007        return Err(error);
3008    }
3009    Ok(())
3010}
3011
3012/// Consumes a streamed event sequence to completion, printing each event.
3013/// Used by `send --stream`, which does not reconnect -- see
3014/// [`subscribe_with_resumption`] for `task subscribe`, which does.
3015async fn consume_stream<T: a2a_client::Transport, V: Serialize + TextRender + TaskOutcome>(
3016    client: A2AClient<T>,
3017    mut stream: BoxStream<'static, Result<V, A2AError>>,
3018    cli: &Cli,
3019) -> Result<(), CliError> {
3020    loop {
3021        match stream.next().await {
3022            Some(Ok(value)) => {
3023                print_or_destroy(&client, &value, cli).await?;
3024            }
3025            Some(Err(error)) => {
3026                let _ = client.destroy().await;
3027                return Err(error.into());
3028            }
3029            None => {
3030                client.destroy().await?;
3031                return Ok(());
3032            }
3033        }
3034    }
3035}
3036
3037/// The task state a `StreamResponse` carries, if any.
3038fn stream_response_state(value: &StreamResponse) -> Option<TaskState> {
3039    match value {
3040        StreamResponse::Task(task) => Some(task.status.state.clone()),
3041        StreamResponse::StatusUpdate(event) => Some(event.status.state.clone()),
3042        StreamResponse::Message(_) | StreamResponse::ArtifactUpdate(_) => None,
3043    }
3044}
3045
3046/// `task subscribe` (§9.4, `A2ACLI_TASK_SUBSCRIBE_002`): reconnects when the
3047/// stream ends before the task settles, using the last state observed to
3048/// tell a cut from a finish. The reconnect's first event reconciles state
3049/// (no `task get` needed) and is suppressed if unchanged from before the
3050/// cut. `--timeout` bounds reconnection cumulatively, not a healthy stream.
3051async fn subscribe_with_resumption<T: a2a_client::Transport>(
3052    client: A2AClient<T>,
3053    mut stream: BoxStream<'static, Result<StreamResponse, A2AError>>,
3054    task_id: String,
3055    tenant: Option<String>,
3056    cli: &Cli,
3057) -> Result<(), CliError> {
3058    let deadline = tokio::time::Instant::now() + cli.timeout;
3059    let mut last_state: Option<TaskState> = None;
3060    let mut attempt: u32 = 0;
3061
3062    loop {
3063        let state_before_this_attempt = last_state.clone();
3064        let mut first_event = attempt > 0;
3065
3066        loop {
3067            match stream.next().await {
3068                Some(Ok(value)) => {
3069                    let state = stream_response_state(&value);
3070                    if let Some(state) = &state {
3071                        last_state = Some(state.clone());
3072                    }
3073
3074                    let is_unchanged_reconciliation =
3075                        first_event && state.is_some() && state == state_before_this_attempt;
3076                    first_event = false;
3077                    if is_unchanged_reconciliation {
3078                        continue;
3079                    }
3080
3081                    print_or_destroy(&client, &value, cli).await?;
3082                }
3083                Some(Err(error)) => {
3084                    let _ = client.destroy().await;
3085                    return Err(error.into());
3086                }
3087                None => break,
3088            }
3089        }
3090
3091        if last_state.as_ref().is_some_and(is_settled) {
3092            client.destroy().await?;
3093            return Ok(());
3094        }
3095
3096        // Retries both a cut and a failed re-subscribe under one budget.
3097        loop {
3098            let now = tokio::time::Instant::now();
3099            if now >= deadline {
3100                let _ = client.destroy().await;
3101                return Err(CliError::Timeout {
3102                    task_id: task_id.clone(),
3103                    timeout: cli.timeout,
3104                });
3105            }
3106            attempt += 1;
3107            eprintln!(
3108                "warning: subscription to task {task_id} was interrupted before it settled; \
3109                 reconnecting (attempt {attempt})..."
3110            );
3111            tokio::time::sleep(
3112                cli.poll_interval
3113                    .min(deadline.saturating_duration_since(now)),
3114            )
3115            .await;
3116
3117            match client
3118                .subscribe_to_task(&SubscribeToTaskRequest {
3119                    id: task_id.clone(),
3120                    tenant: tenant.clone(),
3121                })
3122                .await
3123            {
3124                Ok(new_stream) => {
3125                    stream = new_stream;
3126                    break;
3127                }
3128                Err(_) => continue,
3129            }
3130        }
3131    }
3132}
3133
3134#[cfg(test)]
3135mod tests {
3136    use super::*;
3137    use a2a_client::{ServiceParams, Transport};
3138    use a2a_server::jsonrpc::jsonrpc_router;
3139    use a2a_server::rest::rest_router;
3140    use a2a_server::{
3141        RequestHandler, ServiceParams as HandlerServiceParams, WELL_KNOWN_AGENT_CARD_PATH,
3142    };
3143    use async_trait::async_trait;
3144    use axum::routing::get;
3145    use axum::{Json, Router};
3146    use futures::stream;
3147    use reqwest::header;
3148    use serde::ser;
3149    use std::collections::BTreeMap;
3150    use std::sync::Mutex;
3151    use tokio::net::TcpListener;
3152
3153    struct TestTransport {
3154        destroy_error: Option<A2AError>,
3155    }
3156
3157    #[async_trait]
3158    impl Transport for TestTransport {
3159        async fn send_message(
3160            &self,
3161            _params: &ServiceParams,
3162            _req: &SendMessageRequest,
3163        ) -> Result<SendMessageResponse, A2AError> {
3164            Err(A2AError::unsupported_operation("unused"))
3165        }
3166
3167        async fn send_streaming_message(
3168            &self,
3169            _params: &ServiceParams,
3170            _req: &SendMessageRequest,
3171        ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3172            Err(A2AError::unsupported_operation("unused"))
3173        }
3174
3175        async fn get_task(
3176            &self,
3177            _params: &ServiceParams,
3178            _req: &GetTaskRequest,
3179        ) -> Result<Task, A2AError> {
3180            Err(A2AError::unsupported_operation("unused"))
3181        }
3182
3183        async fn list_tasks(
3184            &self,
3185            _params: &ServiceParams,
3186            _req: &ListTasksRequest,
3187        ) -> Result<ListTasksResponse, A2AError> {
3188            Err(A2AError::unsupported_operation("unused"))
3189        }
3190
3191        async fn cancel_task(
3192            &self,
3193            _params: &ServiceParams,
3194            _req: &CancelTaskRequest,
3195        ) -> Result<Task, A2AError> {
3196            Err(A2AError::unsupported_operation("unused"))
3197        }
3198
3199        async fn subscribe_to_task(
3200            &self,
3201            _params: &ServiceParams,
3202            _req: &SubscribeToTaskRequest,
3203        ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3204            Err(A2AError::unsupported_operation("unused"))
3205        }
3206
3207        async fn create_push_config(
3208            &self,
3209            _params: &ServiceParams,
3210            _req: &TaskPushNotificationConfig,
3211        ) -> Result<TaskPushNotificationConfig, A2AError> {
3212            Err(A2AError::unsupported_operation("unused"))
3213        }
3214
3215        async fn get_push_config(
3216            &self,
3217            _params: &ServiceParams,
3218            _req: &GetTaskPushNotificationConfigRequest,
3219        ) -> Result<TaskPushNotificationConfig, A2AError> {
3220            Err(A2AError::unsupported_operation("unused"))
3221        }
3222
3223        async fn list_push_configs(
3224            &self,
3225            _params: &ServiceParams,
3226            _req: &ListTaskPushNotificationConfigsRequest,
3227        ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
3228            Err(A2AError::unsupported_operation("unused"))
3229        }
3230
3231        async fn delete_push_config(
3232            &self,
3233            _params: &ServiceParams,
3234            _req: &DeleteTaskPushNotificationConfigRequest,
3235        ) -> Result<(), A2AError> {
3236            Err(A2AError::unsupported_operation("unused"))
3237        }
3238
3239        async fn get_extended_agent_card(
3240            &self,
3241            _params: &ServiceParams,
3242            _req: &GetExtendedAgentCardRequest,
3243        ) -> Result<AgentCard, A2AError> {
3244            Err(A2AError::unsupported_operation("unused"))
3245        }
3246
3247        async fn destroy(&self) -> Result<(), A2AError> {
3248            match &self.destroy_error {
3249                Some(error) => Err(error.clone()),
3250                None => Ok(()),
3251            }
3252        }
3253    }
3254
3255    struct FailingSerialize;
3256
3257    impl Serialize for FailingSerialize {
3258        fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
3259        where
3260            S: serde::Serializer,
3261        {
3262            Err(ser::Error::custom("serialize failed"))
3263        }
3264    }
3265
3266    impl TextRender for FailingSerialize {
3267        fn render_text(&self) -> String {
3268            String::new()
3269        }
3270    }
3271
3272    impl TaskOutcome for FailingSerialize {
3273        fn warn_outcome(&self) {}
3274    }
3275
3276    fn make_test_client(destroy_error: Option<A2AError>) -> A2AClient<TestTransport> {
3277        A2AClient::new(TestTransport { destroy_error })
3278    }
3279
3280    #[derive(Default)]
3281    struct RunTestState {
3282        tasks: Mutex<BTreeMap<String, Task>>,
3283        push_configs: Mutex<BTreeMap<(String, String), TaskPushNotificationConfig>>,
3284    }
3285
3286    struct RunTestHandler {
3287        state: Arc<RunTestState>,
3288        extended_card: AgentCard,
3289    }
3290
3291    struct RunTestServer {
3292        base_url: String,
3293        handle: tokio::task::JoinHandle<()>,
3294    }
3295
3296    impl Drop for RunTestServer {
3297        fn drop(&mut self) {
3298            self.handle.abort();
3299        }
3300    }
3301
3302    impl RunTestServer {
3303        async fn spawn() -> Self {
3304            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3305            let base_url = format!("http://{}", listener.local_addr().unwrap());
3306            let state = Arc::new(RunTestState::default());
3307
3308            state.tasks.lock().unwrap().insert(
3309                "task-1".to_string(),
3310                make_fixture_task("task-1", "ctx-1", TaskState::Completed, "seeded result"),
3311            );
3312
3313            let public_card = make_fixture_card(&base_url, "Fixture Agent");
3314            let extended_card = make_fixture_card(&base_url, "Fixture Agent (extended)");
3315            let handler = Arc::new(RunTestHandler {
3316                state,
3317                extended_card,
3318            });
3319
3320            let card = public_card.clone();
3321            let app = Router::new()
3322                .route(
3323                    WELL_KNOWN_AGENT_CARD_PATH,
3324                    get(move || {
3325                        let card = card.clone();
3326                        async move { Json(card) }
3327                    }),
3328                )
3329                .nest("/jsonrpc", jsonrpc_router(handler.clone()))
3330                .nest("/rest", rest_router(handler));
3331
3332            let handle = tokio::spawn(async move {
3333                axum::serve(listener, app).await.unwrap();
3334            });
3335
3336            RunTestServer { base_url, handle }
3337        }
3338    }
3339
3340    #[async_trait]
3341    impl RequestHandler for RunTestHandler {
3342        async fn send_message(
3343            &self,
3344            _params: &HandlerServiceParams,
3345            req: SendMessageRequest,
3346        ) -> Result<SendMessageResponse, A2AError> {
3347            let task_id = req
3348                .message
3349                .task_id
3350                .clone()
3351                .unwrap_or_else(|| "task-send".to_string());
3352            let context_id = req
3353                .message
3354                .context_id
3355                .clone()
3356                .unwrap_or_else(|| "ctx-send".to_string());
3357            let text = req.message.text().unwrap_or_default();
3358            if text == "send-error" {
3359                return Err(A2AError::invalid_request("send failed"));
3360            }
3361
3362            let task = make_fixture_task(
3363                &task_id,
3364                &context_id,
3365                TaskState::Completed,
3366                &format!("Echo: {text}"),
3367            );
3368            self.state
3369                .tasks
3370                .lock()
3371                .unwrap()
3372                .insert(task_id, task.clone());
3373            Ok(SendMessageResponse::Task(task))
3374        }
3375
3376        async fn send_streaming_message(
3377            &self,
3378            _params: &HandlerServiceParams,
3379            req: SendMessageRequest,
3380        ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3381            let task_id = req
3382                .message
3383                .task_id
3384                .clone()
3385                .unwrap_or_else(|| "task-stream".to_string());
3386            let context_id = req
3387                .message
3388                .context_id
3389                .clone()
3390                .unwrap_or_else(|| "ctx-stream".to_string());
3391            let text = req.message.text().unwrap_or_default();
3392            if text == "stream-error" {
3393                return Ok(Box::pin(stream::once(async {
3394                    Err(A2AError::internal("stream failed"))
3395                })));
3396            }
3397
3398            let task = make_fixture_task(
3399                &task_id,
3400                &context_id,
3401                TaskState::Completed,
3402                &format!("Echo: {text}"),
3403            );
3404            self.state
3405                .tasks
3406                .lock()
3407                .unwrap()
3408                .insert(task_id.clone(), task.clone());
3409
3410            Ok(Box::pin(stream::iter(vec![
3411                Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
3412                    task_id,
3413                    context_id,
3414                    status: TaskStatus {
3415                        state: TaskState::Working,
3416                        message: None,
3417                        timestamp: None,
3418                    },
3419                    metadata: None,
3420                })),
3421                Ok(StreamResponse::Task(task)),
3422            ])))
3423        }
3424
3425        async fn get_task(
3426            &self,
3427            _params: &HandlerServiceParams,
3428            req: GetTaskRequest,
3429        ) -> Result<Task, A2AError> {
3430            self.state
3431                .tasks
3432                .lock()
3433                .unwrap()
3434                .get(&req.id)
3435                .cloned()
3436                .ok_or_else(|| A2AError::task_not_found(&req.id))
3437        }
3438
3439        async fn list_tasks(
3440            &self,
3441            _params: &HandlerServiceParams,
3442            req: ListTasksRequest,
3443        ) -> Result<ListTasksResponse, A2AError> {
3444            if req.context_id.as_deref() == Some("error") {
3445                return Err(A2AError::invalid_params("list failed"));
3446            }
3447
3448            let tasks = self
3449                .state
3450                .tasks
3451                .lock()
3452                .unwrap()
3453                .values()
3454                .filter(|task| {
3455                    req.context_id
3456                        .as_ref()
3457                        .map(|context_id| task.context_id == *context_id)
3458                        .unwrap_or(true)
3459                })
3460                .filter(|task| {
3461                    req.status
3462                        .as_ref()
3463                        .map(|status| task.status.state == *status)
3464                        .unwrap_or(true)
3465                })
3466                .cloned()
3467                .collect();
3468
3469            Ok(ListTasksResponse {
3470                tasks,
3471                next_page_token: String::new(),
3472                page_size: 0,
3473                total_size: 0,
3474            })
3475        }
3476
3477        async fn cancel_task(
3478            &self,
3479            _params: &HandlerServiceParams,
3480            req: CancelTaskRequest,
3481        ) -> Result<Task, A2AError> {
3482            let mut tasks = self.state.tasks.lock().unwrap();
3483            let task = tasks
3484                .get(&req.id)
3485                .cloned()
3486                .ok_or_else(|| A2AError::task_not_found(&req.id))?;
3487            let canceled =
3488                make_fixture_task(&task.id, &task.context_id, TaskState::Canceled, "canceled");
3489            tasks.insert(req.id, canceled.clone());
3490            Ok(canceled)
3491        }
3492
3493        async fn subscribe_to_task(
3494            &self,
3495            _params: &HandlerServiceParams,
3496            req: SubscribeToTaskRequest,
3497        ) -> Result<BoxStream<'static, Result<StreamResponse, A2AError>>, A2AError> {
3498            if req.id == "stream-error" {
3499                return Ok(Box::pin(stream::once(async {
3500                    Err(A2AError::internal("stream failed"))
3501                })));
3502            }
3503
3504            let task = self
3505                .state
3506                .tasks
3507                .lock()
3508                .unwrap()
3509                .get(&req.id)
3510                .cloned()
3511                .ok_or_else(|| A2AError::task_not_found(&req.id))?;
3512
3513            Ok(Box::pin(stream::iter(vec![
3514                Ok(StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
3515                    task_id: req.id.clone(),
3516                    context_id: task.context_id.clone(),
3517                    status: TaskStatus {
3518                        state: TaskState::Working,
3519                        message: None,
3520                        timestamp: None,
3521                    },
3522                    metadata: None,
3523                })),
3524                Ok(StreamResponse::Task(task)),
3525            ])))
3526        }
3527
3528        async fn create_push_config(
3529            &self,
3530            _params: &HandlerServiceParams,
3531            req: TaskPushNotificationConfig,
3532        ) -> Result<TaskPushNotificationConfig, A2AError> {
3533            if !self.state.tasks.lock().unwrap().contains_key(&req.task_id) {
3534                return Err(A2AError::task_not_found(&req.task_id));
3535            }
3536
3537            let config_id = req.id.clone().unwrap_or_else(|| "generated".to_string());
3538            self.state
3539                .push_configs
3540                .lock()
3541                .unwrap()
3542                .insert((req.task_id.clone(), config_id), req.clone());
3543            Ok(req)
3544        }
3545
3546        async fn get_push_config(
3547            &self,
3548            _params: &HandlerServiceParams,
3549            req: GetTaskPushNotificationConfigRequest,
3550        ) -> Result<TaskPushNotificationConfig, A2AError> {
3551            self.state
3552                .push_configs
3553                .lock()
3554                .unwrap()
3555                .get(&(req.task_id.clone(), req.id.clone()))
3556                .cloned()
3557                .ok_or_else(|| A2AError::task_not_found(&req.task_id))
3558        }
3559
3560        async fn list_push_configs(
3561            &self,
3562            _params: &HandlerServiceParams,
3563            req: ListTaskPushNotificationConfigsRequest,
3564        ) -> Result<ListTaskPushNotificationConfigsResponse, A2AError> {
3565            if req.task_id == "missing" {
3566                return Err(A2AError::task_not_found(&req.task_id));
3567            }
3568
3569            let configs = self
3570                .state
3571                .push_configs
3572                .lock()
3573                .unwrap()
3574                .values()
3575                .filter(|config| config.task_id == req.task_id)
3576                .cloned()
3577                .collect();
3578
3579            Ok(ListTaskPushNotificationConfigsResponse {
3580                configs,
3581                next_page_token: None,
3582            })
3583        }
3584
3585        async fn delete_push_config(
3586            &self,
3587            _params: &HandlerServiceParams,
3588            req: DeleteTaskPushNotificationConfigRequest,
3589        ) -> Result<(), A2AError> {
3590            let deleted = self
3591                .state
3592                .push_configs
3593                .lock()
3594                .unwrap()
3595                .remove(&(req.task_id.clone(), req.id.clone()));
3596            if deleted.is_none() {
3597                return Err(A2AError::task_not_found(&req.task_id));
3598            }
3599            Ok(())
3600        }
3601
3602        async fn get_extended_agent_card(
3603            &self,
3604            _params: &HandlerServiceParams,
3605            req: GetExtendedAgentCardRequest,
3606        ) -> Result<AgentCard, A2AError> {
3607            if req.tenant.as_deref() == Some("error") {
3608                return Err(A2AError::unsupported_operation("extended card denied"));
3609            }
3610
3611            Ok(self.extended_card.clone())
3612        }
3613    }
3614
3615    fn make_fixture_card(base_url: &str, name: &str) -> AgentCard {
3616        AgentCard {
3617            name: name.to_string(),
3618            description: "CLI unit-test fixture".to_string(),
3619            version: VERSION.to_string(),
3620            supported_interfaces: vec![
3621                AgentInterface::new(format!("{base_url}/jsonrpc"), TRANSPORT_PROTOCOL_JSONRPC),
3622                AgentInterface::new(format!("{base_url}/rest"), TRANSPORT_PROTOCOL_HTTP_JSON),
3623            ],
3624            capabilities: AgentCapabilities {
3625                streaming: Some(true),
3626                push_notifications: Some(true),
3627                extensions: None,
3628                extended_agent_card: Some(true),
3629            },
3630            default_input_modes: vec!["text/plain".to_string()],
3631            default_output_modes: vec!["text/plain".to_string()],
3632            skills: vec![],
3633            provider: None,
3634            documentation_url: None,
3635            icon_url: None,
3636            security_schemes: None,
3637            security_requirements: None,
3638            signatures: None,
3639        }
3640    }
3641
3642    fn make_fixture_task(task_id: &str, context_id: &str, state: TaskState, text: &str) -> Task {
3643        Task {
3644            id: task_id.to_string(),
3645            context_id: context_id.to_string(),
3646            status: TaskStatus {
3647                state,
3648                message: Some(Message {
3649                    message_id: format!("msg-{task_id}"),
3650                    context_id: Some(context_id.to_string()),
3651                    task_id: Some(task_id.to_string()),
3652                    role: Role::Agent,
3653                    parts: vec![Part::text(text)],
3654                    metadata: None,
3655                    extensions: None,
3656                    reference_task_ids: None,
3657                }),
3658                timestamp: None,
3659            },
3660            artifacts: None,
3661            history: None,
3662            metadata: None,
3663        }
3664    }
3665
3666    fn build_args(base_url: &str, args: &[&str]) -> Vec<String> {
3667        let mut argv = vec![
3668            "a2acli".to_string(),
3669            "--agent-card".to_string(),
3670            base_url.to_string(),
3671        ];
3672        argv.extend(args.iter().map(|arg| (*arg).to_string()));
3673        argv
3674    }
3675
3676    /// Parse and run `a2acli` exactly as the real binary does, so the
3677    /// `ArgMatches`-derived part ordering (`resolve_message_parts`) is
3678    /// exercised the same way in tests as in production.
3679    async fn run_test_cli(base_url: &str, args: &[&str]) -> Result<(), CliError> {
3680        run_args(build_args(base_url, args)).await
3681    }
3682
3683    async fn unused_base_url() -> String {
3684        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
3685        let addr = listener.local_addr().unwrap();
3686        drop(listener);
3687        format!("http://{addr}")
3688    }
3689
3690    fn assert_unsupported_operation<T>(result: Result<T, A2AError>) {
3691        match result {
3692            Ok(_) => panic!("expected unsupported operation error"),
3693            Err(err) => assert_eq!(err.code, a2a::error_code::UNSUPPORTED_OPERATION),
3694        }
3695    }
3696
3697    #[test]
3698    fn test_parse_header() {
3699        let header = parse_header("Authorization: Bearer token").unwrap();
3700        assert_eq!(header.name, "Authorization");
3701        assert_eq!(header.value, "Bearer token");
3702    }
3703
3704    #[test]
3705    fn test_parse_header_requires_separator() {
3706        let err = parse_header("Authorization").unwrap_err();
3707        assert_eq!(err, "header must be in NAME:VALUE format");
3708    }
3709
3710    #[test]
3711    fn test_parse_header_requires_name() {
3712        let err = parse_header(": value").unwrap_err();
3713        assert_eq!(err, "header name cannot be empty");
3714    }
3715
3716    #[test]
3717    fn test_build_send_message_request_populates_optional_fields() {
3718        let request = build_send_message_request(
3719            &MessageCommand {
3720                text: Some("hello".to_string()),
3721                text_parts: Vec::new(),
3722                file_parts: Vec::new(),
3723                data_parts: Vec::new(),
3724                media_types: Vec::new(),
3725                context_id: Some("ctx-1".to_string()),
3726                task_id: Some("task-1".to_string()),
3727                history_length: Some(4),
3728                accepted_output_modes: vec!["text/plain".to_string()],
3729                return_immediately: true,
3730                stream: false,
3731            },
3732            vec![Part::text("hello")],
3733            Some("tenant-1".to_string()),
3734        );
3735
3736        assert_eq!(request.message.text(), Some("hello"));
3737        assert_eq!(request.message.context_id.as_deref(), Some("ctx-1"));
3738        assert_eq!(request.message.task_id.as_deref(), Some("task-1"));
3739        assert_eq!(request.tenant.as_deref(), Some("tenant-1"));
3740        assert_eq!(
3741            request
3742                .configuration
3743                .as_ref()
3744                .and_then(|config| config.history_length),
3745            Some(4)
3746        );
3747        assert_eq!(
3748            request
3749                .configuration
3750                .as_ref()
3751                .and_then(|config| config.return_immediately),
3752            Some(true)
3753        );
3754    }
3755
3756    #[test]
3757    fn test_build_send_message_request_without_optional_fields() {
3758        let request = build_send_message_request(
3759            &MessageCommand {
3760                text: Some("hello".to_string()),
3761                text_parts: Vec::new(),
3762                file_parts: Vec::new(),
3763                data_parts: Vec::new(),
3764                media_types: Vec::new(),
3765                context_id: None,
3766                task_id: None,
3767                history_length: None,
3768                accepted_output_modes: Vec::new(),
3769                return_immediately: false,
3770                stream: false,
3771            },
3772            vec![Part::text("hello")],
3773            None,
3774        );
3775
3776        assert_eq!(request.message.text(), Some("hello"));
3777        assert!(request.configuration.is_none());
3778        assert!(request.tenant.is_none());
3779    }
3780
3781    #[test]
3782    fn test_cli_parse_send_command() {
3783        let cli = Cli::try_parse_from([
3784            "a2acli",
3785            "--transport",
3786            "jsonrpc",
3787            "--svc-param",
3788            "X-Test:123",
3789            "send",
3790            "hello",
3791            "--history-length",
3792            "2",
3793        ])
3794        .unwrap();
3795
3796        assert_eq!(cli.transport, vec![Binding::Jsonrpc]);
3797        assert_eq!(cli.svc_params.len(), 1);
3798        assert!(matches!(cli.command, Command::Send(_)));
3799    }
3800
3801    #[test]
3802    fn test_cli_parse_push_config_create_command() {
3803        let cli = Cli::try_parse_from([
3804            "a2acli",
3805            "task",
3806            "push-config",
3807            "create",
3808            "task-1",
3809            "https://example.com/callback",
3810            "--config-id",
3811            "cfg-1",
3812            "--token",
3813            "tok-1",
3814            "--auth-scheme",
3815            "Bearer",
3816            "--auth-credentials",
3817            "secret",
3818        ])
3819        .unwrap();
3820
3821        match cli.command {
3822            Command::Task {
3823                command:
3824                    TaskCommand::PushConfig {
3825                        command: PushConfigCommand::Create(command),
3826                    },
3827            } => {
3828                assert_eq!(command.task_id, "task-1");
3829                assert_eq!(command.url, "https://example.com/callback");
3830                assert_eq!(command.config_id.as_deref(), Some("cfg-1"));
3831                assert_eq!(command.token.as_deref(), Some("tok-1"));
3832                assert_eq!(command.auth_scheme.as_deref(), Some("Bearer"));
3833                assert_eq!(command.auth_credentials.as_deref(), Some("secret"));
3834            }
3835            other => panic!("unexpected command: {other:?}"),
3836        }
3837    }
3838
3839    #[test]
3840    fn test_build_push_notification_config() {
3841        let config = build_push_notification_config(&CreatePushConfigCommand {
3842            task_id: "task-1".to_string(),
3843            url: "https://example.com/callback".to_string(),
3844            config_id: Some("cfg-1".to_string()),
3845            token: Some("tok-1".to_string()),
3846            auth_scheme: Some("Bearer".to_string()),
3847            auth_credentials: Some("secret".to_string()),
3848        })
3849        .unwrap();
3850
3851        assert_eq!(config.id.as_deref(), Some("cfg-1"));
3852        assert_eq!(config.token.as_deref(), Some("tok-1"));
3853        assert_eq!(
3854            config
3855                .authentication
3856                .as_ref()
3857                .map(|auth| auth.scheme.as_str()),
3858            Some("Bearer")
3859        );
3860        assert_eq!(
3861            config
3862                .authentication
3863                .as_ref()
3864                .and_then(|auth| auth.credentials.as_deref()),
3865            Some("secret")
3866        );
3867    }
3868
3869    #[test]
3870    fn test_build_push_notification_config_requires_auth_scheme() {
3871        let err = build_push_notification_config(&CreatePushConfigCommand {
3872            task_id: "task-1".to_string(),
3873            url: "https://example.com/callback".to_string(),
3874            config_id: None,
3875            token: None,
3876            auth_scheme: None,
3877            auth_credentials: Some("secret".to_string()),
3878        })
3879        .unwrap_err();
3880
3881        assert!(matches!(err, CliError::InvalidInput(_)));
3882    }
3883
3884    #[test]
3885    fn test_build_push_notification_config_without_authentication() {
3886        let config = build_push_notification_config(&CreatePushConfigCommand {
3887            task_id: "task-1".to_string(),
3888            url: "https://example.com/callback".to_string(),
3889            config_id: None,
3890            token: None,
3891            auth_scheme: None,
3892            auth_credentials: None,
3893        })
3894        .unwrap();
3895
3896        assert_eq!(config.url, "https://example.com/callback");
3897        assert!(config.authentication.is_none());
3898    }
3899
3900    #[test]
3901    fn test_binding_protocols() {
3902        assert_eq!(Binding::Jsonrpc.protocol(), TRANSPORT_PROTOCOL_JSONRPC);
3903        assert_eq!(Binding::Rest.protocol(), TRANSPORT_PROTOCOL_HTTP_JSON);
3904    }
3905
3906    #[test]
3907    fn test_apply_request_auth_builds_headers() {
3908        let cli = Cli::try_parse_from([
3909            "a2acli",
3910            "--bearer",
3911            "secret",
3912            "--api-key",
3913            "key-123",
3914            "--svc-param",
3915            "X-Test: 123",
3916            "card",
3917            "get",
3918        ])
3919        .unwrap();
3920
3921        let request = apply_request_auth(Client::new().get("http://example.com"), &cli)
3922            .build()
3923            .unwrap();
3924
3925        assert_eq!(
3926            request.headers().get(header::AUTHORIZATION).unwrap(),
3927            "Bearer secret"
3928        );
3929        assert_eq!(request.headers().get("X-API-Key").unwrap(), "key-123");
3930        assert_eq!(request.headers().get("X-Test").unwrap(), "123");
3931    }
3932
3933    #[tokio::test]
3934    async fn test_finish_client_call_propagates_destroy_error() {
3935        let err = finish_client_call(
3936            make_test_client(Some(A2AError::internal("destroy failed"))),
3937            Ok(serde_json::json!({ "ok": true })),
3938        )
3939        .await
3940        .unwrap_err();
3941
3942        assert!(matches!(err, CliError::A2A(_)));
3943    }
3944
3945    fn json_cli() -> Cli {
3946        parse_with_matches(&["--output", "json", "task", "get", "unused"]).0
3947    }
3948
3949    #[tokio::test]
3950    async fn test_consume_stream_reports_json_error() {
3951        let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
3952        let err = consume_stream(make_test_client(None), stream, &json_cli())
3953            .await
3954            .unwrap_err();
3955
3956        assert!(matches!(err, CliError::Json(_)));
3957    }
3958
3959    /// `print_or_destroy` is the print-failure path both `consume_stream`
3960    /// and `subscribe_with_resumption` share; exercised directly since
3961    /// `StreamResponse`'s own `Serialize` impl never fails for a real
3962    /// value, so this path is otherwise unreachable through
3963    /// `subscribe_with_resumption` specifically.
3964    #[tokio::test]
3965    async fn test_print_or_destroy_reports_json_error() {
3966        let client = make_test_client(None);
3967        let err = print_or_destroy(&client, &FailingSerialize, &json_cli())
3968            .await
3969            .unwrap_err();
3970
3971        assert!(matches!(err, CliError::Json(_)));
3972    }
3973
3974    #[tokio::test]
3975    async fn test_print_or_destroy_succeeds_in_text_mode() {
3976        let client = make_test_client(None);
3977        print_or_destroy(&client, &FailingSerialize, &text_cli())
3978            .await
3979            .unwrap();
3980    }
3981
3982    #[tokio::test]
3983    async fn test_consume_stream_propagates_destroy_error_on_completion() {
3984        let stream = Box::pin(stream::empty::<Result<Task, A2AError>>());
3985        let err = consume_stream(
3986            make_test_client(Some(A2AError::internal("destroy failed"))),
3987            stream,
3988            &json_cli(),
3989        )
3990        .await
3991        .unwrap_err();
3992
3993        assert!(matches!(err, CliError::A2A(_)));
3994    }
3995
3996    #[tokio::test]
3997    async fn test_test_transport_methods_return_unused_errors() {
3998        let transport = TestTransport {
3999            destroy_error: None,
4000        };
4001        let params = ServiceParams::new();
4002
4003        assert_unsupported_operation(
4004            transport
4005                .send_message(
4006                    &params,
4007                    &SendMessageRequest {
4008                        message: Message::new(Role::User, vec![Part::text("hello")]),
4009                        configuration: None,
4010                        metadata: None,
4011                        tenant: None,
4012                    },
4013                )
4014                .await,
4015        );
4016        assert_unsupported_operation(
4017            transport
4018                .send_streaming_message(
4019                    &params,
4020                    &SendMessageRequest {
4021                        message: Message::new(Role::User, vec![Part::text("hello")]),
4022                        configuration: None,
4023                        metadata: None,
4024                        tenant: None,
4025                    },
4026                )
4027                .await,
4028        );
4029        assert_unsupported_operation(
4030            transport
4031                .get_task(
4032                    &params,
4033                    &GetTaskRequest {
4034                        id: "task-1".to_string(),
4035                        history_length: None,
4036                        tenant: None,
4037                    },
4038                )
4039                .await,
4040        );
4041        assert_unsupported_operation(
4042            transport
4043                .list_tasks(
4044                    &params,
4045                    &ListTasksRequest {
4046                        context_id: None,
4047                        status: None,
4048                        page_size: None,
4049                        page_token: None,
4050                        history_length: None,
4051                        status_timestamp_after: None,
4052                        include_artifacts: None,
4053                        tenant: None,
4054                    },
4055                )
4056                .await,
4057        );
4058        assert_unsupported_operation(
4059            transport
4060                .cancel_task(
4061                    &params,
4062                    &CancelTaskRequest {
4063                        id: "task-1".to_string(),
4064                        metadata: None,
4065                        tenant: None,
4066                    },
4067                )
4068                .await,
4069        );
4070        assert_unsupported_operation(
4071            transport
4072                .subscribe_to_task(
4073                    &params,
4074                    &SubscribeToTaskRequest {
4075                        id: "task-1".to_string(),
4076                        tenant: None,
4077                    },
4078                )
4079                .await,
4080        );
4081        assert_unsupported_operation(
4082            transport
4083                .create_push_config(
4084                    &params,
4085                    &TaskPushNotificationConfig {
4086                        task_id: "task-1".to_string(),
4087                        url: "https://example.com/callback".to_string(),
4088                        id: Some("cfg-1".to_string()),
4089                        token: None,
4090                        authentication: None,
4091                        tenant: None,
4092                    },
4093                )
4094                .await,
4095        );
4096        assert_unsupported_operation(
4097            transport
4098                .get_push_config(
4099                    &params,
4100                    &GetTaskPushNotificationConfigRequest {
4101                        task_id: "task-1".to_string(),
4102                        id: "cfg-1".to_string(),
4103                        tenant: None,
4104                    },
4105                )
4106                .await,
4107        );
4108        assert_unsupported_operation(
4109            transport
4110                .list_push_configs(
4111                    &params,
4112                    &ListTaskPushNotificationConfigsRequest {
4113                        task_id: "task-1".to_string(),
4114                        page_size: None,
4115                        page_token: None,
4116                        tenant: None,
4117                    },
4118                )
4119                .await,
4120        );
4121        assert_unsupported_operation(
4122            transport
4123                .delete_push_config(
4124                    &params,
4125                    &DeleteTaskPushNotificationConfigRequest {
4126                        task_id: "task-1".to_string(),
4127                        id: "cfg-1".to_string(),
4128                        tenant: None,
4129                    },
4130                )
4131                .await,
4132        );
4133        assert_unsupported_operation(
4134            transport
4135                .get_extended_agent_card(&params, &GetExtendedAgentCardRequest { tenant: None })
4136                .await,
4137        );
4138    }
4139
4140    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4141    async fn test_run_executes_all_commands_in_lib_tests() {
4142        let server = RunTestServer::spawn().await;
4143
4144        run_test_cli(&server.base_url, &["card", "get"])
4145            .await
4146            .unwrap();
4147        run_test_cli(
4148            &server.base_url,
4149            &["--compact", "card", "get", "--extended"],
4150        )
4151        .await
4152        .unwrap();
4153        run_test_cli(
4154            &server.base_url,
4155            &[
4156                "--transport",
4157                "jsonrpc",
4158                "--bearer",
4159                "secret",
4160                "--svc-param",
4161                "X-Test: 123",
4162                "send",
4163                "hello from unit test",
4164                "--task-id",
4165                "task-send",
4166                "--context-id",
4167                "ctx-send",
4168            ],
4169        )
4170        .await
4171        .unwrap();
4172        run_test_cli(
4173            &server.base_url,
4174            &[
4175                "--compact",
4176                "send",
4177                "streaming request",
4178                "--stream",
4179                "--task-id",
4180                "task-stream",
4181                "--context-id",
4182                "ctx-stream",
4183            ],
4184        )
4185        .await
4186        .unwrap();
4187        run_test_cli(&server.base_url, &["task", "get", "task-send"])
4188            .await
4189            .unwrap();
4190        run_test_cli(
4191            &server.base_url,
4192            &[
4193                "--compact",
4194                "task",
4195                "list",
4196                "--context-id",
4197                "ctx-send",
4198                "--status",
4199                "completed",
4200            ],
4201        )
4202        .await
4203        .unwrap();
4204        run_test_cli(&server.base_url, &["task", "cancel", "task-send"])
4205            .await
4206            .unwrap();
4207        run_test_cli(
4208            &server.base_url,
4209            &["--compact", "task", "subscribe", "task-stream"],
4210        )
4211        .await
4212        .unwrap();
4213        run_test_cli(
4214            &server.base_url,
4215            &[
4216                "task",
4217                "push-config",
4218                "create",
4219                "task-1",
4220                "https://example.com/callback",
4221                "--config-id",
4222                "cfg-1",
4223            ],
4224        )
4225        .await
4226        .unwrap();
4227        run_test_cli(
4228            &server.base_url,
4229            &["--compact", "task", "push-config", "get", "task-1", "cfg-1"],
4230        )
4231        .await
4232        .unwrap();
4233        run_test_cli(
4234            &server.base_url,
4235            &["--compact", "task", "push-config", "list", "task-1"],
4236        )
4237        .await
4238        .unwrap();
4239        run_test_cli(
4240            &server.base_url,
4241            &["task", "push-config", "delete", "task-1", "cfg-1"],
4242        )
4243        .await
4244        .unwrap();
4245    }
4246
4247    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4248    async fn test_run_surfaces_errors_in_lib_tests() {
4249        let server = RunTestServer::spawn().await;
4250
4251        let err = run_test_cli(
4252            &server.base_url,
4253            &["card", "get", "--extended", "--tenant", "error"],
4254        )
4255        .await
4256        .unwrap_err();
4257        assert!(matches!(
4258            err,
4259            CliError::A2A(error) if error.code == a2a::error_code::UNSUPPORTED_OPERATION
4260        ));
4261
4262        let err = run_test_cli(&server.base_url, &["send", "send-error"])
4263            .await
4264            .unwrap_err();
4265        assert!(matches!(
4266            err,
4267            CliError::A2A(error) if error.code == a2a::error_code::INVALID_REQUEST
4268        ));
4269
4270        let err = run_test_cli(&server.base_url, &["task", "list", "--context-id", "error"])
4271            .await
4272            .unwrap_err();
4273        assert!(matches!(
4274            err,
4275            CliError::A2A(error) if error.code == a2a::error_code::INVALID_PARAMS
4276        ));
4277
4278        let err = run_test_cli(
4279            &server.base_url,
4280            &["--compact", "send", "stream-error", "--stream"],
4281        )
4282        .await
4283        .unwrap_err();
4284        assert!(matches!(
4285            err,
4286            CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
4287        ));
4288
4289        let err = run_test_cli(
4290            &server.base_url,
4291            &["--compact", "task", "subscribe", "stream-error"],
4292        )
4293        .await
4294        .unwrap_err();
4295        assert!(matches!(
4296            err,
4297            CliError::A2A(error) if error.code == a2a::error_code::INTERNAL_ERROR
4298        ));
4299
4300        let err = run_test_cli(
4301            &server.base_url,
4302            &[
4303                "task",
4304                "push-config",
4305                "create",
4306                "missing",
4307                "https://example.com/callback",
4308                "--config-id",
4309                "cfg-missing",
4310            ],
4311        )
4312        .await
4313        .unwrap_err();
4314        assert!(matches!(
4315            err,
4316            CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
4317        ));
4318
4319        let err = run_test_cli(
4320            &server.base_url,
4321            &["task", "push-config", "list", "missing"],
4322        )
4323        .await
4324        .unwrap_err();
4325        assert!(matches!(
4326            err,
4327            CliError::A2A(error) if error.code == a2a::error_code::TASK_NOT_FOUND
4328        ));
4329
4330        let base_url = unused_base_url().await;
4331        let err = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
4332        assert!(matches!(err, CliError::Http(_)));
4333    }
4334
4335    #[test]
4336    fn test_task_state_conversion() {
4337        let cases = [
4338            (TaskStateArg::Unspecified, TaskState::Unspecified),
4339            (TaskStateArg::Submitted, TaskState::Submitted),
4340            (TaskStateArg::Working, TaskState::Working),
4341            (TaskStateArg::Completed, TaskState::Completed),
4342            (TaskStateArg::Failed, TaskState::Failed),
4343            (TaskStateArg::Canceled, TaskState::Canceled),
4344            (TaskStateArg::InputRequired, TaskState::InputRequired),
4345            (TaskStateArg::Rejected, TaskState::Rejected),
4346            (TaskStateArg::AuthRequired, TaskState::AuthRequired),
4347        ];
4348
4349        for (input, expected) in cases {
4350            assert_eq!(TaskState::from(input), expected);
4351        }
4352    }
4353
4354    #[test]
4355    fn test_means_not_a_readable_path_classification() {
4356        use std::io::ErrorKind;
4357
4358        // "Doesn't name a readable file here" — --data-part goes on to try
4359        // the value as inline JSON. InvalidFilename is the Windows answer
4360        // for a path containing `{`, `"` or `:`, i.e. inline JSON itself,
4361        // and is why this can't just test for NotFound.
4362        for kind in [
4363            ErrorKind::NotFound,
4364            ErrorKind::InvalidFilename,
4365            ErrorKind::InvalidInput,
4366        ] {
4367            assert!(
4368                means_not_a_readable_path(kind),
4369                "{kind:?} should fall through to inline-JSON parsing"
4370            );
4371        }
4372
4373        // A real file that failed to read — must be reported, not masked.
4374        for kind in [
4375            ErrorKind::PermissionDenied,
4376            ErrorKind::IsADirectory,
4377            ErrorKind::InvalidData,
4378        ] {
4379            assert!(
4380                !means_not_a_readable_path(kind),
4381                "{kind:?} should be surfaced as a read error"
4382            );
4383        }
4384    }
4385
4386    #[test]
4387    fn test_parse_duration_variants() {
4388        assert_eq!(parse_duration("2s").unwrap(), Duration::from_secs(2));
4389        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
4390        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
4391        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
4392        assert_eq!(parse_duration("1.5s").unwrap(), Duration::from_millis(1500));
4393    }
4394
4395    #[test]
4396    fn test_parse_duration_rejects_invalid_input() {
4397        assert!(parse_duration("banana").is_err());
4398        assert!(parse_duration("-1s").is_err());
4399    }
4400
4401    #[test]
4402    fn test_is_settled_classifies_task_states() {
4403        let settled = [
4404            TaskState::Completed,
4405            TaskState::Failed,
4406            TaskState::Canceled,
4407            TaskState::Rejected,
4408            TaskState::InputRequired,
4409            TaskState::AuthRequired,
4410        ];
4411        for state in settled {
4412            assert!(is_settled(&state), "{state:?} should be settled");
4413        }
4414
4415        let unsettled = [
4416            TaskState::Unspecified,
4417            TaskState::Submitted,
4418            TaskState::Working,
4419        ];
4420        for state in unsettled {
4421            assert!(!is_settled(&state), "{state:?} should not be settled");
4422        }
4423    }
4424
4425    fn parse_with_matches(args: &[&str]) -> (Cli, ArgMatches) {
4426        let mut argv = vec!["a2acli".to_string()];
4427        argv.extend(args.iter().map(|arg| (*arg).to_string()));
4428        let matches = Cli::command().try_get_matches_from(argv).unwrap();
4429        let cli = Cli::from_arg_matches(&matches).unwrap();
4430        (cli, matches)
4431    }
4432
4433    fn send_command(cli: &Cli) -> &MessageCommand {
4434        match &cli.command {
4435            Command::Send(command) => command,
4436            other => panic!("expected Command::Send, got {other:?}"),
4437        }
4438    }
4439
4440    #[test]
4441    fn test_resolve_message_parts_preserves_interleaved_order_and_media_type() {
4442        let (cli, matches) = parse_with_matches(&[
4443            "send",
4444            "--text-part",
4445            "hello",
4446            "--file-part",
4447            "https://example.com/doc.pdf",
4448            "--media-type",
4449            "application/pdf",
4450            "--data-part",
4451            r#"{"k":1}"#,
4452        ]);
4453        let send_matches = matches.subcommand_matches("send").unwrap();
4454        let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
4455
4456        assert_eq!(parts.len(), 3);
4457        assert_eq!(parts[0].as_text(), Some("hello"));
4458        assert!(parts[0].media_type.is_none());
4459        assert!(matches!(
4460            &parts[1].content,
4461            PartContent::Url(url) if url == "https://example.com/doc.pdf"
4462        ));
4463        assert_eq!(parts[1].media_type.as_deref(), Some("application/pdf"));
4464        assert!(matches!(&parts[2].content, PartContent::Data(_)));
4465        assert!(parts[2].media_type.is_none());
4466    }
4467
4468    #[test]
4469    fn test_resolve_message_parts_positional_text_is_shorthand() {
4470        let (cli, matches) = parse_with_matches(&["send", "hello"]);
4471        let send_matches = matches.subcommand_matches("send").unwrap();
4472        let parts = resolve_message_parts(send_matches, send_command(&cli)).unwrap();
4473
4474        assert_eq!(parts.len(), 1);
4475        assert_eq!(parts[0].as_text(), Some("hello"));
4476    }
4477
4478    #[test]
4479    fn test_resolve_message_parts_rejects_media_type_without_preceding_part() {
4480        let (cli, matches) =
4481            parse_with_matches(&["send", "--media-type", "text/plain", "--text-part", "hi"]);
4482        let send_matches = matches.subcommand_matches("send").unwrap();
4483        let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
4484        assert!(matches!(err, CliError::InvalidInput(_)));
4485    }
4486
4487    #[test]
4488    fn test_resolve_message_parts_rejects_positional_text_with_part_flags() {
4489        let (cli, matches) = parse_with_matches(&["send", "hello", "--text-part", "world"]);
4490        let send_matches = matches.subcommand_matches("send").unwrap();
4491        let err = resolve_message_parts(send_matches, send_command(&cli)).unwrap_err();
4492        assert!(matches!(err, CliError::InvalidInput(_)));
4493    }
4494
4495    #[test]
4496    fn test_cli_error_exit_codes_and_envelope_codes() {
4497        let a2a = CliError::A2A(A2AError::task_not_found("t-1"));
4498        assert_eq!(a2a.exit_code(), 1);
4499        let envelope = a2a.envelope();
4500        assert_eq!(envelope.error.code, "TASK_NOT_FOUND");
4501        assert_eq!(envelope.error.a2a_code, Some(-32001));
4502
4503        let usage = CliError::InvalidInput("bad flag".to_string());
4504        assert_eq!(usage.exit_code(), 2);
4505        assert_eq!(usage.envelope().error.code, "A2ACLI_ERR_USAGE");
4506        assert!(usage.envelope().error.a2a_code.is_none());
4507
4508        let read_file = CliError::ReadFile {
4509            path: "missing.bin".to_string(),
4510            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
4511        };
4512        assert_eq!(read_file.exit_code(), 2);
4513        assert_eq!(read_file.envelope().error.code, "A2ACLI_ERR_USAGE");
4514
4515        let timeout = CliError::Timeout {
4516            task_id: "t-2".to_string(),
4517            timeout: Duration::from_secs(30),
4518        };
4519        assert_eq!(timeout.exit_code(), 5);
4520        assert_eq!(timeout.envelope().error.code, "A2ACLI_ERR_TIMEOUT");
4521
4522        let json = CliError::Json(serde_json::from_str::<serde_json::Value>("{").unwrap_err());
4523        assert_eq!(json.exit_code(), 1);
4524        assert_eq!(json.envelope().error.code, "A2ACLI_ERR_INTERNAL");
4525    }
4526
4527    #[test]
4528    fn test_cli_error_report_prints_one_json_line_to_stderr() {
4529        // report() itself only writes to the real process stderr, which
4530        // isn't capturable here; assert on the envelope it serializes
4531        // instead, since that's the part the contract cares about.
4532        let error = CliError::A2A(A2AError::unsupported_operation("nope"));
4533        let json = serde_json::to_string(&error.envelope()).unwrap();
4534        assert!(json.contains("\"code\":\"UNSUPPORTED_OPERATION\""));
4535        assert!(!json.contains('\n'));
4536    }
4537
4538    #[test]
4539    fn test_agent_card_render_text() {
4540        let card = AgentCard {
4541            name: "Fixture".to_string(),
4542            description: "desc".to_string(),
4543            version: VERSION.to_string(),
4544            supported_interfaces: vec![AgentInterface::new(
4545                "http://host/jsonrpc",
4546                TRANSPORT_PROTOCOL_JSONRPC,
4547            )],
4548            capabilities: AgentCapabilities {
4549                streaming: Some(true),
4550                push_notifications: Some(false),
4551                extensions: None,
4552                extended_agent_card: None,
4553            },
4554            default_input_modes: vec![],
4555            default_output_modes: vec![],
4556            skills: vec![],
4557            provider: None,
4558            documentation_url: None,
4559            icon_url: None,
4560            security_schemes: None,
4561            security_requirements: None,
4562            signatures: None,
4563        };
4564
4565        let text = card.render_text();
4566        assert!(text.contains("Name: Fixture"));
4567        assert!(text.contains("Streaming: true"));
4568        assert!(text.contains("Push Notifications: false"));
4569        assert!(text.contains("Extended Card: false"));
4570        assert!(text.contains("Interfaces:"));
4571        assert!(text.contains("http://host/jsonrpc"));
4572        assert!(text.contains("Skills: 0"));
4573    }
4574
4575    #[test]
4576    fn test_message_render_text_renders_every_part_kind() {
4577        let mut file_part = Part::raw(vec![1, 2, 3]);
4578        file_part.filename = Some("report.bin".to_string());
4579        file_part.media_type = Some("application/octet-stream".to_string());
4580
4581        let message = Message::new(
4582            Role::Agent,
4583            vec![
4584                Part::text("hello"),
4585                Part::data(serde_json::json!({"k": 1})),
4586                file_part,
4587            ],
4588        );
4589
4590        let text = message.render_text();
4591        assert!(text.contains("Text:\nhello"));
4592        assert!(text.contains("Data:"));
4593        assert!(text.contains("\"k\": 1"));
4594        assert!(text.contains("File: report.bin (application/octet-stream, 3 bytes)"));
4595    }
4596
4597    /// §10.3: a part whose media type the agent didn't declare still has to
4598    /// render — by name and size for inline bytes, by URL for a referenced
4599    /// file — rather than being dropped because the media type is missing.
4600    #[test]
4601    fn test_render_part_without_media_type_falls_back_to_name_and_url() {
4602        let message = Message::new(
4603            Role::Agent,
4604            vec![
4605                Part::raw(vec![7; 12]),
4606                Part::raw(vec![1, 2]).with_filename("notes.txt"),
4607                Part::url("https://example.com/spec.pdf"),
4608                Part::url("https://example.com/report.csv").with_media_type("text/csv"),
4609            ],
4610        );
4611
4612        let text = message.render_text();
4613        // No filename and no media type: neither is invented, and the part
4614        // is still accounted for.
4615        assert!(text.contains("File: (unnamed) (12 bytes)"), "{text}");
4616        assert!(text.contains("File: notes.txt (2 bytes)"), "{text}");
4617        assert!(
4618            text.contains("File: https://example.com/spec.pdf"),
4619            "{text}"
4620        );
4621        assert!(
4622            text.contains("File: https://example.com/report.csv (text/csv)"),
4623            "{text}"
4624        );
4625    }
4626
4627    /// A message with no parts renders an explicit `(none)` rather than
4628    /// nothing at all, so `text` output never leaves the reader unsure
4629    /// whether content was omitted or absent.
4630    #[test]
4631    fn test_message_render_text_marks_an_empty_part_list() {
4632        let message = Message {
4633            message_id: "msg-1".to_string(),
4634            context_id: Some("ctx-1".to_string()),
4635            task_id: Some("task-1".to_string()),
4636            role: Role::Agent,
4637            parts: vec![],
4638            metadata: None,
4639            extensions: None,
4640            reference_task_ids: None,
4641        };
4642
4643        let text = message.render_text();
4644        assert!(text.contains("Context ID: ctx-1"), "{text}");
4645        assert!(text.contains("Task ID: task-1"), "{text}");
4646        assert!(text.contains("Parts: (none)"), "{text}");
4647    }
4648
4649    /// A `send` that answers with a bare `Message` instead of a `Task`
4650    /// renders through the message form (§10.2) — the response enum must not
4651    /// have a task-shaped rendering as its only arm.
4652    #[test]
4653    fn test_send_message_response_renders_the_message_arm() {
4654        let message = Message::new(Role::Agent, vec![Part::text("no task needed")]);
4655        let response = SendMessageResponse::Message(message.clone());
4656
4657        assert_eq!(response.render_text(), message.render_text());
4658        assert!(response.render_text().contains("no task needed"));
4659    }
4660
4661    /// A card that declares no compatible interface renders `(none)` under
4662    /// `Interfaces:` — `card get` is exactly the command you reach for to
4663    /// find out *why* a transport couldn't be selected, so an empty list is
4664    /// the case it most needs to state plainly.
4665    #[test]
4666    fn test_agent_card_render_text_marks_an_empty_interface_list() {
4667        let card = AgentCard {
4668            name: "Bare".to_string(),
4669            description: "no interfaces".to_string(),
4670            version: VERSION.to_string(),
4671            supported_interfaces: vec![],
4672            capabilities: AgentCapabilities {
4673                streaming: None,
4674                push_notifications: None,
4675                extensions: None,
4676                extended_agent_card: None,
4677            },
4678            default_input_modes: vec![],
4679            default_output_modes: vec![],
4680            skills: vec![],
4681            provider: None,
4682            documentation_url: None,
4683            icon_url: None,
4684            security_schemes: None,
4685            security_requirements: None,
4686            signatures: None,
4687        };
4688
4689        let text = card.render_text();
4690        assert!(text.contains("Interfaces:\n  (none)"), "{text}");
4691        // Absent capabilities read as `false`, never as a missing line.
4692        assert!(text.contains("Streaming: false"), "{text}");
4693    }
4694
4695    /// §10.3: artifacts are rendered per artifact, labeled by name when the
4696    /// agent gave one and by artifact id when it didn't, with their parts
4697    /// rendered underneath — never summarized as a count.
4698    #[test]
4699    fn test_task_render_text_lists_artifacts_and_resume_hint() {
4700        let mut task = make_fixture_task(
4701            "task-art",
4702            "ctx-art",
4703            TaskState::InputRequired,
4704            "which region?",
4705        );
4706        task.artifacts = Some(vec![
4707            Artifact {
4708                artifact_id: "art-1".to_string(),
4709                name: Some("summary".to_string()),
4710                description: None,
4711                parts: vec![Part::text("all clear")],
4712                metadata: None,
4713                extensions: None,
4714            },
4715            Artifact {
4716                artifact_id: "art-2".to_string(),
4717                name: None,
4718                description: None,
4719                parts: vec![Part::data(serde_json::json!({"rows": 3}))],
4720                metadata: None,
4721                extensions: None,
4722            },
4723        ]);
4724
4725        let text = task.render_text();
4726        assert!(text.contains("Artifact: summary"), "{text}");
4727        assert!(text.contains("all clear"), "{text}");
4728        // Unnamed artifact falls back to its id rather than rendering blank.
4729        assert!(text.contains("Artifact: art-2"), "{text}");
4730        assert!(text.contains("\"rows\": 3"), "{text}");
4731        // §9.2: an interrupted task always carries the resume command.
4732        assert!(
4733            text.contains("Resume with: a2acli send --task-id task-art \"<reply>\""),
4734            "{text}"
4735        );
4736    }
4737
4738    /// §9.1: every task state has a distinct short label, and none of them
4739    /// renders as the protocol's wire spelling — that form belongs to
4740    /// `-o json` only.
4741    #[test]
4742    fn test_task_state_label_covers_every_state() {
4743        let cases = [
4744            (TaskState::Unspecified, "UNSPECIFIED"),
4745            (TaskState::Submitted, "SUBMITTED"),
4746            (TaskState::Working, "WORKING"),
4747            (TaskState::Completed, "COMPLETED"),
4748            (TaskState::Failed, "FAILED"),
4749            (TaskState::Canceled, "CANCELED"),
4750            (TaskState::InputRequired, "INPUT_REQUIRED"),
4751            (TaskState::Rejected, "REJECTED"),
4752            (TaskState::AuthRequired, "AUTH_REQUIRED"),
4753        ];
4754
4755        for (state, expected) in cases {
4756            assert_eq!(task_state_label(&state), expected);
4757            assert!(!expected.starts_with("TASK_STATE_"));
4758        }
4759    }
4760
4761    #[test]
4762    fn test_list_tasks_render_text_empty_and_paged() {
4763        let empty = ListTasksResponse {
4764            tasks: vec![],
4765            next_page_token: "next-token".to_string(),
4766            page_size: 10,
4767            total_size: 0,
4768        };
4769
4770        let text = empty.render_text();
4771        assert!(text.contains("Total: 0"), "{text}");
4772        assert!(text.contains("Page Size: 10"), "{text}");
4773        assert!(text.contains("Next Page Token: next-token"), "{text}");
4774        assert!(text.contains("Tasks: (none)"), "{text}");
4775
4776        let populated = ListTasksResponse {
4777            tasks: vec![make_fixture_task(
4778                "task-1",
4779                "ctx-1",
4780                TaskState::Completed,
4781                "done",
4782            )],
4783            next_page_token: String::new(),
4784            page_size: 10,
4785            total_size: 1,
4786        };
4787
4788        let text = populated.render_text();
4789        // An empty page token is omitted rather than rendered as a blank
4790        // field, so `text` output never suggests there is a next page.
4791        assert!(!text.contains("Next Page Token"), "{text}");
4792        assert!(text.contains("Task:"), "{text}");
4793        assert!(text.contains("Task ID: task-1"), "{text}");
4794    }
4795
4796    #[test]
4797    fn test_push_config_render_text_includes_token_and_auth_scheme() {
4798        let config = TaskPushNotificationConfig {
4799            url: "https://example.com/hook".to_string(),
4800            id: Some("cfg-1".to_string()),
4801            task_id: "task-1".to_string(),
4802            token: Some("tok-1".to_string()),
4803            authentication: Some(AuthenticationInfo {
4804                scheme: "Bearer".to_string(),
4805                credentials: Some("super-secret".to_string()),
4806            }),
4807            tenant: None,
4808        };
4809
4810        let text = config.render_text();
4811        assert!(text.contains("Config ID: cfg-1"), "{text}");
4812        assert!(text.contains("Task ID: task-1"), "{text}");
4813        assert!(text.contains("URL: https://example.com/hook"), "{text}");
4814        assert!(text.contains("Token: tok-1"), "{text}");
4815        assert!(text.contains("Auth Scheme: Bearer"), "{text}");
4816        // The scheme is useful; the credential behind it is never echoed.
4817        assert!(!text.contains("super-secret"), "{text}");
4818    }
4819
4820    #[test]
4821    fn test_list_push_configs_render_text_empty_and_paged() {
4822        let empty = ListTaskPushNotificationConfigsResponse {
4823            configs: vec![],
4824            next_page_token: Some("next-token".to_string()),
4825        };
4826
4827        let text = empty.render_text();
4828        assert!(text.contains("Next Page Token: next-token"), "{text}");
4829        assert!(text.contains("Configs: (none)"), "{text}");
4830
4831        let populated = ListTaskPushNotificationConfigsResponse {
4832            configs: vec![TaskPushNotificationConfig {
4833                url: "https://example.com/hook".to_string(),
4834                id: Some("cfg-1".to_string()),
4835                task_id: "task-1".to_string(),
4836                token: None,
4837                authentication: None,
4838                tenant: None,
4839            }],
4840            next_page_token: None,
4841        };
4842
4843        let text = populated.render_text();
4844        assert!(!text.contains("Next Page Token"), "{text}");
4845        assert!(text.contains("Push Config:"), "{text}");
4846        assert!(text.contains("Config ID: cfg-1"), "{text}");
4847    }
4848
4849    #[test]
4850    fn test_push_config_deleted_render_text() {
4851        let deleted = PushConfigDeleted {
4852            deleted: true,
4853            task_id: "task-1".to_string(),
4854            id: "cfg-1".to_string(),
4855        };
4856
4857        let text = deleted.render_text();
4858        assert!(text.contains("Deleted: true"), "{text}");
4859        assert!(text.contains("Task ID: task-1"), "{text}");
4860        assert!(text.contains("Config ID: cfg-1"), "{text}");
4861    }
4862
4863    /// Under `--stream` each event is rendered on its own, so the update
4864    /// events need renderings of their own — a status update that pauses the
4865    /// task still has to carry the resume command (§9.2), and an artifact
4866    /// update still has to show the artifact's parts (§10.3).
4867    #[test]
4868    fn test_stream_response_render_text_covers_update_events() {
4869        let status = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
4870            task_id: "task-s".to_string(),
4871            context_id: "ctx-s".to_string(),
4872            status: TaskStatus {
4873                state: TaskState::AuthRequired,
4874                message: None,
4875                timestamp: None,
4876            },
4877            metadata: None,
4878        });
4879
4880        let text = status.render_text();
4881        assert!(text.contains("Task ID: task-s"), "{text}");
4882        assert!(text.contains("Context ID: ctx-s"), "{text}");
4883        assert!(text.contains("State: AUTH_REQUIRED"), "{text}");
4884        assert!(
4885            text.contains("Resume with: a2acli send --task-id task-s \"<reply>\""),
4886            "{text}"
4887        );
4888
4889        // A state that isn't interrupted gets no resume line to act on.
4890        let working = StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
4891            task_id: "task-s".to_string(),
4892            context_id: "ctx-s".to_string(),
4893            status: TaskStatus {
4894                state: TaskState::Working,
4895                message: None,
4896                timestamp: None,
4897            },
4898            metadata: None,
4899        });
4900        assert!(!working.render_text().contains("Resume with"));
4901
4902        let artifact = StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
4903            task_id: "task-s".to_string(),
4904            context_id: "ctx-s".to_string(),
4905            artifact: Artifact {
4906                artifact_id: "art-9".to_string(),
4907                name: None,
4908                description: None,
4909                parts: vec![Part::text("chunk one")],
4910                metadata: None,
4911                extensions: None,
4912            },
4913            append: None,
4914            last_chunk: None,
4915            metadata: None,
4916        });
4917
4918        let text = artifact.render_text();
4919        assert!(text.contains("Task ID: task-s"), "{text}");
4920        assert!(text.contains("Artifact: art-9"), "{text}");
4921        assert!(text.contains("chunk one"), "{text}");
4922
4923        // A stream may also carry whole tasks and messages, which render
4924        // through their own forms rather than a stream-specific one.
4925        let task = make_fixture_task("task-s", "ctx-s", TaskState::Completed, "done");
4926        assert_eq!(
4927            StreamResponse::Task(task.clone()).render_text(),
4928            task.render_text()
4929        );
4930        let message = Message::new(Role::Agent, vec![Part::text("interim")]);
4931        assert_eq!(
4932            StreamResponse::Message(message.clone()).render_text(),
4933            message.render_text()
4934        );
4935    }
4936
4937    fn text_cli() -> Cli {
4938        parse_with_matches(&["task", "get", "unused"]).0
4939    }
4940
4941    /// `text` mode renders through [`TextRender`] and never through
4942    /// `Serialize`, so a value that cannot be serialized still streams — the
4943    /// mirror of `test_consume_stream_reports_json_error`.
4944    #[tokio::test]
4945    async fn test_consume_stream_text_mode_does_not_serialize() {
4946        let stream = Box::pin(stream::once(async { Ok(FailingSerialize) }));
4947        consume_stream(make_test_client(None), stream, &text_cli())
4948            .await
4949            .unwrap();
4950    }
4951
4952    /// Malformed JSON in a `--data-part` file is the caller's input to fix,
4953    /// so it surfaces as a usage error naming the source — not as the
4954    /// generic internal `CliError::Json`.
4955    #[test]
4956    fn test_parse_data_part_json_rejects_malformed_content() {
4957        let error = parse_data_part_json("{not json", "payload.json").unwrap_err();
4958
4959        assert_eq!(error.exit_code(), 2);
4960        let message = error.to_string();
4961        assert!(message.contains("payload.json"), "{message}");
4962        assert!(matches!(error, CliError::InvalidInput(_)));
4963    }
4964
4965    /// A server that answers the well-known Agent Card path with exactly one
4966    /// canned response and serves nothing else — enough to drive Appendix
4967    /// D's classification of a card fetch, which needs a real
4968    /// `reqwest::Error` (the type has no public constructor, so the
4969    /// classification can't be asserted on a synthetic one).
4970    struct CardOnlyServer {
4971        base_url: String,
4972        handle: tokio::task::JoinHandle<()>,
4973    }
4974
4975    impl Drop for CardOnlyServer {
4976        fn drop(&mut self) {
4977            self.handle.abort();
4978        }
4979    }
4980
4981    impl CardOnlyServer {
4982        async fn spawn(status: axum::http::StatusCode, body: &'static str) -> Self {
4983            let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
4984            let base_url = format!("http://{}", listener.local_addr().unwrap());
4985            let app = Router::new().route(
4986                WELL_KNOWN_AGENT_CARD_PATH,
4987                get(move || async move {
4988                    (
4989                        status,
4990                        [(header::CONTENT_TYPE, "application/json")],
4991                        body.to_string(),
4992                    )
4993                }),
4994            );
4995            let handle = tokio::spawn(async move {
4996                axum::serve(listener, app).await.unwrap();
4997            });
4998
4999            CardOnlyServer { base_url, handle }
5000        }
5001    }
5002
5003    /// Appendix D: a card fetch distinguishes "the agent isn't there" from
5004    /// "the agent refused you" from "that isn't an agent card", because the
5005    /// three need different fixes — and each maps to its own exit status
5006    /// (§11.6) so a script can branch on it.
5007    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5008    async fn test_card_fetch_failures_are_classified_per_appendix_d() {
5009        let not_found = CardOnlyServer::spawn(axum::http::StatusCode::NOT_FOUND, "{}").await;
5010        let error = run_test_cli(&not_found.base_url, &["card", "get"])
5011            .await
5012            .unwrap_err();
5013        assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
5014        assert_eq!(error.exit_code(), 3);
5015        assert!(error.envelope().error.hint.is_some());
5016
5017        for status in [
5018            axum::http::StatusCode::UNAUTHORIZED,
5019            axum::http::StatusCode::FORBIDDEN,
5020        ] {
5021            let denied = CardOnlyServer::spawn(status, "{}").await;
5022            let error = run_test_cli(&denied.base_url, &["card", "get"])
5023                .await
5024                .unwrap_err();
5025            assert_eq!(
5026                error.envelope().error.code,
5027                "A2ACLI_ERR_AUTH_FAILED",
5028                "status {status}"
5029            );
5030            assert_eq!(error.exit_code(), 4, "status {status}");
5031        }
5032
5033        // 200, but the body isn't an Agent Card: the agent answered, so this
5034        // is neither unreachable nor a missing card.
5035        let invalid =
5036            CardOnlyServer::spawn(axum::http::StatusCode::OK, r#"{"not":"a card"}"#).await;
5037        let error = run_test_cli(&invalid.base_url, &["card", "get"])
5038            .await
5039            .unwrap_err();
5040        assert_eq!(error.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
5041        assert_eq!(error.exit_code(), 1);
5042
5043        // An unreachable agent stays distinct from all of the above.
5044        let base_url = unused_base_url().await;
5045        let error = run_test_cli(&base_url, &["card", "get"]).await.unwrap_err();
5046        assert_eq!(error.envelope().error.code, "A2ACLI_ERR_UNREACHABLE");
5047        assert_eq!(error.exit_code(), 3);
5048    }
5049
5050    #[test]
5051    fn test_parse_dotenv_basic() {
5052        let parsed = parse_dotenv("A2ACLI_BEARER=secret\nA2ACLI_TRANSPORT=jsonrpc,rest\n");
5053        assert_eq!(
5054            parsed,
5055            vec![
5056                ("A2ACLI_BEARER".to_string(), "secret".to_string()),
5057                ("A2ACLI_TRANSPORT".to_string(), "jsonrpc,rest".to_string()),
5058            ]
5059        );
5060    }
5061
5062    #[test]
5063    fn test_parse_dotenv_ignores_blank_lines_and_comments() {
5064        let parsed = parse_dotenv("\n# a comment\n   \nA2ACLI_TENANT=acme\n# another comment\n");
5065        assert_eq!(
5066            parsed,
5067            vec![("A2ACLI_TENANT".to_string(), "acme".to_string())]
5068        );
5069    }
5070
5071    #[test]
5072    fn test_parse_dotenv_tolerates_export_prefix() {
5073        let parsed = parse_dotenv("export A2ACLI_BASE_URL=https://agent.example.com\n");
5074        assert_eq!(
5075            parsed,
5076            vec![(
5077                "A2ACLI_BASE_URL".to_string(),
5078                "https://agent.example.com".to_string()
5079            )]
5080        );
5081    }
5082
5083    #[test]
5084    fn test_parse_dotenv_strips_one_layer_of_quotes() {
5085        let parsed = parse_dotenv("A2ACLI_BEARER=\"Bearer token\"\nA2ACLI_TENANT='acme'\n");
5086        assert_eq!(
5087            parsed,
5088            vec![
5089                ("A2ACLI_BEARER".to_string(), "Bearer token".to_string()),
5090                ("A2ACLI_TENANT".to_string(), "acme".to_string()),
5091            ]
5092        );
5093    }
5094
5095    #[test]
5096    fn test_find_flag_value_supports_space_and_equals_forms() {
5097        let space = vec![
5098            OsString::from("a2acli"),
5099            OsString::from("--config"),
5100            OsString::from("prod.env"),
5101        ];
5102        assert_eq!(
5103            find_flag_value(&space, "--config"),
5104            Some("prod.env".to_string())
5105        );
5106
5107        let equals = vec![
5108            OsString::from("a2acli"),
5109            OsString::from("--config=prod.env"),
5110        ];
5111        assert_eq!(
5112            find_flag_value(&equals, "--config"),
5113            Some("prod.env".to_string())
5114        );
5115
5116        let absent = vec![OsString::from("a2acli"), OsString::from("send")];
5117        assert_eq!(find_flag_value(&absent, "--config"), None);
5118    }
5119
5120    #[test]
5121    fn test_config_source_labels() {
5122        assert_eq!(ConfigSource::Flag.label(), "flag");
5123        assert_eq!(ConfigSource::EnvVar.label(), "environment variable");
5124        assert_eq!(ConfigSource::LocalFile.label(), "local .env file");
5125        assert_eq!(ConfigSource::GlobalFile.label(), "global .env file");
5126        assert_eq!(ConfigSource::Default.label(), "built-in default");
5127    }
5128
5129    #[test]
5130    fn test_redact_secret() {
5131        assert_eq!(
5132            redact_secret(&Some("secret".to_string())),
5133            "(set, redacted)"
5134        );
5135        assert_eq!(redact_secret(&None), "(not set)");
5136    }
5137
5138    /// §10.1's three reference forms, and the scheme a bare host gets. These
5139    /// mirror the reference implementation's `normalize` in
5140    /// `internal/flagparse/urlorpath.go`: loopback gets `http://` because a
5141    /// local development agent is rarely served over TLS, everything else
5142    /// gets `https://`, and a reference that already carries a path is a
5143    /// full card URL rather than an origin.
5144    #[test]
5145    fn test_normalize_card_reference_host_forms() {
5146        let cases = [
5147            // Bare origin -> well-known path appended.
5148            (
5149                "example.com",
5150                "https://example.com/.well-known/agent-card.json",
5151            ),
5152            (
5153                "http://example.com",
5154                "http://example.com/.well-known/agent-card.json",
5155            ),
5156            (
5157                "https://example.com/",
5158                "https://example.com/.well-known/agent-card.json",
5159            ),
5160            // Loopback -> http, since a dev agent is rarely behind TLS.
5161            (
5162                "localhost:3000",
5163                "http://localhost:3000/.well-known/agent-card.json",
5164            ),
5165            (
5166                "127.0.0.1:8080",
5167                "http://127.0.0.1:8080/.well-known/agent-card.json",
5168            ),
5169            (
5170                "[::1]:9000",
5171                "http://[::1]:9000/.well-known/agent-card.json",
5172            ),
5173            // Already carries a path -> a full card URL, used as-is.
5174            (
5175                "https://example.com/custom/card.json",
5176                "https://example.com/custom/card.json",
5177            ),
5178            (
5179                "http://example.com/agents/a/card",
5180                "http://example.com/agents/a/card",
5181            ),
5182        ];
5183
5184        for (reference, expected) in cases {
5185            assert_eq!(
5186                normalize_card_reference(reference),
5187                CardReference::Url(expected.to_string()),
5188                "reference {reference}"
5189            );
5190        }
5191    }
5192
5193    #[test]
5194    fn test_normalize_card_reference_file_forms() {
5195        // `file://` is explicit and needs no filesystem check.
5196        assert_eq!(
5197            normalize_card_reference("file:///tmp/card.json"),
5198            CardReference::File(PathBuf::from("/tmp/card.json"))
5199        );
5200        // A path-shaped reference is a path even when nothing is there, so a
5201        // typo reports a missing file rather than being fetched over HTTPS.
5202        for reference in ["/no/such/card.json", "./card.json", "../card.json"] {
5203            assert_eq!(
5204                normalize_card_reference(reference),
5205                CardReference::File(PathBuf::from(reference)),
5206                "reference {reference}"
5207            );
5208        }
5209    }
5210
5211    /// Windows path shapes, asserted as plain strings so they are checked on
5212    /// every platform. macOS and Linux cannot reproduce the divergence — a
5213    /// Unix path starts with `/` and is caught by the first prefix — so only
5214    /// the Windows CI job would otherwise notice a regression here, which is
5215    /// exactly how this was found (a2aproject/a2a-rs#190).
5216    #[test]
5217    fn test_windows_path_shapes_are_recognized_as_files() {
5218        for reference in [
5219            r"C:\Users\me\card.json",
5220            r"c:/Users/me/card.json",
5221            r"\\server\share\card.json",
5222            r".\card.json",
5223            r"..\card.json",
5224        ] {
5225            assert!(looks_like_file_path(reference), "{reference}");
5226            assert_eq!(
5227                normalize_card_reference(reference),
5228                CardReference::File(PathBuf::from(reference)),
5229                "{reference}"
5230            );
5231        }
5232
5233        // A `host:port` is not a drive root, however short the host: the
5234        // byte after the colon is a digit, not a path separator.
5235        for reference in ["localhost:3000", "a:3000", "example.com:443"] {
5236            assert!(!has_windows_drive_prefix(reference), "{reference}");
5237            assert!(!looks_like_file_path(reference), "{reference}");
5238        }
5239
5240        // `file:///C:/…`: the slash before the drive letter is URL syntax,
5241        // not part of the path.
5242        assert_eq!(
5243            normalize_card_reference("file:///C:/Users/me/card.json"),
5244            CardReference::File(PathBuf::from("C:/Users/me/card.json"))
5245        );
5246        // On Unix the same leading slash *is* the root, and is kept.
5247        assert_eq!(
5248            normalize_card_reference("file:///tmp/card.json"),
5249            CardReference::File(PathBuf::from("/tmp/card.json"))
5250        );
5251    }
5252
5253    /// A bare name that happens to exist on disk is treated as a file, the
5254    /// same way the reference implementation stats the reference before
5255    /// falling back to the host forms.
5256    #[test]
5257    fn test_normalize_card_reference_prefers_an_existing_file_over_a_host() {
5258        let dir = std::env::temp_dir().join(format!("a2acli-norm-{}", std::process::id()));
5259        std::fs::create_dir_all(&dir).unwrap();
5260        let path = dir.join("example.com");
5261        std::fs::write(&path, "{}").unwrap();
5262
5263        let previous = std::env::current_dir().unwrap();
5264        std::env::set_current_dir(&dir).unwrap();
5265        let resolved = normalize_card_reference("example.com");
5266        std::env::set_current_dir(previous).unwrap();
5267
5268        assert_eq!(resolved, CardReference::File(PathBuf::from("example.com")));
5269        let _ = std::fs::remove_dir_all(&dir);
5270    }
5271
5272    #[test]
5273    fn test_is_loopback_host() {
5274        for host in [
5275            "localhost",
5276            "localhost:3000",
5277            "127.0.0.1",
5278            "127.0.0.1:80",
5279            "::1",
5280            "[::1]:9000",
5281        ] {
5282            assert!(is_loopback_host(host), "expected loopback: {host}");
5283        }
5284        for host in [
5285            "example.com",
5286            "example.com:443",
5287            "8.8.8.8",
5288            "2606:4700::1111",
5289        ] {
5290            assert!(!is_loopback_host(host), "expected non-loopback: {host}");
5291        }
5292    }
5293
5294    #[test]
5295    fn test_card_file_errors_map_to_appendix_d_codes() {
5296        let missing = CliError::CardFile {
5297            path: "/no/such/card.json".to_string(),
5298            source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
5299        };
5300        assert_eq!(missing.envelope().error.code, "A2ACLI_ERR_CARD_NOT_FOUND");
5301        assert_eq!(missing.exit_code(), 3);
5302
5303        let invalid = CliError::CardInvalid("card.json: missing field `name`".to_string());
5304        assert_eq!(invalid.envelope().error.code, "A2ACLI_ERR_CARD_INVALID");
5305        assert_eq!(invalid.exit_code(), 1);
5306    }
5307
5308    /// The §7.2 selection matrix, in one place: the two flags are exclusive,
5309    /// `--endpoint` needs exactly one transport, and absent both the
5310    /// deprecated `--base-url` still supplies the reference.
5311    #[test]
5312    fn test_resolve_agent_selection_matrix() {
5313        let (cli, matches) = parse_with_matches(&["--agent-card", "example.com", "card", "get"]);
5314        assert_eq!(
5315            resolve_agent_selection(&cli, &matches).unwrap(),
5316            AgentSelection::Card("example.com".to_string())
5317        );
5318
5319        // Neither flag: falls back to --base-url at its built-in default,
5320        // and does not warn, because nothing deprecated was passed.
5321        let (cli, matches) = parse_with_matches(&["card", "get"]);
5322        assert_eq!(
5323            resolve_agent_selection(&cli, &matches).unwrap(),
5324            AgentSelection::Card("http://localhost:3000".to_string())
5325        );
5326
5327        // --base-url explicitly: still honored (the alias keeps working).
5328        let (cli, matches) = parse_with_matches(&["--base-url", "http://host:1", "card", "get"]);
5329        assert_eq!(
5330            resolve_agent_selection(&cli, &matches).unwrap(),
5331            AgentSelection::Card("http://host:1".to_string())
5332        );
5333
5334        let (cli, matches) = parse_with_matches(&[
5335            "--endpoint",
5336            "http://host/jsonrpc",
5337            "--transport",
5338            "jsonrpc",
5339            "card",
5340            "get",
5341        ]);
5342        assert_eq!(
5343            resolve_agent_selection(&cli, &matches).unwrap(),
5344            AgentSelection::Endpoint {
5345                url: "http://host/jsonrpc".to_string(),
5346                binding: Binding::Jsonrpc,
5347            }
5348        );
5349
5350        // --endpoint with zero and with two transports: both usage errors.
5351        for transports in [vec![], vec!["jsonrpc", "rest"]] {
5352            let mut args = vec!["--endpoint", "http://host/jsonrpc"];
5353            for transport in &transports {
5354                args.push("--transport");
5355                args.push(transport);
5356            }
5357            args.extend(["card", "get"]);
5358            let (cli, matches) = parse_with_matches(&args);
5359            let error = resolve_agent_selection(&cli, &matches).unwrap_err();
5360            assert_eq!(
5361                error.exit_code(),
5362                2,
5363                "with {} transport(s)",
5364                transports.len()
5365            );
5366            assert!(error.to_string().contains("exactly one --transport"));
5367        }
5368
5369        let (cli, matches) = parse_with_matches(&[
5370            "--agent-card",
5371            "example.com",
5372            "--endpoint",
5373            "http://host/jsonrpc",
5374            "--transport",
5375            "jsonrpc",
5376            "card",
5377            "get",
5378        ]);
5379        let error = resolve_agent_selection(&cli, &matches).unwrap_err();
5380        assert_eq!(error.exit_code(), 2);
5381        assert!(error.to_string().contains("mutually exclusive"));
5382    }
5383
5384    /// `--endpoint` stands in a one-interface card so the rest of the client
5385    /// path is unchanged; it must carry exactly the named interface and
5386    /// declare no capabilities it cannot vouch for.
5387    #[test]
5388    fn test_synthesized_endpoint_card_carries_only_the_named_interface() {
5389        let card = synthesized_endpoint_card("http://host/rest", Binding::Rest);
5390
5391        assert_eq!(card.supported_interfaces.len(), 1);
5392        assert_eq!(card.supported_interfaces[0].url, "http://host/rest");
5393        assert_eq!(
5394            card.supported_interfaces[0].protocol_binding,
5395            Binding::Rest.protocol()
5396        );
5397        assert!(card.description.contains("no agent card was resolved"));
5398        // No card was read, so nothing is advertised.
5399        assert_eq!(card.capabilities.streaming, None);
5400        assert!(card.skills.is_empty());
5401    }
5402
5403    #[test]
5404    fn test_parse_protocol_version() {
5405        assert_eq!(parse_protocol_version("1"), Some((1, 0)));
5406        assert_eq!(parse_protocol_version("1.0"), Some((1, 0)));
5407        assert_eq!(parse_protocol_version("1.2"), Some((1, 2)));
5408        // A patch component is tolerated and ignored: A2A versions are
5409        // major.minor, and a card declaring 1.0.2 must still negotiate.
5410        assert_eq!(parse_protocol_version("1.0.2"), Some((1, 0)));
5411        assert_eq!(parse_protocol_version(" 1.1 "), Some((1, 1)));
5412        assert_eq!(parse_protocol_version("nonsense"), None);
5413        assert_eq!(parse_protocol_version(""), None);
5414        assert_eq!(parse_protocol_version("1.x"), None);
5415    }
5416
5417    /// §13.2: anything outside 1.x is refused rather than downgraded,
5418    /// because A2A reads an empty or pre-1.0 version as 0.3.
5419    #[test]
5420    fn test_validate_a2a_version() {
5421        for accepted in [None, Some("1"), Some("1.0"), Some("1.7")] {
5422            assert!(
5423                validate_a2a_version(accepted).is_ok(),
5424                "should accept {accepted:?}"
5425            );
5426        }
5427
5428        for rejected in ["0.3", "0.9", "2.0", "nonsense", ""] {
5429            let error = validate_a2a_version(Some(rejected)).unwrap_err();
5430            assert_eq!(error.exit_code(), 2, "rejecting {rejected}");
5431            assert_eq!(error.envelope().error.code, "A2ACLI_ERR_USAGE");
5432        }
5433
5434        // The pre-1.0 refusal explains itself rather than just failing.
5435        let error = validate_a2a_version(Some("0.3")).unwrap_err();
5436        assert!(error.to_string().contains("must be 1.x"), "{error}");
5437    }
5438
5439    #[test]
5440    fn test_negotiate_a2a_version_uses_an_explicit_flag_verbatim() {
5441        // Explicit and equal to what this build speaks: nothing to report.
5442        let negotiated = negotiate_a2a_version(Some("1.0"), "1.0", (1, 0));
5443        assert_eq!(negotiated.version, "1.0");
5444        assert!(negotiated.note.is_none());
5445
5446        // Explicit and different: used as written, and said out loud, since
5447        // §13.2 forbids a silent change of the signaled version.
5448        let negotiated = negotiate_a2a_version(Some("1.3"), "1.0", (1, 0));
5449        assert_eq!(negotiated.version, "1.3");
5450        assert!(negotiated.note.unwrap().contains("1.3"));
5451    }
5452
5453    /// Absent a flag, negotiate down to the highest version both sides
5454    /// declare — `supported` is a parameter precisely so this branch is
5455    /// exercisable while this build's own VERSION is still 1.0.
5456    #[test]
5457    fn test_negotiate_a2a_version_takes_the_highest_shared_1x() {
5458        // Agent declares less than we support: follow it down, and say so.
5459        let negotiated = negotiate_a2a_version(None, "1.1", (1, 3));
5460        assert_eq!(negotiated.version, "1.1");
5461        assert!(negotiated.note.unwrap().contains("negotiated"));
5462
5463        // Agent declares more than we support: hold at ours, nothing to say.
5464        let negotiated = negotiate_a2a_version(None, "1.5", (1, 3));
5465        assert_eq!(negotiated.version, "1.3");
5466        assert!(negotiated.note.is_none());
5467
5468        // Equal: no note.
5469        let negotiated = negotiate_a2a_version(None, "1.3", (1, 3));
5470        assert_eq!(negotiated.version, "1.3");
5471        assert!(negotiated.note.is_none());
5472    }
5473
5474    /// The 1.0 floor: a card declaring a pre-1.0 or non-1.x version does not
5475    /// drag the tool into 0.3 semantics, and the refusal is reported.
5476    #[test]
5477    fn test_negotiate_a2a_version_never_goes_below_the_1x_floor() {
5478        for declared in ["0.3", "0.9", "2.0"] {
5479            let negotiated = negotiate_a2a_version(None, declared, (1, 0));
5480            assert_eq!(negotiated.version, "1.0", "declared {declared}");
5481            let note = negotiated.note.unwrap_or_default();
5482            assert!(note.contains("outside 1.x"), "declared {declared}: {note}");
5483        }
5484
5485        // An unparseable declaration is also not a reason to downgrade.
5486        let negotiated = negotiate_a2a_version(None, "not-a-version", (1, 0));
5487        assert_eq!(negotiated.version, "1.0");
5488        assert!(negotiated.note.unwrap().contains("unparseable"));
5489    }
5490
5491    /// EXIT_002 enumerates exactly four outcomes worth naming. `COMPLETED`
5492    /// is success and `CANCELED` is what `task cancel` was asked to produce,
5493    /// so neither says anything; the remaining states are mid-flight.
5494    #[test]
5495    fn test_task_outcome_note_names_only_non_success_and_paused_states() {
5496        for (state, expected) in [
5497            (TaskState::Failed, "FAILED"),
5498            (TaskState::Rejected, "REJECTED"),
5499            (TaskState::InputRequired, "INPUT_REQUIRED"),
5500            (TaskState::AuthRequired, "AUTH_REQUIRED"),
5501        ] {
5502            let note =
5503                task_outcome_note(&state).unwrap_or_else(|| panic!("{state:?} should be named"));
5504            assert!(note.contains(expected), "{state:?} -> {note}");
5505        }
5506
5507        for state in [
5508            TaskState::Completed,
5509            TaskState::Canceled,
5510            TaskState::Submitted,
5511            TaskState::Working,
5512            TaskState::Unspecified,
5513        ] {
5514            assert!(
5515                task_outcome_note(&state).is_none(),
5516                "{state:?} should stay silent"
5517            );
5518        }
5519    }
5520
5521    /// The warning is derived from the response's own shape, so a reply that
5522    /// created no task has no outcome to name.
5523    #[test]
5524    fn test_task_outcome_is_read_from_the_reported_value() {
5525        // A `Message`-only reply created no task (SEND_003).
5526        let message = Message::new(Role::Agent, vec![Part::text("hi")]);
5527        assert!(
5528            task_outcome_note(&TaskState::Completed).is_none(),
5529            "sanity: COMPLETED is silent"
5530        );
5531        SendMessageResponse::Message(message.clone()).warn_outcome();
5532        StreamResponse::Message(message).warn_outcome();
5533
5534        // A task-bearing response reads the task's state.
5535        let task = make_fixture_task("t-1", "c-1", TaskState::Failed, "broke");
5536        assert_eq!(
5537            task_outcome_note(&task.status.state),
5538            Some("the agent reported it FAILED")
5539        );
5540        SendMessageResponse::Task(task.clone()).warn_outcome();
5541        StreamResponse::Task(task).warn_outcome();
5542
5543        // A streamed status update reads the event's state.
5544        StreamResponse::StatusUpdate(TaskStatusUpdateEvent {
5545            task_id: "t-2".to_string(),
5546            context_id: "c-2".to_string(),
5547            status: TaskStatus {
5548                state: TaskState::AuthRequired,
5549                message: None,
5550                timestamp: None,
5551            },
5552            metadata: None,
5553        })
5554        .warn_outcome();
5555    }
5556
5557    /// clap renders "error: …" then a blank line then a usage block, and
5558    /// indents continuation lines. The envelope carries one line, so the
5559    /// first block is joined and the redundant `error: ` prefix dropped —
5560    /// the envelope already says it is an error.
5561    #[test]
5562    fn test_flatten_clap_message() {
5563        let rendered = "error: unexpected argument '--nope' found\n\nUsage: a2acli [OPTIONS]\n";
5564        assert_eq!(
5565            flatten_clap_message(rendered),
5566            "unexpected argument '--nope' found"
5567        );
5568
5569        // Continuation lines are trimmed before joining, so no run of
5570        // spaces survives into the JSON.
5571        let rendered = "error: the following required arguments were not provided:\n  <ID>\n\nUsage: a2acli task get <ID>\n";
5572        assert_eq!(
5573            flatten_clap_message(rendered),
5574            "the following required arguments were not provided: <ID>"
5575        );
5576
5577        // A message with no blank line, and one with nothing but a usage
5578        // block, both still produce something.
5579        assert_eq!(flatten_clap_message("error: bare"), "bare");
5580        assert_eq!(flatten_clap_message("\n\nUsage: a2acli"), "Usage: a2acli");
5581    }
5582
5583    #[test]
5584    fn test_usage_error_maps_to_the_appendix_d_usage_code() {
5585        let error = CliError::Usage("unexpected argument '--nope' found".to_string());
5586        assert_eq!(error.exit_code(), 2);
5587        let envelope = error.envelope();
5588        assert_eq!(envelope.error.code, "A2ACLI_ERR_USAGE");
5589        assert_eq!(envelope.error.message, "unexpected argument '--nope' found");
5590        assert!(envelope.error.hint.unwrap().contains("--help"));
5591    }
5592}