1use crate::agents::{DeliberationPhase, PendingToolCall, ToolCallStatus, UserToolHandlerTrait};
2use crate::nats_utils::ensure_kv_bucket;
3use anyhow::Result;
4use async_trait::async_trait;
5use futures_util::StreamExt;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7use tracing::{info, warn};
8use uuid::Uuid;
9
10fn escape_xml(s: &str) -> String {
12 s.replace('&', "&")
13 .replace('<', "<")
14 .replace('>', ">")
15}
16
17fn escape_xml_attr(s: &str) -> String {
19 s.replace('&', "&")
20 .replace('<', "<")
21 .replace('>', ">")
22 .replace('"', """)
23 .replace('\'', "'")
24}
25
26fn compute_finalization_reserve(
31 phase_budget: Duration,
32 reserve_secs: f64,
33 reserve_ratio: f64,
34) -> Duration {
35 let safe_secs = if reserve_secs.is_finite() && reserve_secs > 0.0 {
36 reserve_secs
37 } else {
38 0.0
39 };
40 let safe_ratio = if reserve_ratio.is_finite() && reserve_ratio > 0.0 {
41 reserve_ratio.min(1.0)
42 } else {
43 0.0
44 };
45 let ratio_based = Duration::from_secs_f64(phase_budget.as_secs_f64() * safe_ratio);
46 let fixed = Duration::from_secs_f64(safe_secs);
47 ratio_based.min(fixed)
48}
49
50pub fn toolcalls_bucket_name(subject_prefix: &str, session_id: &str) -> String {
61 format!(
62 "{}_toolcalls_{}",
63 crate::nats_utils::sanitize_subject_component(subject_prefix),
64 crate::nats_utils::sanitize_subject_component(session_id)
65 )
66}
67
68#[derive(Clone, Debug)]
71pub struct UserToolHandler {
72 nats_client: async_nats::Client,
73 js_context: async_nats::jetstream::Context,
74 session_id: String,
75 agent_id: String,
76 subject_prefix: String,
78 phase_start: Instant,
80 phase_budget: Duration,
82 max_pending_per_agent: usize,
84 finalization_reserve_secs: f64,
85 finalization_reserve_ratio: f64,
86}
87
88enum WaitResult {
89 Responded(String),
90 Timeout,
91 Error(String),
92}
93
94impl UserToolHandler {
95 pub fn new(
96 nats_client: async_nats::Client,
97 js_context: async_nats::jetstream::Context,
98 session_id: String,
99 agent_id: String,
100 phase_budget_remaining_secs: f64,
101 ) -> Self {
102 let safe_budget = if phase_budget_remaining_secs.is_finite() {
104 phase_budget_remaining_secs.max(0.0)
105 } else {
106 0.0
107 };
108 Self {
109 nats_client,
110 js_context,
111 session_id,
112 agent_id,
113 subject_prefix: "nsed".to_string(),
114 phase_start: Instant::now(),
115 phase_budget: Duration::from_secs_f64(safe_budget),
116 max_pending_per_agent: 3,
117 finalization_reserve_secs: 30.0,
118 finalization_reserve_ratio: 0.15,
119 }
120 }
121
122 pub fn with_subject_prefix(mut self, prefix: String) -> Self {
124 self.subject_prefix = prefix;
125 self
126 }
127
128 pub fn with_max_pending_per_agent(mut self, max: usize) -> Self {
130 self.max_pending_per_agent = max;
131 self
132 }
133
134 pub fn with_finalization_reserve(mut self, secs: f64, ratio: f64) -> Self {
136 self.finalization_reserve_secs = secs;
137 self.finalization_reserve_ratio = ratio;
138 self
139 }
140
141 fn remaining_budget(&self) -> Duration {
144 self.phase_budget.saturating_sub(self.phase_start.elapsed())
145 }
146
147 fn finalization_reserve(&self) -> Duration {
150 compute_finalization_reserve(
151 self.phase_budget,
152 self.finalization_reserve_secs,
153 self.finalization_reserve_ratio,
154 )
155 }
156
157 fn now_epoch_millis() -> u64 {
158 SystemTime::now()
159 .duration_since(UNIX_EPOCH)
160 .unwrap_or_default()
161 .as_millis() as u64
162 }
163
164 fn bucket_name(&self) -> String {
165 toolcalls_bucket_name(&self.subject_prefix, &self.session_id)
166 }
167
168 pub async fn handle_call(
170 &self,
171 tool_name: &str,
172 arguments_json: &str,
173 round: u32,
174 phase: DeliberationPhase,
175 ) -> String {
176 let remaining = self.remaining_budget();
189 let reserve = self.finalization_reserve();
190 if remaining <= reserve {
191 info!(
192 agent = %self.agent_id,
193 tool = %tool_name,
194 remaining_secs = remaining.as_secs_f64(),
195 reserve_secs = reserve.as_secs_f64(),
196 "Not asking: no budget beyond the finalization reserve."
197 );
198 return "[No response — phase budget exhausted. Proceed immediately.]".to_string();
199 }
200
201 let arguments: serde_json::Value = match serde_json::from_str(arguments_json) {
203 Ok(v) => v,
204 Err(e) => {
205 return format!("Error: Invalid JSON arguments: {}", e);
206 }
207 };
208
209 let bucket_name = self.bucket_name();
211 let toolcall_store = match self.get_or_create_bucket(&bucket_name).await {
212 Ok(store) => store,
213 Err(e) => {
214 warn!("Failed to access toolcall bucket: {}", e);
215 return format!("Error: Failed to register tool call: {}", e);
216 }
217 };
218
219 match self.count_pending_for_agent(&toolcall_store).await {
221 Ok(count) if count >= self.max_pending_per_agent => {
222 return format!(
223 "Error: Maximum pending tool calls ({}) reached for this agent. \
224 Wait for existing calls to be answered before making new ones.",
225 self.max_pending_per_agent
226 );
227 }
228 Err(e) => {
229 warn!("Failed to check pending count: {}", e);
230 }
232 _ => {}
233 }
234
235 let call_id = Uuid::new_v4().to_string();
237 let pending_call = PendingToolCall {
238 call_id: call_id.clone(),
239 job_id: self.session_id.clone(),
240 agent_id: self.agent_id.clone(),
241 tool_name: tool_name.to_string(),
242 arguments: arguments.clone(),
243 round,
244 phase,
245 status: ToolCallStatus::Pending,
246 created_at: Self::now_epoch_millis(),
247 responded_at: None,
248 result: None,
249 };
250
251 let key = format!("call_{}", call_id);
253 let data = match serde_json::to_vec(&pending_call) {
254 Ok(d) => d,
255 Err(e) => return format!("Error: Failed to serialize tool call: {}", e),
256 };
257 if let Err(e) = toolcall_store.put(&key, data.into()).await {
258 return format!("Error: Failed to store pending tool call: {}", e);
259 }
260
261 self.publish_sse_event(
263 "tool_call_pending",
264 &serde_json::json!({
265 "call_id": &call_id,
266 "agent_id": &self.agent_id,
267 "tool_name": tool_name,
268 "arguments": &arguments,
269 "round": round,
270 "phase": &phase,
271 }),
272 )
273 .await;
274
275 info!(
276 agent = %self.agent_id,
277 tool = %tool_name,
278 call_id = %call_id,
279 "User tool call published. Waiting for response..."
280 );
281
282 let deadline = remaining.saturating_sub(reserve);
285
286 let result = self
288 .wait_for_response(&toolcall_store, &key, deadline)
289 .await;
290
291 match result {
292 WaitResult::Responded(response_text) => {
293 self.publish_sse_event(
294 "tool_call_responded",
295 &serde_json::json!({
296 "call_id": &call_id,
297 "agent_id": &self.agent_id,
298 "tool_name": tool_name,
299 }),
300 )
301 .await;
302 info!(
303 agent = %self.agent_id,
304 call_id = %call_id,
305 "User tool call responded."
306 );
307 let escaped = escape_xml(&response_text);
310 let safe_tool = escape_xml_attr(tool_name);
311 let safe_call = escape_xml_attr(&call_id);
312 format!(
313 "<user_tool_result tool=\"{}\" call_id=\"{}\">{}</user_tool_result>",
314 safe_tool, safe_call, escaped
315 )
316 }
317 WaitResult::Timeout => {
318 self.expire_call(&toolcall_store, &key, &call_id, tool_name)
319 .await;
320 let remaining_after = self.remaining_budget();
321 format!(
322 "[No response yet — you have {:.0}s remaining to finalize your proposal \
323 with your best judgment. The user may respond later and the result will \
324 be available next round.]",
325 remaining_after.as_secs_f64()
326 )
327 }
328 WaitResult::Error(e) => {
329 warn!(call_id = %call_id, error = %e, "Error waiting for tool call response");
330 format!("Error waiting for user response: {}", e)
331 }
332 }
333 }
334
335 async fn get_or_create_bucket(
336 &self,
337 bucket_name: &str,
338 ) -> Result<async_nats::jetstream::kv::Store> {
339 ensure_kv_bucket(
340 &self.js_context,
341 async_nats::jetstream::kv::Config {
342 bucket: bucket_name.to_string(),
343 history: 5,
344 max_age: Duration::from_secs(86400 * 3),
345 storage: async_nats::jetstream::stream::StorageType::File,
346 ..Default::default()
347 },
348 )
349 .await
350 }
351
352 async fn count_pending_for_agent(
353 &self,
354 store: &async_nats::jetstream::kv::Store,
355 ) -> Result<usize> {
356 let scan_start = Instant::now();
357 let mut count = 0;
358 let mut total_keys = 0u32;
359 let mut keys = store.keys().await?;
360 while let Some(key_result) = keys.next().await {
361 let Ok(key) = key_result else { continue };
362 if !key.starts_with("call_") {
363 continue;
364 }
365 total_keys += 1;
366 let Ok(Some(entry)) = store.get(&key).await else {
367 continue;
368 };
369 let Ok(call) = serde_json::from_slice::<PendingToolCall>(&entry) else {
370 continue;
371 };
372 if call.agent_id == self.agent_id && call.status == ToolCallStatus::Pending {
373 count += 1;
374 }
375 }
376 let scan_ms = scan_start.elapsed().as_millis();
377 if total_keys > 50 || scan_ms > 100 {
378 warn!(
379 total_keys = total_keys,
380 pending = count,
381 agent = %self.agent_id,
382 scan_ms = scan_ms,
383 "Tool call bucket scan is growing — consider secondary counter if this persists"
384 );
385 }
386 Ok(count)
387 }
388
389 async fn wait_for_response(
390 &self,
391 store: &async_nats::jetstream::kv::Store,
392 key: &str,
393 timeout_duration: Duration,
394 ) -> WaitResult {
395 let mut watcher = match store.watch_with_history(key).await {
399 Ok(w) => w,
400 Err(e) => return WaitResult::Error(format!("Failed to create KV watcher: {}", e)),
401 };
402
403 tokio::select! {
404 result = async {
405 while let Some(entry) = watcher.next().await {
406 let Ok(entry) = entry else { continue };
407 let Ok(call) = serde_json::from_slice::<PendingToolCall>(&entry.value) else {
408 continue;
409 };
410 if call.status == ToolCallStatus::Responded {
411 return WaitResult::Responded(call.result.unwrap_or_default());
412 }
413 }
414 WaitResult::Error("KV watcher stream ended unexpectedly".to_string())
415 } => result,
416 _ = tokio::time::sleep(timeout_duration) => {
417 WaitResult::Timeout
418 }
419 }
420 }
421
422 async fn expire_call(
423 &self,
424 store: &async_nats::jetstream::kv::Store,
425 key: &str,
426 call_id: &str,
427 tool_name: &str,
428 ) {
429 if let Ok(Some(entry)) = store.entry(key).await
431 && let Ok(mut call) = serde_json::from_slice::<PendingToolCall>(&entry.value)
432 {
433 if call.status != ToolCallStatus::Pending {
434 return;
436 }
437 call.status = ToolCallStatus::Expired;
438 match serde_json::to_vec(&call) {
439 Ok(data) => {
440 match store.update(key, data.into(), entry.revision).await {
442 Ok(_) => {
443 self.publish_sse_event(
445 "tool_call_expired",
446 &serde_json::json!({
447 "call_id": call_id,
448 "agent_id": &self.agent_id,
449 "tool_name": tool_name,
450 "timeout_secs": self.phase_budget.as_secs_f64(),
451 }),
452 )
453 .await;
454 }
455 Err(e) => {
456 warn!(
457 call_id = %call_id,
458 error = %e,
459 "CAS update failed for expire_call (concurrent modification?)"
460 );
461 }
462 }
463 }
464 Err(e) => {
465 warn!(
466 call_id = %call_id,
467 error = %e,
468 "Failed to serialize expired tool call"
469 );
470 }
471 }
472 }
473 }
474
475 async fn publish_sse_event<T: serde::Serialize>(&self, suffix: &str, payload: &T) {
476 let data = match serde_json::to_vec(payload) {
477 Ok(d) => d,
478 Err(e) => {
479 warn!("Failed to serialize SSE event: {}", e);
480 return;
481 }
482 };
483 let safe_session = crate::nats_utils::sanitize_subject_component(&self.session_id);
484 let safe_prefix = crate::nats_utils::sanitize_subject_component(&self.subject_prefix);
485 let subject = format!("{}.{}.result.event.{}", safe_prefix, safe_session, suffix);
486 if let Err(e) = self.nats_client.publish(subject.clone(), data.into()).await {
487 warn!("Failed to publish SSE event to {}: {}", subject, e);
488 }
489 }
490}
491
492#[async_trait]
494impl UserToolHandlerTrait for UserToolHandler {
495 async fn handle_call(
496 &self,
497 tool_name: &str,
498 arguments_json: &str,
499 round: u32,
500 phase: DeliberationPhase,
501 ) -> String {
502 self.handle_call(tool_name, arguments_json, round, phase)
503 .await
504 }
505}
506
507#[derive(Debug)]
513pub struct NatsUserToolHandlerFactory;
514
515impl crate::workers::UserToolHandlerFactory for NatsUserToolHandlerFactory {
516 fn create(
517 &self,
518 nats: async_nats::Client,
519 js: async_nats::jetstream::Context,
520 session_id: String,
521 agent_id: String,
522 budget_remaining_secs: f64,
523 subject_prefix: String,
524 ) -> std::sync::Arc<dyn UserToolHandlerTrait> {
525 std::sync::Arc::new(
526 UserToolHandler::new(nats, js, session_id, agent_id, budget_remaining_secs)
527 .with_subject_prefix(subject_prefix),
528 )
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535 use crate::agents::{PendingToolCall, ToolCallStatus, UserToolDefinition};
536
537 #[test]
538 fn test_user_tool_definition_serde_roundtrip() {
539 let def = UserToolDefinition {
540 name: "dm_user".to_string(),
541 description: "Send a DM to the user".to_string(),
542 parameters: Some(serde_json::json!({
543 "type": "object",
544 "properties": {
545 "message": { "type": "string" }
546 },
547 "required": ["message"]
548 })),
549 strict: Some(true),
550 };
551
552 let json = serde_json::to_string(&def).unwrap();
553 let parsed: UserToolDefinition = serde_json::from_str(&json).unwrap();
554 assert_eq!(parsed.name, "dm_user");
555 assert_eq!(parsed.strict, Some(true));
556 assert!(parsed.parameters.is_some());
557 }
558
559 #[test]
560 fn test_user_tool_definition_minimal() {
561 let json = r#"{"name": "ping", "description": "Ping the user"}"#;
562 let parsed: UserToolDefinition = serde_json::from_str(json).unwrap();
563 assert_eq!(parsed.name, "ping");
564 assert!(parsed.parameters.is_none());
565 assert!(parsed.strict.is_none());
566 }
567
568 #[test]
569 fn test_pending_tool_call_serde_roundtrip() {
570 let call = PendingToolCall {
571 call_id: "abc-123".to_string(),
572 job_id: "job-1".to_string(),
573 agent_id: "agent-1".to_string(),
574 tool_name: "user_dm_user".to_string(),
575 arguments: serde_json::json!({"message": "hello"}),
576 round: 1,
577 phase: DeliberationPhase::Proposing,
578 status: ToolCallStatus::Pending,
579 created_at: 1234567890,
580 responded_at: None,
581 result: None,
582 };
583
584 let json = serde_json::to_string(&call).unwrap();
585 let parsed: PendingToolCall = serde_json::from_str(&json).unwrap();
586 assert_eq!(parsed.call_id, "abc-123");
587 assert_eq!(parsed.status, ToolCallStatus::Pending);
588 assert!(parsed.responded_at.is_none());
589 assert!(parsed.result.is_none());
590 }
591
592 #[test]
593 fn test_pending_tool_call_responded() {
594 let call = PendingToolCall {
595 call_id: "abc-123".to_string(),
596 job_id: "job-1".to_string(),
597 agent_id: "agent-1".to_string(),
598 tool_name: "user_dm_user".to_string(),
599 arguments: serde_json::json!({}),
600 round: 2,
601 phase: DeliberationPhase::Evaluating,
602 status: ToolCallStatus::Responded,
603 created_at: 1234567890,
604 responded_at: Some(1234567900),
605 result: Some("The answer is 42".to_string()),
606 };
607
608 let json = serde_json::to_string(&call).unwrap();
609 let parsed: PendingToolCall = serde_json::from_str(&json).unwrap();
610 assert_eq!(parsed.status, ToolCallStatus::Responded);
611 assert_eq!(parsed.result, Some("The answer is 42".to_string()));
612 }
613
614 #[test]
615 fn test_tool_call_status_all_variants() {
616 for (status, expected) in [
617 (ToolCallStatus::Pending, "\"Pending\""),
618 (ToolCallStatus::Responded, "\"Responded\""),
619 (ToolCallStatus::Expired, "\"Expired\""),
620 ] {
621 let json = serde_json::to_string(&status).unwrap();
622 assert_eq!(json, expected);
623 let parsed: ToolCallStatus = serde_json::from_str(&json).unwrap();
624 assert_eq!(parsed, status);
625 }
626 }
627
628 async fn js() -> Option<(async_nats::Client, async_nats::jetstream::Context)> {
630 let url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string());
631 let client = async_nats::connect(&url).await.ok()?;
632 let js = async_nats::jetstream::new(client.clone());
633 Some((client, js))
634 }
635
636 #[tokio::test]
637 async fn a_call_with_no_time_to_wait_is_refused_before_anything_is_published() {
638 let Some((client, js)) = js().await else {
639 eprintln!("Skipping: NATS unavailable");
640 return;
641 };
642 let session = format!("test-refuse-{}", Uuid::new_v4());
643 let handler = UserToolHandler::new(
647 client,
648 js.clone(),
649 session.clone(),
650 "ALPHA".to_string(),
651 60.0,
652 )
653 .with_finalization_reserve(1000.0, 1.0);
654
655 let answer = handler
656 .handle_call(
657 "user_dm_user",
658 r#"{"message":"hi"}"#,
659 1,
660 DeliberationPhase::Proposing,
661 )
662 .await;
663 assert!(
664 answer.contains("phase budget exhausted"),
665 "the model is told to proceed: {answer}"
666 );
667
668 let bucket = format!("nsed_toolcalls_{session}");
671 assert!(
672 js.get_key_value(&bucket).await.is_err(),
673 "a question that cannot be waited for must not create {bucket}"
674 );
675 }
676
677 #[test]
678 fn the_bucket_name_follows_the_configured_prefix() {
679 assert_eq!(
680 toolcalls_bucket_name("nsed", "room-f2205792"),
681 "nsed_toolcalls_room-f2205792"
682 );
683 assert_eq!(
686 toolcalls_bucket_name("staging", "room-1"),
687 "staging_toolcalls_room-1"
688 );
689 assert_eq!(
692 toolcalls_bucket_name("nsed", "room.1 *>"),
693 toolcalls_bucket_name("nsed", "room.1 *>"),
694 );
695 let odd = toolcalls_bucket_name("ns.ed", "a>b");
696 assert!(!odd.contains('.'), "{odd}");
697 assert!(!odd.contains('>'), "{odd}");
698 }
699
700 #[test]
701 fn test_finalization_reserve_computation() {
702 assert_eq!(
704 compute_finalization_reserve(Duration::from_secs(200), 30.0, 0.15),
705 Duration::from_secs(30)
706 );
707 }
708
709 #[test]
710 fn test_finalization_reserve_small_budget() {
711 assert_eq!(
713 compute_finalization_reserve(Duration::from_secs(60), 30.0, 0.15),
714 Duration::from_secs(9)
715 );
716 }
717
718 #[test]
722 fn test_escape_xml_basic_entities() {
723 assert_eq!(escape_xml("hello"), "hello");
724 assert_eq!(escape_xml("<script>"), "<script>");
725 assert_eq!(escape_xml("a & b"), "a & b");
726 assert_eq!(escape_xml(""), "");
727 }
728
729 #[test]
731 fn test_escape_xml_preserves_wrapper_integrity() {
732 let malicious = "</user_tool_result><injected>evil</injected>";
733 let escaped = escape_xml(malicious);
734 let wrapped = format!(
735 "<user_tool_result tool=\"test\" call_id=\"c1\">{}</user_tool_result>",
736 escaped
737 );
738 assert!(wrapped.starts_with("<user_tool_result tool=\"test\" call_id=\"c1\">"));
740 assert!(wrapped.ends_with("</user_tool_result>"));
741 assert!(!wrapped.contains("<injected>"));
743 assert!(wrapped.contains("<injected>"));
744 }
745
746 #[test]
748 fn test_escape_xml_combined() {
749 let input = "x < 5 & y > 3";
750 let expected = "x < 5 & y > 3";
751 assert_eq!(escape_xml(input), expected);
752 }
753
754 #[test]
756 fn test_escape_xml_ampersand_first() {
757 let input = "<";
759 let expected = "&lt;";
760 assert_eq!(escape_xml(input), expected);
761 }
762
763 #[test]
767 fn test_escape_xml_attr_quotes() {
768 assert_eq!(escape_xml_attr(r#"he said "hi""#), "he said "hi"");
769 assert_eq!(escape_xml_attr("it's"), "it's");
770 }
771
772 #[test]
774 fn test_escape_xml_attr_prevents_attribute_injection() {
775 let malicious_tool = r#"evil" onclick="alert(1)"#;
777 let safe = escape_xml_attr(malicious_tool);
778 assert!(!safe.contains('"'));
780 assert!(safe.contains("""));
781 }
782
783 #[test]
787 fn test_finalization_reserve_nan_inputs() {
788 let result = compute_finalization_reserve(Duration::from_secs(100), f64::NAN, 0.15);
789 assert_eq!(result, Duration::ZERO);
790
791 let result = compute_finalization_reserve(Duration::from_secs(100), 30.0, f64::NAN);
792 assert_eq!(result, Duration::ZERO);
793 }
794
795 #[test]
797 fn test_finalization_reserve_negative_inputs() {
798 let result = compute_finalization_reserve(Duration::from_secs(100), -10.0, 0.15);
799 assert_eq!(result, Duration::ZERO);
800
801 let result = compute_finalization_reserve(Duration::from_secs(100), 30.0, -0.5);
802 assert_eq!(result, Duration::ZERO);
803 }
804
805 #[test]
807 fn test_finalization_reserve_infinite_inputs() {
808 let result = compute_finalization_reserve(Duration::from_secs(100), f64::INFINITY, 0.15);
809 assert_eq!(result, Duration::ZERO);
811
812 let result =
813 compute_finalization_reserve(Duration::from_secs(100), 30.0, f64::NEG_INFINITY);
814 assert_eq!(result, Duration::ZERO);
815 }
816
817 #[test]
819 fn test_finalization_reserve_ratio_capped() {
820 let result = compute_finalization_reserve(Duration::from_secs(100), 200.0, 2.0);
822 assert_eq!(result, Duration::from_secs(100));
823 }
824
825 #[test]
830 fn test_phase_budget_sanitization_nan() {
831 let val: f64 = f64::NAN;
832 let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
833 assert_eq!(safe, 0.0);
834 let _ = Duration::from_secs_f64(safe);
836 }
837
838 #[test]
839 fn test_phase_budget_sanitization_infinity() {
840 let val: f64 = f64::INFINITY;
841 let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
842 assert_eq!(safe, 0.0);
843 let _ = Duration::from_secs_f64(safe);
844 }
845
846 #[test]
847 fn test_phase_budget_sanitization_neg_infinity() {
848 let val: f64 = f64::NEG_INFINITY;
849 let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
850 assert_eq!(safe, 0.0);
851 let _ = Duration::from_secs_f64(safe);
852 }
853
854 #[test]
855 fn test_phase_budget_sanitization_negative() {
856 let val: f64 = -100.0;
857 let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
858 assert_eq!(safe, 0.0);
859 let _ = Duration::from_secs_f64(safe);
860 }
861
862 #[test]
863 fn test_phase_budget_sanitization_valid() {
864 let val: f64 = 42.5;
865 let safe = if val.is_finite() { val.max(0.0) } else { 0.0 };
866 assert_eq!(safe, 42.5);
867 assert_eq!(Duration::from_secs_f64(safe), Duration::from_millis(42500));
868 }
869
870 #[test]
871 fn test_publish_sse_event_sanitizes_session_id() {
872 let raw = "my.session>with*wildcards";
874 let safe = crate::nats_utils::sanitize_subject_component(raw);
875 assert!(!safe.contains('.'), "Dots should be removed");
876 assert!(!safe.contains('>'), "Greater-than should be removed");
877 assert!(!safe.contains('*'), "Wildcards should be removed");
878 assert!(!safe.is_empty(), "Sanitized result should not be empty");
879 }
880
881 #[test]
886 fn test_escape_xml_empty_string() {
887 assert_eq!(escape_xml(""), "");
888 }
889
890 #[test]
891 fn test_escape_xml_no_special_chars() {
892 let input = "Hello, world! 123 test";
893 assert_eq!(escape_xml(input), input);
894 }
895
896 #[test]
897 fn test_escape_xml_all_special_chars() {
898 let input = "&<>";
899 assert_eq!(escape_xml(input), "&<>");
900 }
901
902 #[test]
903 fn test_escape_xml_preserves_quotes() {
904 let input = "He said \"hello\" and it's fine";
906 assert_eq!(escape_xml(input), "He said \"hello\" and it's fine");
907 }
908
909 #[test]
910 fn test_escape_xml_already_escaped() {
911 let input = "& < >";
913 let result = escape_xml(input);
914 assert_eq!(result, "&amp; &lt; &gt;");
915 }
916
917 #[test]
918 fn test_escape_xml_multiline() {
919 let input = "line1 <b>bold</b>\nline2 & more\nline3 > end";
920 let expected = "line1 <b>bold</b>\nline2 & more\nline3 > end";
921 assert_eq!(escape_xml(input), expected);
922 }
923
924 #[test]
929 fn test_escape_xml_attr_empty() {
930 assert_eq!(escape_xml_attr(""), "");
931 }
932
933 #[test]
934 fn test_escape_xml_attr_all_five_special_chars() {
935 let input = "&<>\"'";
936 assert_eq!(escape_xml_attr(input), "&<>"'");
937 }
938
939 #[test]
940 fn test_escape_xml_attr_no_special_chars() {
941 let input = "simple text 123";
942 assert_eq!(escape_xml_attr(input), input);
943 }
944
945 #[test]
946 fn test_escape_xml_attr_mixed_quotes_and_entities() {
947 let input = "tool_name=\"bad\" & 'evil' <injected>";
948 let expected = "tool_name="bad" & 'evil' <injected>";
949 assert_eq!(escape_xml_attr(input), expected);
950 }
951
952 #[test]
957 fn test_finalization_reserve_zero_budget() {
958 let result = compute_finalization_reserve(Duration::ZERO, 30.0, 0.15);
959 assert_eq!(result, Duration::ZERO);
960 }
961
962 #[test]
963 fn test_finalization_reserve_both_params_zero() {
964 let result = compute_finalization_reserve(Duration::from_secs(100), 0.0, 0.0);
965 assert_eq!(result, Duration::ZERO);
966 }
967
968 #[test]
969 fn test_finalization_reserve_ratio_exactly_one() {
970 let result = compute_finalization_reserve(Duration::from_secs(100), 200.0, 1.0);
972 assert_eq!(result, Duration::from_secs(100));
974 }
975
976 #[test]
977 fn test_finalization_reserve_fixed_smaller_than_ratio() {
978 let result = compute_finalization_reserve(Duration::from_secs(100), 10.0, 0.5);
980 assert_eq!(result, Duration::from_secs(10));
981 }
982
983 #[test]
984 fn test_finalization_reserve_very_large_budget() {
985 let result = compute_finalization_reserve(Duration::from_secs(10000), 60.0, 0.01);
987 assert_eq!(result, Duration::from_secs(60));
989 }
990
991 #[test]
992 fn test_finalization_reserve_very_small_budget() {
993 let result = compute_finalization_reserve(Duration::from_millis(100), 30.0, 0.15);
995 assert_eq!(result, Duration::from_millis(15));
997 }
998
999 #[test]
1000 fn test_finalization_reserve_both_nan() {
1001 let result = compute_finalization_reserve(Duration::from_secs(100), f64::NAN, f64::NAN);
1002 assert_eq!(result, Duration::ZERO);
1003 }
1004
1005 #[test]
1006 fn test_finalization_reserve_both_infinite() {
1007 let result =
1008 compute_finalization_reserve(Duration::from_secs(100), f64::INFINITY, f64::INFINITY);
1009 assert_eq!(result, Duration::ZERO);
1011 }
1012
1013 #[test]
1014 fn test_finalization_reserve_neg_infinity_secs() {
1015 let result =
1016 compute_finalization_reserve(Duration::from_secs(100), f64::NEG_INFINITY, 0.15);
1017 assert_eq!(result, Duration::ZERO);
1019 }
1020
1021 #[test]
1022 fn test_finalization_reserve_fractional_duration() {
1023 let result = compute_finalization_reserve(Duration::from_millis(500), 1.0, 0.1);
1025 assert_eq!(result, Duration::from_millis(50));
1027 }
1028}