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#[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#[async_trait::async_trait]
37pub trait StreamableTaskHandler: Send + Sync + std::fmt::Debug {
38 async fn handle_streaming_task(
44 &self,
45 task: Task,
46 message: Option<A2AMessage>,
47 emitter: StreamEmitter,
48 ) -> Result<()>;
49}
50
51#[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 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 pub fn artifact_service(&self) -> Option<Arc<dyn ArtifactService>> {
90 self.artifact_service.clone()
91 }
92
93 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 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 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 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 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 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
297fn 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
340fn 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
382const 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
388struct ToolLoopOutcome {
394 messages: Vec<Message>,
395 final_text: String,
396 exhausted: bool,
397}
398
399async 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#[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 pub fn set_enable_usage_metadata(&mut self, enable: bool) {
532 self.enable_usage_metadata = enable;
533 }
534
535 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#[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 pub fn set_enable_usage_metadata(&mut self, enable: bool) {
633 self.enable_usage_metadata = enable;
634 }
635
636 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
688async 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 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 #[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 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 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 #[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 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 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 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 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 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 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 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}