Skip to main content

inference_gateway_adk/server/
task_handler.rs

1use super::agent::Agent;
2use super::artifact_service::ArtifactService;
3use super::storage::Storage;
4use super::usage_tracker::UsageTracker;
5use crate::a2a_types::{
6    Artifact, Message as A2AMessage, Part, Role, StreamResponse, Struct, Task,
7    TaskArtifactUpdateEvent, TaskState, TaskStatus, TaskStatusUpdateEvent, Timestamp,
8};
9use anyhow::{Result, anyhow};
10use futures_util::stream::StreamExt;
11use inference_gateway_sdk::{CompletionUsage, Message, MessageContent, MessageRole};
12use serde_json::{Map, Value};
13use std::sync::Arc;
14use tokio::sync::mpsc;
15use tracing::{debug, warn};
16
17/// Handler invoked by the server for `message/send` requests.
18///
19/// Implementations receive a freshly-built task (already in
20/// `TaskStateSubmitted`) plus the incoming user message, run the business
21/// logic, and return the final task - typically with `state == Completed`
22/// and an agent reply attached to `status.message`.
23#[async_trait::async_trait]
24pub trait TaskHandler: Send + Sync + std::fmt::Debug {
25    async fn handle_task(&self, task: Task, message: Option<A2AMessage>) -> Result<Task>;
26}
27
28/// Handler invoked by the server for `message/stream` requests.
29///
30/// The server is responsible for parsing the request, persisting the initial
31/// `Submitted` task, and emitting the first event (the `Task` wrapper). The
32/// handler then drives the task to a terminal state by emitting
33/// `StreamResponse` events via [`StreamEmitter`]. The last emitted event
34/// **must** carry a `TaskStatusUpdateEvent` with `final: true`; otherwise
35/// callers will treat the stream as unterminated.
36#[async_trait::async_trait]
37pub trait StreamableTaskHandler: Send + Sync + std::fmt::Debug {
38    /// Drive a `message/stream` interaction.
39    ///
40    /// `task` is the freshly-built task already persisted in storage at
41    /// `TaskStateSubmitted`. The handler should emit subsequent events
42    /// (typically `Working` → optional artifact(s) → `Completed`).
43    async fn handle_streaming_task(
44        &self,
45        task: Task,
46        message: Option<A2AMessage>,
47        emitter: StreamEmitter,
48    ) -> Result<()>;
49}
50
51/// Emits `StreamResponse` events into an active `message/stream` response and
52/// keeps the stored task in sync with the latest status.
53#[derive(Clone)]
54pub struct StreamEmitter {
55    tx: mpsc::Sender<StreamResponse>,
56    storage: Arc<dyn Storage>,
57    artifact_service: Option<Arc<dyn ArtifactService>>,
58}
59
60impl std::fmt::Debug for StreamEmitter {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("StreamEmitter").finish_non_exhaustive()
63    }
64}
65
66impl StreamEmitter {
67    pub(super) fn new(tx: mpsc::Sender<StreamResponse>, storage: Arc<dyn Storage>) -> Self {
68        Self {
69            tx,
70            storage,
71            artifact_service: None,
72        }
73    }
74
75    /// Attach an [`ArtifactService`] so the emitter can mint URI-bearing
76    /// file/data artifacts (via [`emit_file_artifact`](Self::emit_file_artifact)
77    /// / [`emit_data_artifact`](Self::emit_data_artifact)).
78    pub(super) fn with_artifact_service(
79        mut self,
80        artifact_service: Option<Arc<dyn ArtifactService>>,
81    ) -> Self {
82        self.artifact_service = artifact_service;
83        self
84    }
85
86    /// Access the underlying artifact service, when one is wired up.
87    /// Streaming handlers can fall back to building their own artifacts
88    /// when this returns `None`.
89    pub fn artifact_service(&self) -> Option<Arc<dyn ArtifactService>> {
90        self.artifact_service.clone()
91    }
92
93    /// Send a raw `StreamResponse` to the connected client.
94    pub async fn emit(&self, response: StreamResponse) -> Result<()> {
95        self.tx
96            .send(response)
97            .await
98            .map_err(|_| anyhow!("stream receiver dropped before handler finished"))
99    }
100
101    /// Convenience helper that updates the stored task to `state` (attaching
102    /// `message` to the task status if provided), then emits a
103    /// `TaskStatusUpdateEvent` describing the new state.
104    pub async fn emit_status(
105        &self,
106        task_id: &str,
107        context_id: &str,
108        state: TaskState,
109        message: Option<A2AMessage>,
110        final_: bool,
111    ) -> Result<()> {
112        let now = Timestamp(chrono::Utc::now());
113        let new_status = TaskStatus {
114            message: message.clone(),
115            state,
116            timestamp: Some(now),
117        };
118
119        if let Some(mut task) = self.storage.get_task(task_id).await {
120            task.status = new_status.clone();
121            if let Some(ref msg) = message {
122                task.history.push(msg.clone());
123            }
124            self.storage.put_task(task).await;
125        }
126
127        let event = TaskStatusUpdateEvent {
128            context_id: context_id.to_string(),
129            final_,
130            metadata: None,
131            status: new_status,
132            task_id: task_id.to_string(),
133        };
134
135        self.emit(StreamResponse {
136            artifact_update: None,
137            message: None,
138            status_update: Some(event),
139            task: None,
140        })
141        .await
142    }
143
144    /// Convenience helper that appends a text artifact to the stored task and
145    /// emits a `TaskArtifactUpdateEvent` describing it.
146    pub async fn emit_text_artifact(
147        &self,
148        task_id: &str,
149        context_id: &str,
150        text: impl Into<String>,
151        last_chunk: bool,
152    ) -> Result<()> {
153        let artifact_id = uuid::Uuid::new_v4().to_string();
154        let text = text.into();
155        let artifact = Artifact {
156            artifact_id: artifact_id.clone(),
157            description: None,
158            extensions: vec![],
159            metadata: None,
160            name: None,
161            parts: vec![Part {
162                data: None,
163                file: None,
164                metadata: None,
165                text: Some(text),
166            }],
167        };
168
169        if let Some(mut task) = self.storage.get_task(task_id).await {
170            task.artifacts.push(artifact.clone());
171            self.storage.put_task(task).await;
172        }
173
174        let event = TaskArtifactUpdateEvent {
175            append: None,
176            artifact,
177            context_id: context_id.to_string(),
178            last_chunk: Some(last_chunk),
179            metadata: None,
180            task_id: task_id.to_string(),
181        };
182
183        self.emit(StreamResponse {
184            artifact_update: Some(event),
185            message: None,
186            status_update: None,
187            task: None,
188        })
189        .await
190    }
191
192    /// Persist `data` through the configured [`ArtifactService`] and
193    /// emit a [`TaskArtifactUpdateEvent`] whose [`FilePart`] carries a
194    /// URI rather than inline bytes.
195    ///
196    /// Falls back to a [`FilePart`] with `fileWithBytes` when no
197    /// [`ArtifactService`] is configured.
198    ///
199    /// [`FilePart`]: crate::a2a_types::FilePart
200    pub async fn emit_file_artifact(
201        &self,
202        task_id: &str,
203        context_id: &str,
204        filename: &str,
205        data: Vec<u8>,
206        mime: Option<&str>,
207        last_chunk: bool,
208    ) -> Result<()> {
209        let svc = match self.artifact_service.as_ref() {
210            Some(s) => Arc::clone(s),
211            None => Arc::new(super::artifact_service::DefaultArtifactService::without_storage())
212                as Arc<dyn ArtifactService>,
213        };
214        let artifact = svc
215            .create_file_artifact(filename, "", filename, data, mime)
216            .await?;
217
218        if let Some(mut task) = self.storage.get_task(task_id).await {
219            task.artifacts.push(artifact.clone());
220            self.storage.put_task(task).await;
221        }
222
223        let event = TaskArtifactUpdateEvent {
224            append: None,
225            artifact,
226            context_id: context_id.to_string(),
227            last_chunk: Some(last_chunk),
228            metadata: None,
229            task_id: task_id.to_string(),
230        };
231        self.emit(StreamResponse {
232            artifact_update: Some(event),
233            message: None,
234            status_update: None,
235            task: None,
236        })
237        .await
238    }
239
240    /// Build a structured-data artifact via the configured
241    /// [`ArtifactService`] (or a service-less default) and emit a
242    /// [`TaskArtifactUpdateEvent`] describing it.
243    pub async fn emit_data_artifact(
244        &self,
245        task_id: &str,
246        context_id: &str,
247        name: &str,
248        description: &str,
249        data: serde_json::Value,
250        last_chunk: bool,
251    ) -> Result<()> {
252        let svc: Arc<dyn ArtifactService> = match self.artifact_service.as_ref() {
253            Some(s) => Arc::clone(s),
254            None => Arc::new(super::artifact_service::DefaultArtifactService::without_storage()),
255        };
256        let artifact = svc.create_data_artifact(name, description, data);
257
258        if let Some(mut task) = self.storage.get_task(task_id).await {
259            task.artifacts.push(artifact.clone());
260            self.storage.put_task(task).await;
261        }
262
263        let event = TaskArtifactUpdateEvent {
264            append: None,
265            artifact,
266            context_id: context_id.to_string(),
267            last_chunk: Some(last_chunk),
268            metadata: None,
269            task_id: task_id.to_string(),
270        };
271        self.emit(StreamResponse {
272            artifact_update: Some(event),
273            message: None,
274            status_update: None,
275            task: None,
276        })
277        .await
278    }
279
280    /// Merge the accumulated usage/execution statistics from `tracker` into the
281    /// stored task's `metadata` field. Streaming handlers call this once, just
282    /// before emitting the terminal status update, so the persisted task
283    /// (returned by later `tasks/get` calls) carries the same `usage` /
284    /// `execution_stats` blocks the background handler attaches.
285    pub async fn populate_usage_metadata(&self, task_id: &str, tracker: &UsageTracker) {
286        let usage = tracker.metadata();
287        if usage.is_empty() {
288            return;
289        }
290        if let Some(mut task) = self.storage.get_task(task_id).await {
291            merge_usage_metadata(&mut task, usage);
292            self.storage.put_task(task).await;
293        }
294    }
295}
296
297/// Merge a usage-metadata map into `task.metadata`, preserving any existing
298/// keys. Used by both default handlers so the emitted `usage` /
299/// `execution_stats` blocks are identical across the streaming and background
300/// paths.
301fn merge_usage_metadata(task: &mut Task, usage: Map<String, Value>) {
302    if usage.is_empty() {
303        return;
304    }
305    match task.metadata.as_mut() {
306        Some(existing) => {
307            for (key, value) in usage {
308                existing.0.insert(key, value);
309            }
310        }
311        None => task.metadata = Some(Struct(usage)),
312    }
313}
314
315pub(super) fn build_agent_text_message(task: &Task, text: &str) -> A2AMessage {
316    A2AMessage {
317        context_id: Some(task.context_id.clone()),
318        extensions: vec![],
319        message_id: uuid::Uuid::new_v4().to_string(),
320        metadata: None,
321        parts: vec![Part {
322            data: None,
323            file: None,
324            metadata: None,
325            text: Some(text.to_string()),
326        }],
327        reference_task_ids: vec![],
328        role: Role::RoleAgent,
329        task_id: Some(task.id.clone()),
330    }
331}
332
333fn message_content_to_string(content: &MessageContent) -> String {
334    match content {
335        MessageContent::String(s) => s.clone(),
336        MessageContent::Array(parts) => serde_json::to_string(parts).unwrap_or_default(),
337    }
338}
339
340/// Translate the task history into the SDK message format expected by the
341/// agent's [`LLMClient`]. Optionally prepends the agent's system prompt.
342///
343/// [`LLMClient`]: super::agent_llm_client::LLMClient
344fn build_sdk_messages(agent: &Agent, task: &Task) -> Vec<Message> {
345    let mut messages: Vec<Message> = Vec::new();
346    if let Some(prompt) = agent.system_prompt.clone() {
347        messages.push(Message {
348            role: MessageRole::System,
349            content: MessageContent::String(prompt),
350            reasoning: None,
351            reasoning_content: None,
352            tool_call_id: None,
353            tool_calls: Vec::new(),
354        });
355    }
356    for a2a_msg in &task.history {
357        let text = a2a_msg
358            .parts
359            .iter()
360            .filter_map(|p| p.text.clone())
361            .collect::<Vec<_>>()
362            .join("");
363        if text.is_empty() {
364            continue;
365        }
366        let role = match a2a_msg.role {
367            Role::RoleAgent => MessageRole::Assistant,
368            _ => MessageRole::User,
369        };
370        messages.push(Message {
371            role,
372            content: MessageContent::String(text),
373            reasoning: None,
374            reasoning_content: None,
375            tool_call_id: None,
376            tool_calls: Vec::new(),
377        });
378    }
379    messages
380}
381
382/// Static message returned by the default handlers when no agent is
383/// configured.
384const NO_AGENT_REPLY: &str = "I received your message. I'm a default task handler without AI capabilities. \
385     To enable AI responses, configure an OpenAI-compatible agent via \
386     `A2AServerBuilder::with_agent(...)`.";
387
388/// Outcome of [`run_tool_loop`]. Carries the conversation buffer (with all
389/// assistant tool-call messages + tool result messages appended in order)
390/// plus the final assistant text the model returned once it stopped
391/// invoking tools, and a flag indicating whether the loop hit the iteration
392/// cap.
393struct ToolLoopOutcome {
394    messages: Vec<Message>,
395    final_text: String,
396    exhausted: bool,
397}
398
399/// Drive a non-streaming "model call → execute tool_calls → feed results
400/// back" loop up to `agent.max_chat_completion()` iterations. The default
401/// task handlers use this to bridge the gap between the inference gateway
402/// (which only emits raw OpenAI-style tool_calls) and the registered
403/// [`ToolHandler`] implementations on the agent.
404///
405/// Tool activity is silent at the wire level - which
406/// only debug-logs tool lifecycle events from inside its
407/// `DefaultBackgroundTaskHandler` instead of forwarding them as A2A
408/// `TaskStatusUpdate` events (the A2A spec has no tool-event variant).
409async fn run_tool_loop(
410    agent: &Agent,
411    mut messages: Vec<Message>,
412    tracker: &UsageTracker,
413) -> Result<ToolLoopOutcome> {
414    let llm = agent.llm_client();
415    let tools = agent.toolbox.clone();
416    let max_iterations = agent.max_chat_completion().max(1) as usize;
417
418    for _ in 0..max_iterations {
419        tracker.increment_iteration();
420
421        let response = llm
422            .create_chat_completion(messages.clone(), tools.clone())
423            .await
424            .map_err(|e| anyhow!("llm call failed: {e}"))?;
425
426        if let Some(usage) = response.usage.as_ref() {
427            tracker.add_token_usage(usage);
428        }
429
430        let Some(choice) = response.choices.into_iter().next() else {
431            return Ok(ToolLoopOutcome {
432                messages,
433                final_text: String::new(),
434                exhausted: false,
435            });
436        };
437
438        let assistant_text = message_content_to_string(&choice.message.content);
439        let tool_calls = choice.message.tool_calls.clone();
440        let reasoning = choice.message.reasoning.clone();
441        let reasoning_content = choice.message.reasoning_content.clone();
442
443        messages.push(Message {
444            role: MessageRole::Assistant,
445            content: MessageContent::String(assistant_text.clone()),
446            reasoning,
447            reasoning_content,
448            tool_call_id: None,
449            tool_calls: tool_calls.clone(),
450        });
451
452        if tool_calls.is_empty() {
453            return Ok(ToolLoopOutcome {
454                messages,
455                final_text: assistant_text,
456                exhausted: false,
457            });
458        }
459
460        let num_tool_calls = tool_calls.len();
461        for tool_call in tool_calls {
462            let tool_name = tool_call.function.name.clone();
463            let args: Value = serde_json::from_str(&tool_call.function.arguments)
464                .unwrap_or_else(|_| Value::String(tool_call.function.arguments.clone()));
465
466            debug!("tool dispatch: {tool_name}");
467            tracker.increment_tool_calls();
468
469            let tool_result = match agent.tool_handler(&tool_name) {
470                Some(handler) => match handler.handle(args).await {
471                    Ok(value) => value,
472                    Err(e) => {
473                        tracker.increment_failed_tools();
474                        format!("tool `{tool_name}` failed: {e}")
475                    }
476                },
477                None => {
478                    tracker.increment_failed_tools();
479                    format!("no handler registered for tool `{tool_name}`")
480                }
481            };
482
483            messages.push(Message {
484                role: MessageRole::Tool,
485                content: MessageContent::String(tool_result),
486                reasoning: None,
487                reasoning_content: None,
488                tool_call_id: Some(tool_call.id.clone()),
489                tool_calls: Vec::new(),
490            });
491        }
492        tracker.add_messages(num_tool_calls);
493    }
494
495    Ok(ToolLoopOutcome {
496        messages,
497        final_text: String::new(),
498        exhausted: true,
499    })
500}
501
502/// Opt-in default `message/send` handler wired up by
503/// [`A2AServerBuilder::with_default_background_task_handler`] /
504/// [`A2AServerBuilder::with_default_task_handlers`].
505///
506/// When an [`Agent`] is configured, delegates to the inference gateway via a
507/// single non-streaming `generate_content` call and returns the resulting
508/// task with `state == Completed` and the reply attached. Without an agent,
509/// returns the static [`NO_AGENT_REPLY`] message - `processWithoutAgentBackground`.
510#[derive(Debug)]
511pub struct DefaultBackgroundTaskHandler {
512    agent: Option<Arc<Agent>>,
513    enable_usage_metadata: bool,
514}
515
516impl DefaultBackgroundTaskHandler {
517    pub fn new(agent: Option<Arc<Agent>>) -> Self {
518        let enable_usage_metadata = agent
519            .as_ref()
520            .map(|a| a.usage_metadata_enabled())
521            .unwrap_or(true);
522        Self {
523            agent,
524            enable_usage_metadata,
525        }
526    }
527
528    /// Override whether terminal tasks carry `usage` / `execution_stats`
529    /// metadata. [`A2AServerBuilder`](crate::A2AServerBuilder) calls this to
530    /// honour [`AgentConfig::enable_usage_metadata`](crate::AgentConfig).
531    pub fn set_enable_usage_metadata(&mut self, enable: bool) {
532        self.enable_usage_metadata = enable;
533    }
534
535    /// Whether terminal tasks will carry usage/execution metadata.
536    pub fn is_usage_metadata_enabled(&self) -> bool {
537        self.enable_usage_metadata
538    }
539}
540
541#[async_trait::async_trait]
542impl TaskHandler for DefaultBackgroundTaskHandler {
543    async fn handle_task(&self, mut task: Task, _message: Option<A2AMessage>) -> Result<Task> {
544        let tracker = UsageTracker::new();
545        let (reply_text, terminal_state) = match self.agent.as_ref() {
546            Some(agent) => {
547                let messages = build_sdk_messages(agent, &task);
548                match run_tool_loop(agent, messages, &tracker).await {
549                    Ok(outcome) if outcome.exhausted => {
550                        warn!(
551                            "default background handler: tool loop exhausted \
552                             after {} iterations without a final answer",
553                            agent.max_chat_completion()
554                        );
555                        (
556                            "Tool loop exhausted before the model produced a \
557                             final answer."
558                                .to_string(),
559                            TaskState::TaskStateFailed,
560                        )
561                    }
562                    Ok(outcome) => {
563                        let text = if outcome.final_text.is_empty() {
564                            "Task completed".to_string()
565                        } else {
566                            outcome.final_text
567                        };
568                        (text, TaskState::TaskStateCompleted)
569                    }
570                    Err(e) => {
571                        warn!("default background handler: agent call failed: {e}");
572                        (
573                            format!("Agent call failed: {e}"),
574                            TaskState::TaskStateFailed,
575                        )
576                    }
577                }
578            }
579            None => (NO_AGENT_REPLY.to_string(), TaskState::TaskStateCompleted),
580        };
581
582        let reply = build_agent_text_message(&task, &reply_text);
583        task.history.push(reply.clone());
584        task.status = TaskStatus {
585            message: Some(reply),
586            state: terminal_state,
587            timestamp: Some(Timestamp(chrono::Utc::now())),
588        };
589
590        if self.enable_usage_metadata && tracker.has_usage() {
591            merge_usage_metadata(&mut task, tracker.metadata());
592        }
593
594        Ok(task)
595    }
596}
597
598/// Opt-in default `message/stream` handler wired up by
599/// [`A2AServerBuilder::with_default_streaming_task_handler`] /
600/// [`A2AServerBuilder::with_default_task_handlers`].
601///
602/// When an [`Agent`] is configured, the handler iterates `generate_content_stream`
603/// from the inference gateway, parses each OpenAI-style delta, and emits a
604/// [`TaskArtifactUpdateEvent`] per non-empty content chunk (`append: true`,
605/// shared `artifact_id`) - clients see the reply build up in real time. The
606/// stream terminates with a final `last_chunk: true` artifact + a
607/// `Completed` status update.
608///
609/// Without an agent, emits a single instructional artifact + `Completed`
610/// so the bundled defaults remain usable for examples and tests.
611#[derive(Debug)]
612pub struct DefaultStreamingTaskHandler {
613    agent: Option<Arc<Agent>>,
614    enable_usage_metadata: bool,
615}
616
617impl DefaultStreamingTaskHandler {
618    pub fn new(agent: Option<Arc<Agent>>) -> Self {
619        let enable_usage_metadata = agent
620            .as_ref()
621            .map(|a| a.usage_metadata_enabled())
622            .unwrap_or(true);
623        Self {
624            agent,
625            enable_usage_metadata,
626        }
627    }
628
629    /// Override whether terminal tasks carry `usage` / `execution_stats`
630    /// metadata. [`A2AServerBuilder`](crate::A2AServerBuilder) calls this to
631    /// honour [`AgentConfig::enable_usage_metadata`](crate::AgentConfig).
632    pub fn set_enable_usage_metadata(&mut self, enable: bool) {
633        self.enable_usage_metadata = enable;
634    }
635
636    /// Whether terminal tasks will carry usage/execution metadata.
637    pub fn is_usage_metadata_enabled(&self) -> bool {
638        self.enable_usage_metadata
639    }
640}
641
642#[async_trait::async_trait]
643impl StreamableTaskHandler for DefaultStreamingTaskHandler {
644    async fn handle_streaming_task(
645        &self,
646        task: Task,
647        _message: Option<A2AMessage>,
648        emitter: StreamEmitter,
649    ) -> Result<()> {
650        emitter
651            .emit_status(
652                &task.id,
653                &task.context_id,
654                TaskState::TaskStateWorking,
655                None,
656                false,
657            )
658            .await?;
659
660        let tracker = UsageTracker::new();
661        let final_text = match self.agent.as_ref() {
662            Some(agent) => stream_agent_deltas(agent, &task, &emitter, &tracker).await?,
663            None => {
664                emitter
665                    .emit_text_artifact(&task.id, &task.context_id, NO_AGENT_REPLY, true)
666                    .await?;
667                NO_AGENT_REPLY.to_string()
668            }
669        };
670
671        if self.enable_usage_metadata && tracker.has_usage() {
672            emitter.populate_usage_metadata(&task.id, &tracker).await;
673        }
674
675        let reply_message = build_agent_text_message(&task, &final_text);
676        emitter
677            .emit_status(
678                &task.id,
679                &task.context_id,
680                TaskState::TaskStateCompleted,
681                Some(reply_message),
682                true,
683            )
684            .await
685    }
686}
687
688/// Drive `generate_content_stream` and forward each delta chunk to
689/// `emitter` as an incremental [`TaskArtifactUpdateEvent`] sharing a single
690/// `artifact_id`. Returns the accumulated reply text on success. On gateway
691/// failure, the helper falls back to a one-shot error artifact so the
692/// stream still terminates cleanly.
693///
694/// When the agent advertises tools, this helper first runs a non-streaming
695/// [`run_tool_loop`] preflight so any `tool_calls` the model emits get
696/// dispatched to registered [`ToolHandler`] implementations. Once the model
697/// stops requesting tools (or the iteration cap is hit), the final answer
698/// is fetched via `generate_content_stream` and delivered as deltas.
699async fn stream_agent_deltas(
700    agent: &Agent,
701    task: &Task,
702    emitter: &StreamEmitter,
703    tracker: &UsageTracker,
704) -> Result<String> {
705    let base_messages = build_sdk_messages(agent, task);
706
707    let messages = if agent.toolbox().is_some() {
708        match run_tool_loop(agent, base_messages, tracker).await {
709            Ok(outcome) if outcome.exhausted => {
710                let msg = "Tool loop exhausted before the model produced a \
711                           final answer."
712                    .to_string();
713                emitter
714                    .emit_text_artifact(&task.id, &task.context_id, &msg, true)
715                    .await?;
716                return Ok(msg);
717            }
718            Ok(outcome) => {
719                if !outcome.final_text.is_empty()
720                    && outcome
721                        .messages
722                        .last()
723                        .map(|m| m.tool_calls.is_empty())
724                        .unwrap_or(true)
725                {
726                    emitter
727                        .emit_text_artifact(&task.id, &task.context_id, &outcome.final_text, true)
728                        .await?;
729                    return Ok(outcome.final_text);
730                }
731                outcome.messages
732            }
733            Err(e) => {
734                warn!("default streaming handler: tool loop failed: {e}");
735                let msg = format!("Agent stream failed: {e}");
736                emitter
737                    .emit_text_artifact(&task.id, &task.context_id, &msg, true)
738                    .await?;
739                return Ok(msg);
740            }
741        }
742    } else {
743        base_messages
744    };
745
746    let llm = agent.llm_client();
747    let tools = agent.toolbox.clone();
748    // The streaming tail is one more model round-trip; count it so a turn that
749    // never entered the tool loop (e.g. an agent without tools) still reports
750    // at least one iteration, matching the Go ADK.
751    tracker.increment_iteration();
752    let mut stream = llm.create_streaming_chat_completion(messages, tools);
753
754    let artifact_id = uuid::Uuid::new_v4().to_string();
755    let mut buffer = String::new();
756
757    while let Some(item) = stream.next().await {
758        let event = match item {
759            Ok(e) => e,
760            Err(e) => {
761                warn!("default streaming handler: gateway error: {e}");
762                let msg = format!("Agent stream failed: {e}");
763                emitter
764                    .emit_text_artifact(&task.id, &task.context_id, &msg, true)
765                    .await?;
766                return Ok(msg);
767            }
768        };
769
770        let data = event.data.trim();
771        if data.is_empty() || data == "[DONE]" {
772            if data == "[DONE]" {
773                break;
774            }
775            continue;
776        }
777
778        let parsed: serde_json::Value = match serde_json::from_str(data) {
779            Ok(v) => v,
780            Err(_) => continue,
781        };
782
783        if let Some(usage_value) = parsed.get("usage").filter(|v| !v.is_null())
784            && let Ok(usage) = serde_json::from_value::<CompletionUsage>(usage_value.clone())
785        {
786            tracker.add_token_usage(&usage);
787        }
788
789        let Some(text) = parsed
790            .get("choices")
791            .and_then(|c| c.as_array())
792            .and_then(|arr| arr.first())
793            .and_then(|c| c.get("delta"))
794            .and_then(|d| d.get("content"))
795            .and_then(|t| t.as_str())
796        else {
797            continue;
798        };
799        if text.is_empty() {
800            continue;
801        }
802        buffer.push_str(text);
803
804        let chunk_event = TaskArtifactUpdateEvent {
805            append: Some(true),
806            artifact: Artifact {
807                artifact_id: artifact_id.clone(),
808                description: None,
809                extensions: vec![],
810                metadata: None,
811                name: None,
812                parts: vec![Part {
813                    data: None,
814                    file: None,
815                    metadata: None,
816                    text: Some(text.to_string()),
817                }],
818            },
819            context_id: task.context_id.clone(),
820            last_chunk: Some(false),
821            metadata: None,
822            task_id: task.id.clone(),
823        };
824        emitter
825            .emit(StreamResponse {
826                artifact_update: Some(chunk_event),
827                message: None,
828                status_update: None,
829                task: None,
830            })
831            .await?;
832    }
833
834    let final_event = TaskArtifactUpdateEvent {
835        append: Some(true),
836        artifact: Artifact {
837            artifact_id,
838            description: None,
839            extensions: vec![],
840            metadata: None,
841            name: None,
842            parts: vec![],
843        },
844        context_id: task.context_id.clone(),
845        last_chunk: Some(true),
846        metadata: None,
847        task_id: task.id.clone(),
848    };
849    emitter
850        .emit(StreamResponse {
851            artifact_update: Some(final_event),
852            message: None,
853            status_update: None,
854            task: None,
855        })
856        .await?;
857
858    Ok(buffer)
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use crate::a2a_types::{AgentCard, Role, SendMessageRequest};
865    use crate::server::agent_builder::AgentBuilder;
866    use crate::server::protocol::{AppState, a2a_handler};
867    use crate::server::server_builder::A2AServerBuilder;
868    use axum::Router;
869    use axum::extract::State;
870    use axum::response::Json;
871    use axum::routing::post;
872    use inference_gateway_sdk::{
873        ChatCompletionTool, ChatCompletionToolType, FunctionObject, FunctionParameters,
874    };
875    use tokio::net::TcpListener;
876
877    fn agent_card_with_streaming(streaming: bool) -> AgentCard {
878        serde_json::from_value(serde_json::json!({
879            "name": "Validation Agent",
880            "description": "Builder validation tests",
881            "version": "0.0.0",
882            "protocolVersion": "0.2.6",
883            "url": "http://localhost/a2a",
884            "preferredTransport": "JSONRPC",
885            "capabilities": {
886                "streaming": streaming,
887                "pushNotifications": false,
888                "stateTransitionHistory": false
889            },
890            "defaultInputModes": ["text/plain"],
891            "defaultOutputModes": ["text/plain"],
892            "skills": [
893                {"id": "x", "name": "x", "description": "x", "tags": ["x"]}
894            ]
895        }))
896        .unwrap()
897    }
898
899    /// Drive the `DefaultStreamingTaskHandler` against a mock OpenAI-compatible
900    /// gateway and verify the handler iterates the delta stream, emitting an
901    /// incremental artifact event per non-empty content chunk (all sharing a
902    /// single artifact_id with `append: true`), terminating with `last_chunk:
903    /// true` and a `Completed` status whose message carries the accumulated
904    /// reply.
905    #[tokio::test]
906    async fn default_streaming_handler_iterates_gateway_deltas() {
907        use crate::A2AClient;
908        use crate::a2a_types::Message as A2AMessage;
909        use crate::config::AgentConfig;
910        use axum::response::sse::{Event as SseEvent, KeepAlive as SseKeepAlive, Sse as SseResp};
911        use futures_util::StreamExt as _;
912
913        // ----- Mock OpenAI-compatible gateway --------------------------------
914        async fn chat_completions() -> SseResp<
915            impl futures_util::Stream<Item = std::result::Result<SseEvent, std::convert::Infallible>>,
916        > {
917            let deltas = [
918                serde_json::json!({"choices":[{"delta":{"content":"Hel"}}]}).to_string(),
919                serde_json::json!({"choices":[{"delta":{"content":"lo "}}]}).to_string(),
920                serde_json::json!({"choices":[{"delta":{"content":"world"}}]}).to_string(),
921                "[DONE]".to_string(),
922            ];
923            let stream = futures_util::stream::iter(
924                deltas
925                    .into_iter()
926                    .map(|d| Ok::<_, std::convert::Infallible>(SseEvent::default().data(d))),
927            );
928            SseResp::new(stream).keep_alive(SseKeepAlive::default())
929        }
930
931        let gateway_listener = TcpListener::bind("127.0.0.1:0")
932            .await
933            .expect("bind gateway");
934        let gateway_addr = gateway_listener.local_addr().expect("addr");
935        let gateway_app = Router::new().route("/chat/completions", post(chat_completions));
936        tokio::spawn(async move {
937            axum::serve(gateway_listener, gateway_app).await.ok();
938        });
939
940        // ----- A2A server using DefaultStreamingTaskHandler ------------------
941        let agent_card = agent_card_with_streaming(true);
942        let agent_config = AgentConfig {
943            provider: "openai".to_string(),
944            model: "test-model".to_string(),
945            base_url: Some(format!("http://{gateway_addr}")),
946            ..AgentConfig::default()
947        };
948        let agent = AgentBuilder::new()
949            .with_config(&agent_config)
950            .build()
951            .await
952            .expect("agent builds");
953
954        let server = A2AServerBuilder::new()
955            .with_agent_card(agent_card)
956            .with_agent(agent)
957            .with_default_task_handlers()
958            .build()
959            .await
960            .expect("server builds");
961
962        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind a2a");
963        let addr = listener.local_addr().expect("addr");
964        let app = Router::new()
965            .route("/a2a", post(a2a_handler))
966            .with_state(Arc::new(AppState::new(server)));
967        tokio::spawn(async move {
968            axum::serve(listener, app).await.ok();
969        });
970
971        let client = A2AClient::new(format!("http://{addr}")).expect("client");
972
973        let request = SendMessageRequest {
974            configuration: None,
975            message: Some(A2AMessage {
976                context_id: None,
977                extensions: vec![],
978                message_id: "msg-default-stream".to_string(),
979                metadata: None,
980                parts: vec![Part {
981                    data: None,
982                    file: None,
983                    metadata: None,
984                    text: Some("hi".to_string()),
985                }],
986                reference_task_ids: vec![],
987                role: Role::RoleUser,
988                task_id: None,
989            }),
990            metadata: None,
991            tenant: "tests".to_string(),
992        };
993
994        let mut stream = Box::pin(client.stream_message(request).await.expect("stream"));
995        let mut events: Vec<StreamResponse> = Vec::new();
996        while let Some(item) = stream.next().await {
997            events.push(item.expect("event"));
998        }
999
1000        assert_eq!(
1001            events.len(),
1002            7,
1003            "unexpected event count {}: {:?}",
1004            events.len(),
1005            events
1006        );
1007
1008        assert!(events[0].task.is_some(), "first event carries task");
1009        let working = events[1]
1010            .status_update
1011            .as_ref()
1012            .expect("second event is status update");
1013        assert_eq!(working.status.state, TaskState::TaskStateWorking);
1014        assert!(!working.final_);
1015
1016        let mut artifact_ids = std::collections::HashSet::new();
1017        let chunks: Vec<String> = (2..=4)
1018            .map(|i| {
1019                let upd = events[i]
1020                    .artifact_update
1021                    .as_ref()
1022                    .unwrap_or_else(|| panic!("event[{i}] should be an artifact update"));
1023                assert_eq!(upd.append, Some(true), "deltas must have append=true");
1024                assert_eq!(upd.last_chunk, Some(false));
1025                artifact_ids.insert(upd.artifact.artifact_id.clone());
1026                upd.artifact
1027                    .parts
1028                    .iter()
1029                    .filter_map(|p| p.text.clone())
1030                    .collect::<String>()
1031            })
1032            .collect();
1033        assert_eq!(chunks, vec!["Hel", "lo ", "world"]);
1034        assert_eq!(
1035            artifact_ids.len(),
1036            1,
1037            "all deltas must share a single artifact_id"
1038        );
1039
1040        let terminal_artifact = events[5]
1041            .artifact_update
1042            .as_ref()
1043            .expect("event[5] should be the terminal artifact chunk");
1044        assert_eq!(terminal_artifact.last_chunk, Some(true));
1045        assert!(
1046            terminal_artifact.artifact.parts.is_empty(),
1047            "terminal chunk should have empty parts"
1048        );
1049        assert_eq!(
1050            artifact_ids.iter().next().unwrap(),
1051            &terminal_artifact.artifact.artifact_id,
1052            "terminal chunk must share artifact_id with deltas"
1053        );
1054
1055        let completed = events[6]
1056            .status_update
1057            .as_ref()
1058            .expect("event[6] should be the Completed status");
1059        assert_eq!(completed.status.state, TaskState::TaskStateCompleted);
1060        assert!(completed.final_);
1061        let assembled = completed
1062            .status
1063            .message
1064            .as_ref()
1065            .expect("completed status carries the final message")
1066            .parts
1067            .iter()
1068            .filter_map(|p| p.text.clone())
1069            .collect::<String>();
1070        assert_eq!(assembled, "Hello world");
1071    }
1072
1073    // ----- tool-dispatch coverage -------------------------------------------
1074
1075    #[derive(Clone, Default)]
1076    struct ToolMockState {
1077        non_streaming_calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1078        captured_tool_results: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1079    }
1080
1081    fn tool_call_response_json() -> serde_json::Value {
1082        serde_json::json!({
1083            "id": "chatcmpl-tool",
1084            "object": "chat.completion",
1085            "created": 0,
1086            "model": "test-model",
1087            "choices": [{
1088                "index": 0,
1089                "finish_reason": "tool_calls",
1090                "message": {
1091                    "role": "assistant",
1092                    "content": "",
1093                    "tool_calls": [{
1094                        "id": "call_1",
1095                        "type": "function",
1096                        "function": {
1097                            "name": "echo_arg",
1098                            "arguments": "{\"text\":\"hi\"}",
1099                        }
1100                    }],
1101                },
1102            }],
1103        })
1104    }
1105
1106    fn final_answer_response_json(text: &str) -> serde_json::Value {
1107        serde_json::json!({
1108            "id": "chatcmpl-final",
1109            "object": "chat.completion",
1110            "created": 0,
1111            "model": "test-model",
1112            "choices": [{
1113                "index": 0,
1114                "finish_reason": "stop",
1115                "message": {
1116                    "role": "assistant",
1117                    "content": text,
1118                    "tool_calls": [],
1119                },
1120            }],
1121        })
1122    }
1123
1124    async fn mock_non_streaming(
1125        State(state): State<std::sync::Arc<ToolMockState>>,
1126        body: Value,
1127    ) -> Json<Value> {
1128        if let Some(msgs) = body.get("messages").and_then(|v| v.as_array()) {
1129            for m in msgs {
1130                if m.get("role").and_then(|v| v.as_str()) == Some("tool")
1131                    && let Some(text) = m.get("content").and_then(|v| v.as_str())
1132                {
1133                    state
1134                        .captured_tool_results
1135                        .lock()
1136                        .expect("mutex poisoned")
1137                        .push(text.to_string());
1138                }
1139            }
1140        }
1141        let call_index = state
1142            .non_streaming_calls
1143            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1144        if call_index == 0 {
1145            Json(tool_call_response_json())
1146        } else {
1147            Json(final_answer_response_json("12 is the tool result"))
1148        }
1149    }
1150
1151    /// Single dispatcher: the tool loop is non-streaming end-to-end (since
1152    /// the inference gateway's OpenAI surface only exposes
1153    /// `CreateChatCompletion`), so this mock only needs to serve the two
1154    /// non-streaming responses in order.
1155    async fn mock_chat_completions(
1156        State(state): State<std::sync::Arc<ToolMockState>>,
1157        body: axum::body::Bytes,
1158    ) -> Json<Value> {
1159        let parsed: Value = serde_json::from_slice(&body).expect("valid JSON");
1160        mock_non_streaming(State(state), parsed).await
1161    }
1162
1163    async fn build_echo_agent_with_recorder(
1164        gateway_url: String,
1165    ) -> (Agent, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
1166        use crate::config::AgentConfig;
1167
1168        let recorded = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1169        let recorded_clone = std::sync::Arc::clone(&recorded);
1170
1171        let echo_tool = ChatCompletionTool {
1172            type_: ChatCompletionToolType::Function,
1173            function: FunctionObject {
1174                name: "echo_arg".to_string(),
1175                description: Some("echo back the text arg".to_string()),
1176                parameters: Some(FunctionParameters(
1177                    serde_json::json!({
1178                        "type": "object",
1179                        "properties": {"text": {"type": "string"}},
1180                        "required": ["text"],
1181                    })
1182                    .as_object()
1183                    .unwrap()
1184                    .clone(),
1185                )),
1186                strict: false,
1187            },
1188        };
1189
1190        let agent_cfg = AgentConfig {
1191            provider: "openai".to_string(),
1192            model: "test-model".to_string(),
1193            base_url: Some(gateway_url),
1194            ..AgentConfig::default()
1195        };
1196
1197        let agent = AgentBuilder::new()
1198            .with_config(&agent_cfg)
1199            .with_toolbox(vec![echo_tool])
1200            .with_async_function_tool("echo_arg".to_string(), move |args: Value| {
1201                let recorded = std::sync::Arc::clone(&recorded_clone);
1202                async move {
1203                    let text = args
1204                        .get("text")
1205                        .and_then(|v| v.as_str())
1206                        .unwrap_or("")
1207                        .to_string();
1208                    recorded.lock().expect("mutex poisoned").push(text.clone());
1209                    Ok(format!("echoed: {text}"))
1210                }
1211            })
1212            .build()
1213            .await
1214            .expect("agent builds");
1215        (agent, recorded)
1216    }
1217
1218    #[tokio::test]
1219    async fn default_background_handler_dispatches_tool_calls() {
1220        use crate::A2AClient;
1221        use crate::a2a_types::Message as A2AMessage;
1222
1223        let mock_state = std::sync::Arc::new(ToolMockState::default());
1224        let gateway_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
1225        let gateway_addr = gateway_listener.local_addr().expect("addr");
1226        let gateway_app = Router::new()
1227            .route("/chat/completions", post(mock_chat_completions))
1228            .with_state(std::sync::Arc::clone(&mock_state));
1229        tokio::spawn(async move {
1230            axum::serve(gateway_listener, gateway_app).await.ok();
1231        });
1232
1233        let (agent, recorded) =
1234            build_echo_agent_with_recorder(format!("http://{gateway_addr}")).await;
1235        let card = agent_card_with_streaming(false);
1236
1237        let mut server = A2AServerBuilder::new()
1238            .with_agent_card(card)
1239            .with_agent(agent)
1240            .with_default_background_task_handler()
1241            .build()
1242            .await
1243            .expect("server builds");
1244
1245        let runner = server
1246            .task_manager
1247            .take()
1248            .expect("task manager configured for background handler")
1249            .start();
1250
1251        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind a2a");
1252        let addr = listener.local_addr().expect("a2a addr");
1253        let app = Router::new()
1254            .route("/a2a", post(a2a_handler))
1255            .with_state(Arc::new(AppState::new(server)));
1256        tokio::spawn(async move {
1257            axum::serve(listener, app).await.ok();
1258        });
1259
1260        let client = A2AClient::new(format!("http://{addr}")).expect("client");
1261        let response = client
1262            .send_message(SendMessageRequest {
1263                configuration: None,
1264                message: Some(A2AMessage {
1265                    context_id: None,
1266                    extensions: vec![],
1267                    message_id: "msg-bg-tool".to_string(),
1268                    metadata: None,
1269                    parts: vec![Part {
1270                        data: None,
1271                        file: None,
1272                        metadata: None,
1273                        text: Some("ask".to_string()),
1274                    }],
1275                    reference_task_ids: vec![],
1276                    role: Role::RoleUser,
1277                    task_id: None,
1278                }),
1279                metadata: None,
1280                tenant: "tests".to_string(),
1281            })
1282            .await
1283            .expect("message/send");
1284
1285        let submitted = response.task.expect("task in response");
1286        assert_eq!(submitted.status.state, TaskState::TaskStateSubmitted);
1287
1288        let final_task = poll_until_terminal(&client, &submitted.id).await;
1289        assert_eq!(final_task.status.state, TaskState::TaskStateCompleted);
1290        let final_text = final_task
1291            .status
1292            .message
1293            .expect("final agent message")
1294            .parts
1295            .iter()
1296            .filter_map(|p| p.text.clone())
1297            .collect::<String>();
1298        assert_eq!(final_text, "12 is the tool result");
1299
1300        assert_eq!(
1301            recorded.lock().expect("mutex poisoned").clone(),
1302            vec!["hi".to_string()],
1303            "echo_arg should fire exactly once with the model-supplied argument",
1304        );
1305        assert_eq!(
1306            mock_state
1307                .captured_tool_results
1308                .lock()
1309                .expect("mutex poisoned")
1310                .clone(),
1311            vec!["echoed: hi".to_string()],
1312            "second gateway call should include the tool result as a Tool-role message",
1313        );
1314
1315        runner.shutdown().await;
1316    }
1317
1318    /// Poll `tasks/get` until the task reaches a terminal state, with a
1319    /// per-test timeout. Used by the queue-driven `message/send` tests
1320    /// that need to wait for the background worker to complete.
1321    async fn poll_until_terminal(client: &crate::A2AClient, task_id: &str) -> Task {
1322        for _ in 0..100 {
1323            let fetched = client
1324                .get_task(crate::a2a_types::GetTaskRequest {
1325                    history_length: None,
1326                    name: format!("tasks/{task_id}"),
1327                    tenant: Some("tests".to_string()),
1328                })
1329                .await
1330                .expect("tasks/get");
1331            if matches!(
1332                fetched.status.state,
1333                TaskState::TaskStateCompleted
1334                    | TaskState::TaskStateFailed
1335                    | TaskState::TaskStateCancelled
1336                    | TaskState::TaskStateRejected
1337            ) {
1338                return fetched;
1339            }
1340            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1341        }
1342        panic!("task {task_id} never reached terminal state within 2s");
1343    }
1344
1345    #[tokio::test]
1346    async fn default_streaming_handler_dispatches_tool_calls() {
1347        use crate::A2AClient;
1348        use crate::a2a_types::Message as A2AMessage;
1349        use futures_util::StreamExt;
1350
1351        let mock_state = std::sync::Arc::new(ToolMockState::default());
1352        let gateway_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
1353        let gateway_addr = gateway_listener.local_addr().expect("addr");
1354        let gateway_app = Router::new()
1355            .route("/chat/completions", post(mock_chat_completions))
1356            .with_state(std::sync::Arc::clone(&mock_state));
1357        tokio::spawn(async move {
1358            axum::serve(gateway_listener, gateway_app).await.ok();
1359        });
1360
1361        let (agent, recorded) =
1362            build_echo_agent_with_recorder(format!("http://{gateway_addr}")).await;
1363        let card = agent_card_with_streaming(true);
1364
1365        let server = A2AServerBuilder::new()
1366            .with_agent_card(card)
1367            .with_agent(agent)
1368            .with_default_streaming_task_handler()
1369            .build()
1370            .await
1371            .expect("server builds");
1372
1373        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind a2a");
1374        let addr = listener.local_addr().expect("a2a addr");
1375        let app = Router::new()
1376            .route("/a2a", post(a2a_handler))
1377            .with_state(Arc::new(AppState::new(server)));
1378        tokio::spawn(async move {
1379            axum::serve(listener, app).await.ok();
1380        });
1381
1382        let client = A2AClient::new(format!("http://{addr}")).expect("client");
1383        let request = SendMessageRequest {
1384            configuration: None,
1385            message: Some(A2AMessage {
1386                context_id: None,
1387                extensions: vec![],
1388                message_id: "msg-stream-tool".to_string(),
1389                metadata: None,
1390                parts: vec![Part {
1391                    data: None,
1392                    file: None,
1393                    metadata: None,
1394                    text: Some("ask".to_string()),
1395                }],
1396                reference_task_ids: vec![],
1397                role: Role::RoleUser,
1398                task_id: None,
1399            }),
1400            metadata: None,
1401            tenant: "tests".to_string(),
1402        };
1403
1404        let mut stream = Box::pin(client.stream_message(request).await.expect("stream"));
1405        let mut events: Vec<StreamResponse> = Vec::new();
1406        while let Some(item) = stream.next().await {
1407            events.push(item.expect("event"));
1408        }
1409
1410        assert_eq!(
1411            recorded.lock().expect("mutex poisoned").clone(),
1412            vec!["hi".to_string()],
1413            "echo_arg should fire once during the tool-loop preflight"
1414        );
1415
1416        let saw_tool_status = events.iter().any(|e| {
1417            e.status_update
1418                .as_ref()
1419                .and_then(|u| u.status.message.as_ref())
1420                .map(|m| {
1421                    m.parts
1422                        .iter()
1423                        .filter_map(|p| p.text.clone())
1424                        .any(|t| t.contains("calling tool"))
1425                })
1426                .unwrap_or(false)
1427        });
1428        assert!(
1429            !saw_tool_status,
1430            "stream should NOT carry tool-lifecycle status updates",
1431        );
1432
1433        let accumulated: String = events
1434            .iter()
1435            .filter_map(|e| e.artifact_update.as_ref())
1436            .flat_map(|a| {
1437                a.artifact
1438                    .parts
1439                    .iter()
1440                    .filter_map(|p| p.text.clone())
1441                    .collect::<Vec<_>>()
1442            })
1443            .collect::<String>();
1444        assert_eq!(accumulated, "12 is the tool result");
1445
1446        let last = events.last().expect("at least one event");
1447        let last_status = last
1448            .status_update
1449            .as_ref()
1450            .expect("last event is a status update");
1451        assert_eq!(last_status.status.state, TaskState::TaskStateCompleted);
1452        assert!(last_status.final_);
1453    }
1454
1455    // ----- usage-metadata coverage ------------------------------------------
1456
1457    fn submitted_usage_task(text: &str) -> Task {
1458        Task {
1459            artifacts: vec![],
1460            context_id: "ctx-usage".to_string(),
1461            history: vec![A2AMessage {
1462                context_id: Some("ctx-usage".to_string()),
1463                extensions: vec![],
1464                message_id: "u-usage".to_string(),
1465                metadata: None,
1466                parts: vec![Part {
1467                    data: None,
1468                    file: None,
1469                    metadata: None,
1470                    text: Some(text.to_string()),
1471                }],
1472                reference_task_ids: vec![],
1473                role: Role::RoleUser,
1474                task_id: Some("task-usage".to_string()),
1475            }],
1476            id: "task-usage".to_string(),
1477            metadata: None,
1478            status: TaskStatus {
1479                message: None,
1480                state: TaskState::TaskStateSubmitted,
1481                timestamp: None,
1482            },
1483        }
1484    }
1485
1486    async fn build_toolless_agent(gateway_url: String) -> Agent {
1487        use crate::config::AgentConfig;
1488        let cfg = AgentConfig {
1489            provider: "openai".to_string(),
1490            model: "test-model".to_string(),
1491            base_url: Some(gateway_url),
1492            ..AgentConfig::default()
1493        };
1494        AgentBuilder::new()
1495            .with_config(&cfg)
1496            .build()
1497            .await
1498            .expect("agent builds")
1499    }
1500
1501    /// Mock non-streaming `/chat/completions` returning a final answer that
1502    /// carries a `usage` block, so the tool loop records token counts.
1503    async fn mock_final_with_usage() -> Json<Value> {
1504        Json(serde_json::json!({
1505            "id": "chatcmpl-usage",
1506            "object": "chat.completion",
1507            "created": 0,
1508            "model": "test-model",
1509            "choices": [{
1510                "index": 0,
1511                "finish_reason": "stop",
1512                "message": {"role": "assistant", "content": "All done", "tool_calls": []},
1513            }],
1514            "usage": {"prompt_tokens": 11, "completion_tokens": 4, "total_tokens": 15},
1515        }))
1516    }
1517
1518    /// Mock streaming `/chat/completions` that emits two content deltas, then a
1519    /// trailing usage-only chunk (empty `choices`), then `[DONE]`.
1520    async fn mock_stream_with_usage() -> axum::response::sse::Sse<
1521        impl futures_util::Stream<
1522            Item = std::result::Result<axum::response::sse::Event, std::convert::Infallible>,
1523        >,
1524    > {
1525        use axum::response::sse::{Event as SseEvent, KeepAlive as SseKeepAlive, Sse as SseResp};
1526        let chunks = [
1527            serde_json::json!({"choices":[{"delta":{"content":"Hi "}}]}).to_string(),
1528            serde_json::json!({"choices":[{"delta":{"content":"there"}}]}).to_string(),
1529            serde_json::json!({
1530                "choices": [],
1531                "usage": {"prompt_tokens": 7, "completion_tokens": 2, "total_tokens": 9},
1532            })
1533            .to_string(),
1534            "[DONE]".to_string(),
1535        ];
1536        let stream = futures_util::stream::iter(
1537            chunks
1538                .into_iter()
1539                .map(|d| Ok::<_, std::convert::Infallible>(SseEvent::default().data(d))),
1540        );
1541        SseResp::new(stream).keep_alive(SseKeepAlive::default())
1542    }
1543
1544    #[tokio::test]
1545    async fn default_background_handler_attaches_usage_metadata() {
1546        let gateway_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
1547        let gateway_addr = gateway_listener.local_addr().expect("addr");
1548        let gateway_app = Router::new().route("/chat/completions", post(mock_final_with_usage));
1549        tokio::spawn(async move {
1550            axum::serve(gateway_listener, gateway_app).await.ok();
1551        });
1552
1553        let agent = build_toolless_agent(format!("http://{gateway_addr}")).await;
1554        let handler = DefaultBackgroundTaskHandler::new(Some(Arc::new(agent)));
1555        assert!(
1556            handler.is_usage_metadata_enabled(),
1557            "usage metadata defaults on"
1558        );
1559
1560        let task = handler
1561            .handle_task(submitted_usage_task("hi"), None)
1562            .await
1563            .expect("handle_task");
1564        assert_eq!(task.status.state, TaskState::TaskStateCompleted);
1565
1566        let meta = task
1567            .metadata
1568            .expect("usage metadata attached on completion");
1569        assert_eq!(meta.0["usage"]["prompt_tokens"], 11);
1570        assert_eq!(meta.0["usage"]["completion_tokens"], 4);
1571        assert_eq!(meta.0["usage"]["total_tokens"], 15);
1572        let stats = &meta.0["execution_stats"];
1573        assert_eq!(stats["iterations"], 1);
1574        assert_eq!(stats["messages"], 0);
1575        assert_eq!(stats["tool_calls"], 0);
1576        assert_eq!(stats["failed_tools"], 0);
1577    }
1578
1579    #[tokio::test]
1580    async fn default_background_handler_omits_usage_metadata_when_disabled() {
1581        let gateway_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
1582        let gateway_addr = gateway_listener.local_addr().expect("addr");
1583        let gateway_app = Router::new().route("/chat/completions", post(mock_final_with_usage));
1584        tokio::spawn(async move {
1585            axum::serve(gateway_listener, gateway_app).await.ok();
1586        });
1587
1588        let agent = build_toolless_agent(format!("http://{gateway_addr}")).await;
1589        let mut handler = DefaultBackgroundTaskHandler::new(Some(Arc::new(agent)));
1590        handler.set_enable_usage_metadata(false);
1591        assert!(!handler.is_usage_metadata_enabled());
1592
1593        let task = handler
1594            .handle_task(submitted_usage_task("hi"), None)
1595            .await
1596            .expect("handle_task");
1597        assert_eq!(task.status.state, TaskState::TaskStateCompleted);
1598        assert!(
1599            task.metadata.is_none(),
1600            "metadata must be absent when usage metadata is disabled"
1601        );
1602    }
1603
1604    /// Drive the streaming handler directly against a mock that returns a usage
1605    /// chunk, then return the task as persisted in storage (where the handler
1606    /// writes the merged metadata before the terminal status update).
1607    async fn run_streaming_usage_case(enable: bool) -> Task {
1608        use crate::{InMemoryStorage, Storage};
1609
1610        let gateway_listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
1611        let gateway_addr = gateway_listener.local_addr().expect("addr");
1612        let gateway_app = Router::new().route("/chat/completions", post(mock_stream_with_usage));
1613        tokio::spawn(async move {
1614            axum::serve(gateway_listener, gateway_app).await.ok();
1615        });
1616
1617        let agent = build_toolless_agent(format!("http://{gateway_addr}")).await;
1618        let storage: Arc<dyn Storage> = Arc::new(InMemoryStorage::new());
1619        let task = submitted_usage_task("hi");
1620        storage.put_task(task.clone()).await;
1621
1622        let (tx, mut rx) = tokio::sync::mpsc::channel::<StreamResponse>(64);
1623        let drain = tokio::spawn(async move { while rx.recv().await.is_some() {} });
1624        let emitter = StreamEmitter::new(tx, Arc::clone(&storage));
1625
1626        let mut handler = DefaultStreamingTaskHandler::new(Some(Arc::new(agent)));
1627        handler.set_enable_usage_metadata(enable);
1628        handler
1629            .handle_streaming_task(task.clone(), None, emitter)
1630            .await
1631            .expect("handle_streaming_task");
1632        drain.await.ok();
1633
1634        storage.get_task(&task.id).await.expect("task persisted")
1635    }
1636
1637    #[tokio::test]
1638    async fn default_streaming_handler_attaches_usage_metadata() {
1639        let stored = run_streaming_usage_case(true).await;
1640        let meta = stored
1641            .metadata
1642            .expect("usage metadata attached to the stored task");
1643        assert_eq!(meta.0["usage"]["prompt_tokens"], 7);
1644        assert_eq!(meta.0["usage"]["completion_tokens"], 2);
1645        assert_eq!(meta.0["usage"]["total_tokens"], 9);
1646        // The streaming tail counts as one iteration even without a tool loop.
1647        assert_eq!(meta.0["execution_stats"]["iterations"], 1);
1648    }
1649
1650    #[tokio::test]
1651    async fn default_streaming_handler_omits_usage_metadata_when_disabled() {
1652        let stored = run_streaming_usage_case(false).await;
1653        assert!(
1654            stored.metadata.is_none(),
1655            "metadata must be absent when usage metadata is disabled"
1656        );
1657    }
1658}