dwara_core/ai/
a2a.rs

1//! A2A (agent-to-agent) protocol support (DW-114, AI-12 #252).
2//!
3//! This module implements the A2A protocol's task lifecycle state
4//! machine and the v0.3 JSON-RPC wire format against the A2A Protocol
5//! Specification (<https://github.com/a2aproject/A2A>) as of 2025-Q4.
6//! The task state vocabulary (`submitted`, `working`, `completed`,
7//! `failed`, `canceled`) and the legal transitions are stable; the
8//! wire format (JSON-RPC method names, envelope shapes) is the v0.3
9//! shape (`message/send`, `message/stream`, `tasks/get`, etc.).
10//!
11//! # What is implemented
12//!
13//! - The [`TaskStateMachine`] struct: a validating state machine for
14//!   A2A task lifecycle transitions. Legal transitions:
15//!   - `Submitted -> Working` (the agent starts processing)
16//!   - `Submitted -> Failed` (the agent rejects the task)
17//!   - `Submitted -> Canceled` (the caller cancels before processing)
18//!   - `Working -> Completed` (the agent finishes successfully)
19//!   - `Working -> Failed` (the agent fails mid-processing)
20//!   - `Working -> Canceled` (the caller cancels during processing)
21//!   - `Completed`, `Failed`, `Canceled` are terminal (no transitions
22//!     out). Illegal transitions return an [`A2AError`] naming the
23//!     attempted and target states.
24//! - The [`A2AAdapter`] struct implementing [`ProviderAdapter`]: it
25//!   translates a canonical [`ChatRequest`] into an A2A v0.3 JSON-RPC
26//!   `message/send` (or `message/stream` for streaming) body
27//!   ([`A2AAdapter::build_request`]) and parses an A2A response back
28//!   into the canonical [`ChatResponse`]
29//!   ([`A2AAdapter::parse_response`]). The adapter handles both direct
30//!   `Message` results (with `parts[]` using the `kind` discriminator)
31//!   and `Task` results (extracting text from `status.message` or
32//!   `artifacts`). Error and stream-event parsing are wired too (the
33//!   SSE framing reuses [`crate::ai::sse`]); stream events cover
34//!   `TaskStatusUpdateEvent`, `TaskArtifactUpdateEvent`, and direct
35//!   `Message` frames.
36//! - The [`AgentCard`] struct and [`AgentCardParser`]: parse the
37//!   JSON-LD-ish Agent Card discovery doc (name, description, url,
38//!   version, capabilities, authentication) from an inline JSON value
39//!   or a file path.
40//! - The [`TaskLifecycle`] enum: the task state vocabulary
41//!   (`Submitted`, `Working`, `Completed`, `Failed`, `Canceled`).
42//! - The [`A2ASession`] struct: reuses the MCP session-management
43//!   patterns (session id, TTL, max-concurrent) for agent-to-agent
44//!   task sessions. Each session owns a [`TaskStateMachine`].
45//! - The [`handle_a2a_request`] function: routes an A2A call through
46//!   the existing `dataplane::ai_proxy` path (the transport is the
47//!   agent's named upstream, exactly like a regular provider).
48//!
49//! # v0.3 wire format (AI-12, #252)
50//!
51//! The adapter speaks the A2A v0.3 JSON-RPC binding:
52//!
53//! - **Request**: `message/send` (non-streaming) or `message/stream`
54//!   (streaming). The canonical `ChatRequest` is folded into
55//!   `params.message.parts[]` using the `kind`-discriminated part
56//!   shape (`text`, `file`, `data`). System messages are folded as a
57//!   text preamble (A2A has no system role). The message carries a
58//!   generated `messageId` and `role: "user"`.
59//! - **Response**: the adapter detects whether the JSON-RPC `result`
60//!   is a `Message` (has `parts` or `kind: "message"`) or a `Task`
61//!   (has `status` or `kind: "task"`). For Tasks, text is extracted
62//!   from `status.message.parts` or `artifacts[].parts`. The task
63//!   state maps to `FinishReason` (`completed` -> `Stop`, `failed` ->
64//!   `Other("failed")`, etc.).
65//! - **Streaming**: SSE frames carry JSON-RPC responses whose
66//!   `result` is one of `TaskStatusUpdateEvent` (has `status`),
67//!   `TaskArtifactUpdateEvent` (has `artifact`), or a direct
68//!   `Message` (has `parts`). Terminal states emit a finish delta.
69//! - **Backward compat**: the adapter still accepts the older
70//!   `content`/`type` message shape and the `result.message` wrapper
71//!   for agents that have not migrated to v0.3.
72//!
73//! # Dependency direction
74//!
75//! `ai` depends on `config` only. The A2A adapter is a pure translator
76//! (no HTTP client of its own), mirroring the OpenAI/Anthropic/Gemini
77//! adapters and the MCP gateway. No new dependencies are introduced
78//! (the scaffold is hand-rolled, the same locked M4 decision as MCP).
79//!
80//! # Feature gate
81//!
82//! The `a2a` cargo feature is flag-only (no new deps). When it is OFF,
83//! the `ai.a2a` config block is accepted but inert: validation warns,
84//! and [`CompiledA2a::compile`] returns `None` (no A2A providers are
85//! wired into the alias table). When it is ON, each configured agent
86//! appears as a provider of kind `a2a` in the model alias table.
87
88use crate::ai::adapter::{AiError, ProviderAdapter, ProviderErrorBody, ProviderRequest};
89use crate::ai::types::{
90    ChatMessage, ChatRequest, ChatResponse, ChatRole, Choice, ContentPart, FinishReason,
91    StreamDelta, StreamEvent,
92};
93use crate::config::ai::{A2aAgentCard, A2aConfig, AiProviderKind};
94use crate::config::Gateway;
95use serde_json::{json, Map, Value};
96use sha2::{Digest, Sha256};
97use std::collections::BTreeMap;
98use std::sync::atomic::{AtomicU64, Ordering};
99
100/// Error returned by A2A task lifecycle operations. Replaces the
101/// former `A2AStub` (the spec is now sufficiently stable for the task
102/// state machine; the wire format may still evolve). The [`A2AStub`]
103/// type is kept as an alias for backward compatibility with the
104/// `handle_a2a_request` function (which is about the network call,
105/// not the state machine).
106#[derive(Debug, Clone, PartialEq)]
107pub enum A2AError {
108    /// An illegal state transition was attempted (e.g. Completed ->
109    /// Working). The `from` and `to` fields name the states.
110    IllegalTransition {
111        from: TaskLifecycle,
112        to: TaskLifecycle,
113    },
114    /// A parse error (Agent Card JSON, response JSON, etc.).
115    ParseError(String),
116    /// The network call is not yet wired (the adapter intentionally
117    /// does not own an HTTP client; the transport is the agent's
118    /// upstream). This is the only remaining "stub" surface.
119    NetworkNotWired(String),
120}
121
122impl std::fmt::Display for A2AError {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            A2AError::IllegalTransition { from, to } => {
126                write!(
127                    f,
128                    "a2a illegal task transition: {} -> {}",
129                    from.as_str(),
130                    to.as_str()
131                )
132            }
133            A2AError::ParseError(m) => write!(f, "a2a parse error: {m}"),
134            A2AError::NetworkNotWired(m) => write!(f, "a2a network not wired: {m}"),
135        }
136    }
137}
138
139impl std::error::Error for A2AError {}
140
141/// The former stub error type, kept as a compatibility alias for the
142/// `handle_a2a_request` function (which is about the network call, not
143/// the state machine). New code should use [`A2AError`] directly.
144#[derive(Debug, Clone, PartialEq)]
145pub struct A2AStub {
146    /// The operation that was attempted.
147    pub transition: String,
148    /// Why it is not wired.
149    pub reason: String,
150}
151
152impl A2AStub {
153    /// Build the standard stub error for a network-not-wired
154    /// operation.
155    pub fn new(transition: impl Into<String>) -> Self {
156        A2AStub {
157            transition: transition.into(),
158            reason: "a2a network call is not wired (DW-114): the adapter does \
159                     not own an HTTP client; the transport is the agent's \
160                     upstream"
161                .to_string(),
162        }
163    }
164}
165
166impl std::fmt::Display for A2AStub {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        write!(f, "a2a stub ({}): {}", self.transition, self.reason)
169    }
170}
171
172impl std::error::Error for A2AStub {}
173
174impl A2AStub {
175    /// Attach a parse-detail message to a stub, producing a stub
176    /// whose `reason` carries the detail (used by the card parser so
177    /// the caller sees both the context and the concrete parse
178    /// failure).
179    fn into_parse_error(self, detail: impl Into<String>) -> A2AStub {
180        A2AStub {
181            transition: self.transition,
182            reason: format!("{}: {}", self.reason, detail.into()),
183        }
184    }
185}
186
187/// The A2A task lifecycle state vocabulary (DW-114). The states follow
188/// the A2A Protocol Specification draft: `submitted`, `working`,
189/// `completed`, `failed`, `canceled`. The legal transitions between
190/// them are enforced by [`TaskStateMachine`].
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum TaskLifecycle {
193    /// A task has been submitted to the agent (the initial state).
194    Submitted,
195    /// The agent is processing the task.
196    Working,
197    /// The task completed successfully; the result is available.
198    /// Terminal state.
199    Completed,
200    /// The task failed; the error is available. Terminal state.
201    Failed,
202    /// The task was canceled by the caller. Terminal state.
203    Canceled,
204}
205
206impl TaskLifecycle {
207    /// The lowercase wire name (the A2A spec spelling).
208    pub fn as_str(&self) -> &'static str {
209        match self {
210            TaskLifecycle::Submitted => "submitted",
211            TaskLifecycle::Working => "working",
212            TaskLifecycle::Completed => "completed",
213            TaskLifecycle::Failed => "failed",
214            TaskLifecycle::Canceled => "canceled",
215        }
216    }
217
218    /// Parse a wire-name string into a state. Returns None for an
219    /// unknown name (the spec is not frozen, so unknown states are
220    /// tolerated rather than rejected).
221    pub fn parse_state(s: &str) -> Option<Self> {
222        match s {
223            "submitted" => Some(TaskLifecycle::Submitted),
224            "working" => Some(TaskLifecycle::Working),
225            "completed" => Some(TaskLifecycle::Completed),
226            "failed" => Some(TaskLifecycle::Failed),
227            "canceled" => Some(TaskLifecycle::Canceled),
228            _ => None,
229        }
230    }
231
232    /// Whether this state is terminal (no transitions out).
233    pub fn is_terminal(&self) -> bool {
234        matches!(
235            self,
236            TaskLifecycle::Completed | TaskLifecycle::Failed | TaskLifecycle::Canceled
237        )
238    }
239
240    /// Whether the transition `self -> target` is legal per the A2A
241    /// task lifecycle state machine.
242    pub fn can_transition_to(&self, target: TaskLifecycle) -> bool {
243        matches!(
244            (self, target),
245            (TaskLifecycle::Submitted, TaskLifecycle::Working)
246                | (TaskLifecycle::Submitted, TaskLifecycle::Failed)
247                | (TaskLifecycle::Submitted, TaskLifecycle::Canceled)
248                | (TaskLifecycle::Working, TaskLifecycle::Completed)
249                | (TaskLifecycle::Working, TaskLifecycle::Failed)
250                | (TaskLifecycle::Working, TaskLifecycle::Canceled)
251        )
252    }
253}
254
255/// A validating state machine for A2A task lifecycle transitions
256/// (DW-114). Holds the current state; transition methods validate the
257/// transition and return the new state, or an [`A2AError`] when the
258/// transition is illegal.
259///
260/// The state machine is the in-process task tracker; it does NOT own
261/// a network connection (the adapter intentionally does not own an
262/// HTTP client — the transport is the agent's upstream, driven from
263/// `dataplane::ai_proxy`). The state machine is updated by the
264/// dataplane as it observes task responses and events.
265#[derive(Debug, Clone)]
266pub struct TaskStateMachine {
267    state: TaskLifecycle,
268}
269
270impl TaskStateMachine {
271    /// Create a new task in the `Submitted` state (the initial state).
272    pub fn new() -> Self {
273        TaskStateMachine {
274            state: TaskLifecycle::Submitted,
275        }
276    }
277
278    /// Create a state machine at a specific state (used when
279    /// reconstructing a task from a persisted or observed state).
280    pub fn from_state(state: TaskLifecycle) -> Self {
281        TaskStateMachine { state }
282    }
283
284    /// The current state.
285    pub fn state(&self) -> TaskLifecycle {
286        self.state
287    }
288
289    /// Transition to `target`, returning the new state on success or
290    /// an [`A2AError::IllegalTransition`] when the transition is not
291    /// legal from the current state.
292    pub fn transition_to(&mut self, target: TaskLifecycle) -> Result<TaskLifecycle, A2AError> {
293        if self.state.can_transition_to(target) {
294            self.state = target;
295            Ok(self.state)
296        } else {
297            Err(A2AError::IllegalTransition {
298                from: self.state,
299                to: target,
300            })
301        }
302    }
303
304    /// Start processing the task (Submitted -> Working). Returns the
305    /// new state or an error when the task is not in the Submitted
306    /// state.
307    pub fn start_work(&mut self) -> Result<TaskLifecycle, A2AError> {
308        self.transition_to(TaskLifecycle::Working)
309    }
310
311    /// Mark the task as completed (Working -> Completed). Returns the
312    /// new state or an error when the task is not in the Working
313    /// state.
314    pub fn complete(&mut self) -> Result<TaskLifecycle, A2AError> {
315        self.transition_to(TaskLifecycle::Completed)
316    }
317
318    /// Mark the task as failed (Submitted|Working -> Failed). Returns
319    /// the new state or an error when the task is in a terminal state.
320    pub fn fail(&mut self) -> Result<TaskLifecycle, A2AError> {
321        self.transition_to(TaskLifecycle::Failed)
322    }
323
324    /// Cancel the task (Submitted|Working -> Canceled). Returns the
325    /// new state or an error when the task is in a terminal state.
326    pub fn cancel(&mut self) -> Result<TaskLifecycle, A2AError> {
327        self.transition_to(TaskLifecycle::Canceled)
328    }
329
330    /// Whether the task is in a terminal state (Completed, Failed, or
331    /// Canceled — no further transitions are possible).
332    pub fn is_terminal(&self) -> bool {
333        self.state.is_terminal()
334    }
335}
336
337impl Default for TaskStateMachine {
338    fn default() -> Self {
339        Self::new()
340    }
341}
342
343/// An Agent Card (DW-114): the JSON-LD-ish discovery doc an A2A agent
344/// publishes to declare its identity, capabilities, and
345/// authentication. Parsed from an inline JSON value or a file path by
346/// [`AgentCardParser`].
347#[derive(Debug, Clone, PartialEq)]
348pub struct AgentCard {
349    /// The agent's human-readable name.
350    pub name: String,
351    /// A short description of what the agent does.
352    pub description: Option<String>,
353    /// The agent's base URL (the A2A endpoint).
354    pub url: String,
355    /// The agent's version string.
356    pub version: Option<String>,
357    /// The agent's declared capabilities (a free-form JSON object;
358    /// the spec is not frozen, so the shape is preserved verbatim).
359    pub capabilities: Value,
360    /// The agent's authentication declaration (a free-form JSON
361    /// object; the spec is not frozen, so the shape is preserved
362    /// verbatim). The gateway does not act on this today (the
363    /// transport auth comes from the agent's upstream config).
364    pub authentication: Value,
365}
366
367/// Parses Agent Card JSON (DW-114) from an inline JSON value or a
368/// file path. The card shape is JSON-LD-ish: a top-level object with
369/// `name`, `description`, `url`, `version`, `capabilities`, and
370/// `authentication` fields. Unknown fields are tolerated (the spec is
371/// not frozen). Required fields are `name` and `url`; missing either
372/// is a parse error.
373pub struct AgentCardParser;
374
375impl AgentCardParser {
376    /// Parse an Agent Card from an inline JSON value. Returns an
377    /// error when the value is not an object or is missing a required
378    /// field (`name`, `url`).
379    pub fn parse_inline(value: &Value) -> Result<AgentCard, A2AStub> {
380        let obj = value.as_object().ok_or_else(|| {
381            A2AStub::new("agent_card_parse").into_parse_error("agent card is not a JSON object")
382        })?;
383        Self::parse_object(obj)
384    }
385
386    /// Parse an Agent Card from a file path. Reads the file and parses
387    /// it as JSON, then delegates to `parse_inline`. Returns an
388    /// error when the file cannot be read or is malformed.
389    pub fn parse_path(path: &str) -> Result<AgentCard, A2AStub> {
390        let bytes = std::fs::read(path).map_err(|e| {
391            A2AStub::new("agent_card_parse")
392                .into_parse_error(format!("could not read agent card file '{path}': {e}"))
393        })?;
394        let value: Value = serde_json::from_slice(&bytes).map_err(|e| {
395            A2AStub::new("agent_card_parse")
396                .into_parse_error(format!("agent card file '{path}' is not valid JSON: {e}"))
397        })?;
398        Self::parse_inline(&value)
399    }
400
401    /// Parse a card source (inline-or-path) from the config. Inline
402    /// takes precedence over path; when neither is set, returns an
403    /// error (a card-less agent has no discovery doc).
404    pub fn parse_source(card: Option<&A2aAgentCard>) -> Result<AgentCard, A2AStub> {
405        let Some(card) = card else {
406            return Err(A2AStub::new("agent_card_parse")
407                .into_parse_error("agent has no card (set card.inline or card.path)"));
408        };
409        if let Some(inline) = &card.inline {
410            return Self::parse_inline(inline);
411        }
412        if let Some(path) = &card.path {
413            return Self::parse_path(path);
414        }
415        Err(A2AStub::new("agent_card_parse")
416            .into_parse_error("agent card has neither inline nor path set"))
417    }
418
419    fn parse_object(obj: &Map<String, Value>) -> Result<AgentCard, A2AStub> {
420        let name = obj
421            .get("name")
422            .and_then(Value::as_str)
423            .ok_or_else(|| {
424                A2AStub::new("agent_card_parse")
425                    .into_parse_error("agent card is missing the required 'name' field")
426            })?
427            .to_string();
428        let url = obj
429            .get("url")
430            .and_then(Value::as_str)
431            .ok_or_else(|| {
432                A2AStub::new("agent_card_parse")
433                    .into_parse_error("agent card is missing the required 'url' field")
434            })?
435            .to_string();
436        let description = obj
437            .get("description")
438            .and_then(Value::as_str)
439            .map(String::from);
440        let version = obj.get("version").and_then(Value::as_str).map(String::from);
441        let capabilities = obj.get("capabilities").cloned().unwrap_or(Value::Null);
442        let authentication = obj.get("authentication").cloned().unwrap_or(Value::Null);
443        Ok(AgentCard {
444            name,
445            description,
446            url,
447            version,
448            capabilities,
449            authentication,
450        })
451    }
452}
453
454/// The A2A provider adapter (DW-114, DP-12 #252). A pure translator,
455/// like the OpenAI/Anthropic/Gemini adapters: it holds no state and
456/// opens no connections. The transport is the agent's named upstream,
457/// driven from `dataplane::ai_proxy`.
458///
459/// `build_request` translates a canonical [`ChatRequest`] into an A2A
460/// v0.3 JSON-RPC `message/send` (or `message/stream` for streaming)
461/// body: the latest user message is folded into `params.message.parts`
462/// using the A2A `kind`-discriminated part shape. `parse_response`
463/// handles both direct `Message` results and `Task` results (extracting
464/// text from `status.message` or `artifacts`).
465pub struct A2AAdapter;
466
467impl ProviderAdapter for A2AAdapter {
468    fn kind(&self) -> AiProviderKind {
469        AiProviderKind::A2a
470    }
471
472    fn build_request(
473        &self,
474        req: &ChatRequest,
475        _provider_model: &str,
476    ) -> Result<ProviderRequest, AiError> {
477        // A2A v0.3 JSON-RPC: the latest user message becomes
478        // `params.message`; prior messages are NOT carried (A2A uses
479        // `contextId` for multi-turn correlation, not an inline
480        // history). System messages are folded into the user message
481        // as a preamble since A2A has no system role.
482        let mut parts: Vec<Value> = Vec::new();
483        for m in &req.messages {
484            if matches!(m.role, ChatRole::System) {
485                // Fold system messages as a text part preamble.
486                let text = m.text_content();
487                if !text.is_empty() {
488                    parts.push(json!({"kind": "text", "text": text}));
489                }
490            }
491        }
492        // The latest user message's parts are the message body.
493        let last_user = req
494            .messages
495            .iter()
496            .rev()
497            .find(|m| matches!(m.role, ChatRole::User));
498        let user_parts = if let Some(m) = last_user {
499            canonical_to_a2a_parts(m)
500        } else {
501            // No user message: send an empty text part (A2A requires
502            // at least one part).
503            vec![json!({"kind": "text", "text": ""})]
504        };
505        parts.extend(user_parts);
506
507        // Generate a message ID and JSON-RPC request ID.
508        let now_ms = std::time::SystemTime::now()
509            .duration_since(std::time::UNIX_EPOCH)
510            .map(|d| d.as_millis())
511            .unwrap_or(0);
512        let message_id = format!("msg-{now_ms:x}");
513        let rpc_id = format!("rpc-{now_ms:x}");
514
515        let mut message = Map::new();
516        message.insert("messageId".into(), json!(message_id));
517        message.insert("role".into(), json!("user"));
518        message.insert("parts".into(), Value::Array(parts));
519
520        let mut params = Map::new();
521        params.insert("message".into(), Value::Object(message));
522
523        let method = if req.stream {
524            "message/stream"
525        } else {
526            "message/send"
527        };
528
529        let mut body = Map::new();
530        body.insert("jsonrpc".into(), json!("2.0"));
531        body.insert("id".into(), json!(rpc_id));
532        body.insert("method".into(), json!(method));
533        body.insert("params".into(), Value::Object(params));
534
535        Ok(ProviderRequest {
536            method: http::Method::POST,
537            // v0.3 JSON-RPC uses a single endpoint; the upstream path
538            // is controlled by the provider/upstream config, not the
539            // adapter. The adapter's `path` is appended to the
540            // upstream's base URL by `ai_proxy`.
541            path: "/".to_string(),
542            headers: vec![],
543            body: Value::Object(body),
544        })
545    }
546
547    fn parse_response(&self, body: &Value) -> Result<ChatResponse, AiError> {
548        let obj = body
549            .as_object()
550            .ok_or_else(|| AiError::Translation("a2a response is not a JSON object".to_string()))?;
551        // The JSON-RPC result envelope or a bare result object.
552        let result = obj.get("result").unwrap_or(body);
553        let result_obj = result
554            .as_object()
555            .ok_or_else(|| AiError::Translation("a2a result is not a JSON object".to_string()))?;
556
557        // Detect the result shape: a direct Message (has `parts` or
558        // `kind: "message"`) or a Task (has `status` or
559        // `kind: "task"`).
560        let is_message = result_obj.contains_key("parts")
561            || result_obj
562                .get("kind")
563                .and_then(Value::as_str)
564                .is_some_and(|k| k == "message");
565        let is_task = result_obj.contains_key("status")
566            || result_obj
567                .get("kind")
568                .and_then(Value::as_str)
569                .is_some_and(|k| k == "task");
570
571        let id = obj.get("id").and_then(Value::as_str).map(String::from);
572
573        if is_message {
574            let content = parse_a2a_message(result)?;
575            return Ok(ChatResponse {
576                id,
577                model: None,
578                choices: vec![Choice {
579                    index: 0,
580                    message: content,
581                    finish_reason: FinishReason::Stop,
582                }],
583                usage: None,
584            });
585        }
586
587        if is_task {
588            let state = result_obj
589                .get("status")
590                .and_then(|s| s.get("state"))
591                .and_then(Value::as_str)
592                .unwrap_or("unknown");
593            let finish_reason = parse_finish_reason(state);
594            // Extract text from status.message or artifacts.
595            let content = extract_task_text(result_obj);
596            return Ok(ChatResponse {
597                id,
598                model: None,
599                choices: vec![Choice {
600                    index: 0,
601                    message: content,
602                    finish_reason,
603                }],
604                usage: None,
605            });
606        }
607
608        // Backward compat: old shape wraps the message under
609        // `result.message` (or a bare top-level `message`).
610        if let Some(msg) = result_obj.get("message") {
611            if let Ok(content) = parse_a2a_message(msg) {
612                return Ok(ChatResponse {
613                    id,
614                    model: None,
615                    choices: vec![Choice {
616                        index: 0,
617                        message: content,
618                        finish_reason: FinishReason::Stop,
619                    }],
620                    usage: None,
621                });
622            }
623        }
624
625        // Fallback: try to parse the result itself as a message
626        // (agents that return a bare message without `kind` or a
627        // `message` wrapper). Only accept it if it has content.
628        if let Ok(content) = parse_a2a_message(result) {
629            if !content.content.is_empty() {
630                return Ok(ChatResponse {
631                    id,
632                    model: None,
633                    choices: vec![Choice {
634                        index: 0,
635                        message: content,
636                        finish_reason: FinishReason::Stop,
637                    }],
638                    usage: None,
639                });
640            }
641        }
642
643        Err(AiError::Translation(
644            "a2a result is neither a Message nor a Task".to_string(),
645        ))
646    }
647
648    fn parse_error(&self, body: &Value) -> ProviderErrorBody {
649        // A2A errors ride the JSON-RPC 2.0 error envelope:
650        // {"error": {"code", "message", "data"}}.
651        match body.get("error") {
652            Some(Value::Object(e)) => ProviderErrorBody {
653                message: e
654                    .get("message")
655                    .and_then(Value::as_str)
656                    .unwrap_or("a2a agent returned an error")
657                    .to_string(),
658                error_type: e.get("data").and_then(Value::as_str).map(String::from),
659                code: e.get("code").map(|c| match c {
660                    Value::String(s) => s.clone(),
661                    other => other.to_string(),
662                }),
663            },
664            Some(Value::String(s)) => ProviderErrorBody {
665                message: s.clone(),
666                error_type: None,
667                code: None,
668            },
669            _ => ProviderErrorBody {
670                message: "a2a agent returned an error".to_string(),
671                error_type: None,
672                code: None,
673            },
674        }
675    }
676
677    fn parse_stream_event(&self, data: &Value) -> Result<Vec<StreamEvent>, AiError> {
678        // A2A v0.3 streaming: SSE frames whose `data:` line is a
679        // JSON-RPC response whose `result` is one of:
680        // - TaskStatusUpdateEvent (has `status.state`, optional
681        //   `status.message`)
682        // - TaskArtifactUpdateEvent (has `artifact.parts`)
683        // - Message (has `parts`)
684        let obj = data.as_object().ok_or_else(|| {
685            AiError::Translation("a2a stream event is not a JSON object".to_string())
686        })?;
687        let result = obj.get("result").unwrap_or(data);
688        let result_obj = result.as_object().ok_or_else(|| {
689            AiError::Translation("a2a stream result is not a JSON object".to_string())
690        })?;
691
692        let mut out = Vec::new();
693
694        // Direct Message stream event (has `parts`).
695        if result_obj.contains_key("parts") {
696            if let Ok(msg) = parse_a2a_message(result) {
697                let text = msg.text_content();
698                if !text.is_empty() {
699                    out.push(StreamEvent::Delta(StreamDelta {
700                        index: 0,
701                        role: Some(ChatRole::Assistant),
702                        content: Some(text),
703                        tool_calls: Vec::new(),
704                        finish_reason: None,
705                    }));
706                }
707            }
708        }
709
710        // TaskStatusUpdateEvent (has `status`).
711        if let Some(status) = result_obj.get("status").and_then(Value::as_object) {
712            let state = status.get("state").and_then(Value::as_str);
713            // Extract text from the status message if present.
714            if let Some(msg_val) = status.get("message") {
715                if let Ok(msg) = parse_a2a_message(msg_val) {
716                    let text = msg.text_content();
717                    if !text.is_empty() {
718                        out.push(StreamEvent::Delta(StreamDelta {
719                            index: 0,
720                            role: Some(ChatRole::Assistant),
721                            content: Some(text),
722                            tool_calls: Vec::new(),
723                            finish_reason: None,
724                        }));
725                    }
726                }
727            }
728            // Terminal state -> finish reason.
729            if let Some(s) = state {
730                let fr = parse_finish_reason(s);
731                if !matches!(fr, FinishReason::Other(_)) || is_terminal_state(s) {
732                    out.push(StreamEvent::Delta(StreamDelta {
733                        index: 0,
734                        role: None,
735                        content: None,
736                        tool_calls: Vec::new(),
737                        finish_reason: Some(fr),
738                    }));
739                }
740            }
741        }
742
743        // TaskArtifactUpdateEvent (has `artifact`).
744        if let Some(artifact) = result_obj.get("artifact").and_then(Value::as_object) {
745            if let Some(parts) = artifact.get("parts").and_then(Value::as_array) {
746                let text = extract_text_from_parts(parts);
747                if !text.is_empty() {
748                    out.push(StreamEvent::Delta(StreamDelta {
749                        index: 0,
750                        role: Some(ChatRole::Assistant),
751                        content: Some(text),
752                        tool_calls: Vec::new(),
753                        finish_reason: None,
754                    }));
755                }
756            }
757        }
758
759        Ok(out)
760    }
761}
762
763/// Whether an A2A task state is terminal (no further transitions).
764fn is_terminal_state(state: &str) -> bool {
765    matches!(state, "completed" | "failed" | "canceled" | "rejected")
766}
767
768/// Extract text from a Task result: try `status.message.parts` first,
769/// then `artifacts[].parts`.
770fn extract_task_text(task_obj: &Map<String, Value>) -> ChatMessage {
771    // Try status.message.
772    if let Some(msg) = task_obj.get("status").and_then(|s| s.get("message")) {
773        if let Ok(content) = parse_a2a_message(msg) {
774            return content;
775        }
776    }
777    // Try artifacts.
778    if let Some(artifacts) = task_obj.get("artifacts").and_then(Value::as_array) {
779        let mut all_text = String::new();
780        for artifact in artifacts {
781            if let Some(parts) = artifact.get("parts").and_then(Value::as_array) {
782                all_text.push_str(&extract_text_from_parts(parts));
783            }
784        }
785        if !all_text.is_empty() {
786            return ChatMessage::text(ChatRole::Assistant, all_text);
787        }
788    }
789    // Fallback: empty assistant message.
790    ChatMessage::text(ChatRole::Assistant, "")
791}
792
793/// Extract concatenated text from A2A `parts[]` (v0.3 `kind`-discriminated).
794fn extract_text_from_parts(parts: &[Value]) -> String {
795    let mut text = String::new();
796    for p in parts {
797        match p.get("kind").and_then(Value::as_str) {
798            Some("text") => {
799                if let Some(t) = p.get("text").and_then(Value::as_str) {
800                    text.push_str(t);
801                }
802            }
803            Some("data") => {
804                // Data parts: serialize as JSON text for visibility.
805                if let Some(data) = p.get("data") {
806                    text.push_str(&data.to_string());
807                }
808            }
809            _ => {}
810        }
811    }
812    text
813}
814
815/// Convert a canonical [`ChatMessage`] into A2A v0.3 `parts[]`.
816fn canonical_to_a2a_parts(m: &ChatMessage) -> Vec<Value> {
817    m.content
818        .iter()
819        .map(|p| match p {
820            ContentPart::Text { text } => json!({"kind": "text", "text": text}),
821            ContentPart::Image {
822                url,
823                media_type,
824                data_b64,
825            } => {
826                // A2A v0.3 file part: prefer inline bytes, fall back
827                // to URI.
828                if let Some(b64) = data_b64 {
829                    json!({
830                        "kind": "file",
831                        "file": {
832                            "name": "image",
833                            "mimeType": media_type.as_deref().unwrap_or("image/png"),
834                            "bytes": b64,
835                        }
836                    })
837                } else {
838                    json!({
839                        "kind": "file",
840                        "file": {
841                            "name": "image",
842                            "mimeType": media_type.as_deref().unwrap_or("image/png"),
843                            "uri": url.clone().unwrap_or_default(),
844                        }
845                    })
846                }
847            }
848        })
849        .collect()
850}
851
852/// Parse an A2A task state into a finish reason.
853fn parse_finish_reason(s: &str) -> FinishReason {
854    match s {
855        "completed" => FinishReason::Stop,
856        "failed" => FinishReason::Other("failed".to_string()),
857        "canceled" => FinishReason::Other("canceled".to_string()),
858        "rejected" => FinishReason::Other("rejected".to_string()),
859        other => FinishReason::Other(other.to_string()),
860    }
861}
862
863/// Parse an A2A v0.3 wire message into the canonical shape. The A2A
864/// message uses `parts[]` with a `kind` discriminator (`text`, `file`,
865/// `data`). The `role` field is optional (defaults to `assistant` for
866/// responses). Backward-compatible with the older `content`/`type`
867/// shape for agents that have not migrated.
868fn parse_a2a_message(v: &Value) -> Result<ChatMessage, AiError> {
869    let obj = v
870        .as_object()
871        .ok_or_else(|| AiError::Translation("a2a message is not an object".to_string()))?;
872    let role = match obj.get("role").and_then(Value::as_str) {
873        Some("system") => ChatRole::System,
874        Some("user") => ChatRole::User,
875        Some("assistant") => ChatRole::Assistant,
876        Some("tool") => ChatRole::Tool,
877        _ => ChatRole::Assistant,
878    };
879    let mut content = Vec::new();
880
881    // v0.3: `parts[]` with `kind` discriminator.
882    if let Some(parts) = obj.get("parts").and_then(Value::as_array) {
883        for p in parts {
884            match p.get("kind").and_then(Value::as_str) {
885                Some("text") => content.push(ContentPart::Text {
886                    text: p
887                        .get("text")
888                        .and_then(Value::as_str)
889                        .unwrap_or_default()
890                        .to_string(),
891                }),
892                Some("file") => {
893                    if let Some(file) = p.get("file").and_then(Value::as_object) {
894                        // Prefer inline bytes, fall back to URI.
895                        if let Some(b64) = file.get("bytes").and_then(Value::as_str) {
896                            content.push(ContentPart::Image {
897                                url: None,
898                                media_type: file
899                                    .get("mimeType")
900                                    .and_then(Value::as_str)
901                                    .map(String::from),
902                                data_b64: Some(b64.to_string()),
903                            });
904                        } else if let Some(uri) = file.get("uri").and_then(Value::as_str) {
905                            content.push(ContentPart::Image {
906                                url: Some(uri.to_string()),
907                                media_type: file
908                                    .get("mimeType")
909                                    .and_then(Value::as_str)
910                                    .map(String::from),
911                                data_b64: None,
912                            });
913                        }
914                    }
915                }
916                Some("data") => {
917                    // Data parts: serialize as text for visibility.
918                    if let Some(data) = p.get("data") {
919                        content.push(ContentPart::Text {
920                            text: data.to_string(),
921                        });
922                    }
923                }
924                _ => {}
925            }
926        }
927    }
928
929    // Backward compat: old `content`/`type` shape.
930    if content.is_empty() {
931        match obj.get("content") {
932            Some(Value::String(s)) => content.push(ContentPart::Text { text: s.clone() }),
933            Some(Value::Array(parts)) => {
934                for p in parts {
935                    match p.get("type").and_then(Value::as_str) {
936                        Some("text") => content.push(ContentPart::Text {
937                            text: p
938                                .get("text")
939                                .and_then(Value::as_str)
940                                .unwrap_or_default()
941                                .to_string(),
942                        }),
943                        Some("image") => content.push(ContentPart::Image {
944                            url: p.get("image_url").and_then(Value::as_str).map(String::from),
945                            media_type: None,
946                            data_b64: None,
947                        }),
948                        _ => {}
949                    }
950                }
951            }
952            _ => {}
953        }
954    }
955
956    Ok(ChatMessage {
957        role,
958        content,
959        name: obj.get("name").and_then(Value::as_str).map(String::from),
960        tool_calls: Vec::new(),
961        tool_call_id: None,
962    })
963}
964
965// --- Session management (mirrors MCP) ------------------------------------
966
967/// Default session TTL in seconds (1 hour), mirroring MCP. Used by
968/// `CompiledA2a::compile` (feature-gated); `#[allow(dead_code)]` so the
969/// default-feature build (feature off) stays warning-free.
970#[allow(dead_code)]
971const DEFAULT_TTL_SECS: u64 = 3600;
972
973/// Default max concurrent sessions, mirroring MCP. Used by
974/// `CompiledA2a::compile` (feature-gated); `#[allow(dead_code)]` so the
975/// default-feature build (feature off) stays warning-free.
976#[allow(dead_code)]
977const DEFAULT_MAX_CONCURRENT: usize = 1000;
978
979/// Per-process session-id counter (disambiguates coarse clocks and
980/// same-nanosecond initializations), mirroring MCP.
981static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
982
983/// Generate a 128-bit hex session id (mirrors MCP's
984/// `generate_session_id`): sha256 over (wall-clock nanos, process
985/// counter, agent name), truncated to 32 hex chars. Unique per
986/// process; no `rand` dependency.
987fn generate_session_id(agent: &str) -> String {
988    let n = std::time::SystemTime::now()
989        .duration_since(std::time::UNIX_EPOCH)
990        .map(|d| d.as_nanos())
991        .unwrap_or(0);
992    let c = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
993    let mut hasher = Sha256::new();
994    hasher.update(n.to_le_bytes());
995    hasher.update(c.to_le_bytes());
996    hasher.update(agent.as_bytes());
997    let hash = hasher.finalize();
998    let hex: String = hash.iter().take(16).map(|b| format!("{b:02x}")).collect();
999    format!("a2a-{hex}")
1000}
1001
1002/// One A2A task session (DW-114), mirroring MCP's session model: a
1003/// session id, a TTL, and a max-concurrent cap. The session is a
1004/// correlation handle for an agent-to-agent task exchange and owns a
1005/// [`TaskStateMachine`] tracking the task's lifecycle state.
1006#[derive(Debug, Clone)]
1007pub struct A2ASession {
1008    /// The session id (a 128-bit hex handle, unique per process).
1009    pub id: String,
1010    /// The agent name this session is bound to.
1011    pub agent: String,
1012    /// Session TTL in seconds.
1013    pub ttl_secs: u64,
1014    /// Max concurrent sessions.
1015    pub max_concurrent: usize,
1016    /// The task lifecycle state machine for this session's task.
1017    pub task: TaskStateMachine,
1018}
1019
1020impl A2ASession {
1021    /// Create a new session for `agent`, using the configured session
1022    /// policy (or the defaults when none is set). The task starts in
1023    /// the `Submitted` state.
1024    pub fn new(agent: &str, ttl_secs: u64, max_concurrent: usize) -> Self {
1025        A2ASession {
1026            id: generate_session_id(agent),
1027            agent: agent.to_string(),
1028            ttl_secs,
1029            max_concurrent,
1030            task: TaskStateMachine::new(),
1031        }
1032    }
1033
1034    /// The session id.
1035    pub fn id(&self) -> &str {
1036        &self.id
1037    }
1038
1039    /// The current task lifecycle state.
1040    pub fn task_state(&self) -> TaskLifecycle {
1041        self.task.state()
1042    }
1043
1044    /// Start processing the task (Submitted -> Working). Delegates to
1045    /// the session's [`TaskStateMachine`].
1046    pub fn start_work(&mut self) -> Result<TaskLifecycle, A2AError> {
1047        self.task.start_work()
1048    }
1049
1050    /// Mark the task as completed (Working -> Completed).
1051    pub fn complete(&mut self) -> Result<TaskLifecycle, A2AError> {
1052        self.task.complete()
1053    }
1054
1055    /// Mark the task as failed (Submitted|Working -> Failed).
1056    pub fn fail(&mut self) -> Result<TaskLifecycle, A2AError> {
1057        self.task.fail()
1058    }
1059
1060    /// Cancel the task (Submitted|Working -> Canceled).
1061    pub fn cancel(&mut self) -> Result<TaskLifecycle, A2AError> {
1062        self.task.cancel()
1063    }
1064}
1065
1066// --- Compiled A2A (built at AiRuntime compile time) ----------------------
1067
1068/// One compiled A2A agent (DW-114): the resolved provider entry an
1069/// alias can route to. Built at `AiRuntime` compile time from the
1070/// `ai.a2a.agents[]` config; immutable once built.
1071#[derive(Debug, Clone)]
1072pub struct CompiledA2aAgent {
1073    /// The agent name (the provider name in the alias table).
1074    pub name: String,
1075    /// The agent's base URL.
1076    pub url: String,
1077    /// The parsed Agent Card (None when the agent has no card or the
1078    /// card failed to parse — the agent still compiles, the card is
1079    /// best-effort).
1080    pub card: Option<AgentCard>,
1081    /// The name of the upstream that carries the transport.
1082    pub upstream: String,
1083    /// Session TTL in seconds.
1084    pub sessions_ttl_secs: u64,
1085    /// Max concurrent sessions.
1086    pub sessions_max_concurrent: usize,
1087}
1088
1089/// The compiled A2A surface (DW-114): the agent table and session
1090/// policy. Built at `AiRuntime` compile time from the `ai.a2a` config
1091/// block; immutable once built. None when the block is absent or the
1092/// `a2a` feature is off (the block is inert).
1093#[derive(Debug, Clone)]
1094pub struct CompiledA2a {
1095    /// The compiled agents, keyed by agent name.
1096    pub agents: BTreeMap<String, CompiledA2aAgent>,
1097    /// Session TTL in seconds.
1098    pub sessions_ttl_secs: u64,
1099    /// Max concurrent sessions.
1100    pub sessions_max_concurrent: usize,
1101}
1102
1103impl CompiledA2a {
1104    /// Compile from the `ai.a2a` config block. Returns None when the
1105    /// block is absent, `enabled` is false, or the `a2a` feature is
1106    /// off (the block is inert in all those cases). Each agent's
1107    /// Agent Card is parsed best-effort: a parse failure logs a
1108    /// warning and the agent compiles without a card (the loud,
1109    /// attributable failure is the validation issue, not a compile
1110    /// abort).
1111    pub fn compile(config: Option<&A2aConfig>) -> Option<Self> {
1112        // Feature gate: when the `a2a` cargo feature is off, the
1113        // block is inert (no A2A providers are wired).
1114        {
1115            let cfg = config?;
1116            if !cfg.enabled {
1117                return None;
1118            }
1119            let sessions = cfg.sessions.as_ref();
1120            let sessions_ttl_secs = sessions
1121                .and_then(|s| s.ttl_secs)
1122                .unwrap_or(DEFAULT_TTL_SECS);
1123            let sessions_max_concurrent = sessions
1124                .and_then(|s| s.max_concurrent)
1125                .unwrap_or(DEFAULT_MAX_CONCURRENT);
1126            let mut agents = BTreeMap::new();
1127            for a in &cfg.agents {
1128                let card = match AgentCardParser::parse_source(a.card.as_ref()) {
1129                    Ok(c) => Some(c),
1130                    Err(e) => {
1131                        tracing::warn!(
1132                            code = "a2a_agent_card_parse_failed",
1133                            agent = %a.name,
1134                            "a2a agent card could not be parsed; agent compiles \
1135                             without a card: {e}"
1136                        );
1137                        None
1138                    }
1139                };
1140                agents.insert(
1141                    a.name.clone(),
1142                    CompiledA2aAgent {
1143                        name: a.name.clone(),
1144                        url: a.url.clone(),
1145                        card,
1146                        upstream: a.upstream.clone(),
1147                        sessions_ttl_secs,
1148                        sessions_max_concurrent,
1149                    },
1150                );
1151            }
1152            Some(CompiledA2a {
1153                agents,
1154                sessions_ttl_secs,
1155                sessions_max_concurrent,
1156            })
1157        }
1158    }
1159
1160    /// The compiled agent by name.
1161    pub fn agent(&self, name: &str) -> Option<&CompiledA2aAgent> {
1162        self.agents.get(name)
1163    }
1164
1165    /// All agent names (introspection/tests).
1166    pub fn agent_names(&self) -> impl Iterator<Item = &str> {
1167        self.agents.keys().map(|s| s.as_str())
1168    }
1169}
1170
1171/// Route an A2A call through the existing `dataplane::ai_proxy` path
1172/// (DW-114). The transport is the agent's named upstream, exactly like
1173/// a regular provider: the [`A2AAdapter`] builds the task-submit
1174/// request, `ai_proxy` places the call through the upstream, and the
1175/// response is parsed back to the canonical shape. This function is
1176/// the seam the dataplane calls for an A2A-routed alias; today it
1177/// returns an [`A2AStub`] error because the adapter intentionally
1178/// does not own an HTTP client (the transport is the agent's
1179/// upstream, driven from `dataplane::ai_proxy`). The task lifecycle
1180/// state machine itself is implemented (see [`TaskStateMachine`]);
1181/// this function is the network-call seam, not the state machine.
1182///
1183/// The scaffold keeps the call-site shape stable so the dataplane
1184/// path compiles unchanged with or without the feature.
1185pub fn handle_a2a_request(
1186    _agent: &CompiledA2aAgent,
1187    _req: &ChatRequest,
1188) -> Result<ChatResponse, A2AStub> {
1189    Err(A2AStub::new("handle_a2a_request"))
1190}
1191
1192/// Build the [`CompiledA2a`] from the gateway's `ai.a2a` block, for
1193/// the `AiRuntime` compile path. Returns None when the block is
1194/// absent or inert (feature off / disabled).
1195pub fn compile_a2a(gateway: &Gateway) -> Option<CompiledA2a> {
1196    let ai = gateway.ai.as_ref()?;
1197    CompiledA2a::compile(ai.a2a.as_ref())
1198}
1199
1200/// The A2A agent entries that should appear as providers in the alias
1201/// table (DW-114). Each entry is `(name, upstream)` — the provider
1202/// name and the upstream that carries its transport. The `AiRuntime`
1203/// compile path inserts these into the provider pool with
1204/// `kind: a2a`. Returns an empty vec when the block is absent or
1205/// inert.
1206pub fn a2a_providers(gateway: &Gateway) -> Vec<(String, String)> {
1207    let Some(compiled) = compile_a2a(gateway) else {
1208        return Vec::new();
1209    };
1210    compiled
1211        .agents
1212        .values()
1213        .map(|a| (a.name.clone(), a.upstream.clone()))
1214        .collect()
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::*;
1220
1221    // White-box: the A2A adapter's translation is private behavior
1222    // not exercised through a public caller (the gateway A2A path is
1223    // stubbed); these stay here with that justification and are
1224    // additionally replayed through the integration suite in
1225    // tests/a2a.rs.
1226
1227    #[test]
1228    fn task_lifecycle_wire_names_round_trip() {
1229        for s in &[
1230            TaskLifecycle::Submitted,
1231            TaskLifecycle::Working,
1232            TaskLifecycle::Completed,
1233            TaskLifecycle::Failed,
1234            TaskLifecycle::Canceled,
1235        ] {
1236            assert_eq!(TaskLifecycle::parse_state(s.as_str()), Some(*s));
1237        }
1238        assert_eq!(TaskLifecycle::parse_state("unknown"), None);
1239    }
1240
1241    #[test]
1242    fn task_lifecycle_is_terminal() {
1243        assert!(!TaskLifecycle::Submitted.is_terminal());
1244        assert!(!TaskLifecycle::Working.is_terminal());
1245        assert!(TaskLifecycle::Completed.is_terminal());
1246        assert!(TaskLifecycle::Failed.is_terminal());
1247        assert!(TaskLifecycle::Canceled.is_terminal());
1248    }
1249
1250    #[test]
1251    fn task_lifecycle_can_transition_to_legal() {
1252        assert!(TaskLifecycle::Submitted.can_transition_to(TaskLifecycle::Working));
1253        assert!(TaskLifecycle::Submitted.can_transition_to(TaskLifecycle::Failed));
1254        assert!(TaskLifecycle::Submitted.can_transition_to(TaskLifecycle::Canceled));
1255        assert!(TaskLifecycle::Working.can_transition_to(TaskLifecycle::Completed));
1256        assert!(TaskLifecycle::Working.can_transition_to(TaskLifecycle::Failed));
1257        assert!(TaskLifecycle::Working.can_transition_to(TaskLifecycle::Canceled));
1258    }
1259
1260    #[test]
1261    fn task_lifecycle_cannot_transition_to_illegal() {
1262        // No transitions out of terminal states.
1263        for terminal in [
1264            TaskLifecycle::Completed,
1265            TaskLifecycle::Failed,
1266            TaskLifecycle::Canceled,
1267        ] {
1268            for target in [
1269                TaskLifecycle::Submitted,
1270                TaskLifecycle::Working,
1271                TaskLifecycle::Completed,
1272                TaskLifecycle::Failed,
1273                TaskLifecycle::Canceled,
1274            ] {
1275                assert!(
1276                    !terminal.can_transition_to(target),
1277                    "terminal {:?} should not transition to {:?}",
1278                    terminal,
1279                    target
1280                );
1281            }
1282        }
1283        // No Submitted -> Completed (must go through Working).
1284        assert!(!TaskLifecycle::Submitted.can_transition_to(TaskLifecycle::Completed));
1285        // No Working -> Submitted (no going back).
1286        assert!(!TaskLifecycle::Working.can_transition_to(TaskLifecycle::Submitted));
1287    }
1288
1289    #[test]
1290    fn state_machine_happy_path_submitted_to_working_to_completed() {
1291        let mut sm = TaskStateMachine::new();
1292        assert_eq!(sm.state(), TaskLifecycle::Submitted);
1293        assert!(!sm.is_terminal());
1294
1295        let s = sm.start_work().expect("submitted -> working");
1296        assert_eq!(s, TaskLifecycle::Working);
1297        assert_eq!(sm.state(), TaskLifecycle::Working);
1298
1299        let s = sm.complete().expect("working -> completed");
1300        assert_eq!(s, TaskLifecycle::Completed);
1301        assert_eq!(sm.state(), TaskLifecycle::Completed);
1302        assert!(sm.is_terminal());
1303    }
1304
1305    #[test]
1306    fn state_machine_fail_from_submitted() {
1307        let mut sm = TaskStateMachine::new();
1308        let s = sm.fail().expect("submitted -> failed");
1309        assert_eq!(s, TaskLifecycle::Failed);
1310        assert!(sm.is_terminal());
1311    }
1312
1313    #[test]
1314    fn state_machine_fail_from_working() {
1315        let mut sm = TaskStateMachine::new();
1316        sm.start_work().expect("submitted -> working");
1317        let s = sm.fail().expect("working -> failed");
1318        assert_eq!(s, TaskLifecycle::Failed);
1319        assert!(sm.is_terminal());
1320    }
1321
1322    #[test]
1323    fn state_machine_cancel_from_submitted() {
1324        let mut sm = TaskStateMachine::new();
1325        let s = sm.cancel().expect("submitted -> canceled");
1326        assert_eq!(s, TaskLifecycle::Canceled);
1327        assert!(sm.is_terminal());
1328    }
1329
1330    #[test]
1331    fn state_machine_cancel_from_working() {
1332        let mut sm = TaskStateMachine::new();
1333        sm.start_work().expect("submitted -> working");
1334        let s = sm.cancel().expect("working -> canceled");
1335        assert_eq!(s, TaskLifecycle::Canceled);
1336        assert!(sm.is_terminal());
1337    }
1338
1339    #[test]
1340    fn state_machine_rejects_completed_to_working() {
1341        let mut sm = TaskStateMachine::new();
1342        sm.start_work().expect("submitted -> working");
1343        sm.complete().expect("working -> completed");
1344        let err = sm.start_work().expect_err("completed is terminal");
1345        assert!(matches!(
1346            err,
1347            A2AError::IllegalTransition {
1348                from: TaskLifecycle::Completed,
1349                to: TaskLifecycle::Working,
1350            }
1351        ));
1352    }
1353
1354    #[test]
1355    fn state_machine_rejects_failed_to_completed() {
1356        let mut sm = TaskStateMachine::new();
1357        sm.fail().expect("submitted -> failed");
1358        let err = sm.complete().expect_err("failed is terminal");
1359        assert!(matches!(
1360            err,
1361            A2AError::IllegalTransition {
1362                from: TaskLifecycle::Failed,
1363                to: TaskLifecycle::Completed,
1364            }
1365        ));
1366    }
1367
1368    #[test]
1369    fn state_machine_rejects_canceled_to_working() {
1370        let mut sm = TaskStateMachine::new();
1371        sm.cancel().expect("submitted -> canceled");
1372        let err = sm.start_work().expect_err("canceled is terminal");
1373        assert!(matches!(
1374            err,
1375            A2AError::IllegalTransition {
1376                from: TaskLifecycle::Canceled,
1377                to: TaskLifecycle::Working,
1378            }
1379        ));
1380    }
1381
1382    #[test]
1383    fn state_machine_rejects_submitted_to_completed() {
1384        let mut sm = TaskStateMachine::new();
1385        let err = sm.complete().expect_err("must go through working");
1386        assert!(matches!(
1387            err,
1388            A2AError::IllegalTransition {
1389                from: TaskLifecycle::Submitted,
1390                to: TaskLifecycle::Completed,
1391            }
1392        ));
1393    }
1394
1395    #[test]
1396    fn state_machine_rejects_working_to_submitted() {
1397        let mut sm = TaskStateMachine::new();
1398        sm.start_work().expect("submitted -> working");
1399        let err = sm
1400            .transition_to(TaskLifecycle::Submitted)
1401            .expect_err("no going back");
1402        assert!(matches!(
1403            err,
1404            A2AError::IllegalTransition {
1405                from: TaskLifecycle::Working,
1406                to: TaskLifecycle::Submitted,
1407            }
1408        ));
1409    }
1410
1411    #[test]
1412    fn state_machine_from_state() {
1413        let sm = TaskStateMachine::from_state(TaskLifecycle::Working);
1414        assert_eq!(sm.state(), TaskLifecycle::Working);
1415    }
1416
1417    #[test]
1418    fn session_task_lifecycle() {
1419        let mut s = A2ASession::new("my-agent", 3600, 1000);
1420        assert!(s.id().starts_with("a2a-"));
1421        assert_eq!(s.task_state(), TaskLifecycle::Submitted);
1422
1423        s.start_work().expect("submitted -> working");
1424        assert_eq!(s.task_state(), TaskLifecycle::Working);
1425
1426        s.complete().expect("working -> completed");
1427        assert_eq!(s.task_state(), TaskLifecycle::Completed);
1428    }
1429
1430    #[test]
1431    fn session_cancel_from_submitted() {
1432        let mut s = A2ASession::new("my-agent", 3600, 1000);
1433        s.cancel().expect("submitted -> canceled");
1434        assert_eq!(s.task_state(), TaskLifecycle::Canceled);
1435    }
1436
1437    #[test]
1438    fn session_fail_from_working() {
1439        let mut s = A2ASession::new("my-agent", 3600, 1000);
1440        s.start_work().expect("submitted -> working");
1441        s.fail().expect("working -> failed");
1442        assert_eq!(s.task_state(), TaskLifecycle::Failed);
1443    }
1444
1445    #[test]
1446    fn session_rejects_illegal_transition() {
1447        let mut s = A2ASession::new("my-agent", 3600, 1000);
1448        s.complete().expect_err("submitted -> completed is illegal");
1449        assert_eq!(s.task_state(), TaskLifecycle::Submitted);
1450    }
1451
1452    #[test]
1453    fn handle_a2a_request_is_stubbed() {
1454        let agent = CompiledA2aAgent {
1455            name: "a".to_string(),
1456            url: "https://agent.example.com".to_string(),
1457            card: None,
1458            upstream: "u".to_string(),
1459            sessions_ttl_secs: 3600,
1460            sessions_max_concurrent: 1000,
1461        };
1462        let req = ChatRequest {
1463            model: "m".to_string(),
1464            messages: vec![ChatMessage::text(ChatRole::User, "hi")],
1465            tools: Vec::new(),
1466            tool_choice: None,
1467            temperature: None,
1468            top_p: None,
1469            max_tokens: None,
1470            stop: None,
1471            stream: false,
1472            stream_options_include_usage: false,
1473            response_format: None,
1474            prompt: None,
1475            prompt_variables: Default::default(),
1476            other: BTreeMap::new(),
1477        };
1478        let err = handle_a2a_request(&agent, &req).unwrap_err();
1479        assert_eq!(err.transition, "handle_a2a_request");
1480    }
1481}