1use async_trait::async_trait;
14use futures::StreamExt;
15use futures::stream;
16use std::sync::Arc;
17use std::sync::atomic::{AtomicUsize, Ordering};
18
19use crate::driver_registry::{
20 BoxedChatDriver, ChatDriver, DriverId, DriverRegistry, LlmCallConfig, LlmCompletionMetadata,
21 LlmMessage, LlmMessageRole, LlmResponseStream, LlmStreamEvent,
22};
23use crate::error::{AgentLoopError, Result};
24use crate::tool_types::ToolCall;
25use llmsim::generator::{LoremGenerator, ResponseGenerator};
26use llmsim::latency::LatencyProfile;
27use llmsim::openai::{ChatCompletionRequest, Message, Role, Usage};
28use llmsim::script::auto_tool_call_id;
29use llmsim::stream::TokenStreamBuilder;
30
31#[derive(Debug, Clone)]
37pub struct LlmSimConfig {
38 pub response: ResponseConfig,
40 pub tool_calls: Option<ToolCallConfig>,
42 pub simulate_latency: bool,
44 pub model_name: String,
46 pub response_delay: Option<std::time::Duration>,
50 pub response_id: Option<String>,
53 pub effort_capture: Option<Arc<std::sync::Mutex<Vec<Option<String>>>>>,
58}
59
60impl Default for LlmSimConfig {
61 fn default() -> Self {
62 Self {
63 response: ResponseConfig::Fixed("Hello! I'm a simulated LLM response.".to_string()),
64 tool_calls: None,
65 simulate_latency: false,
66 model_name: "llmsim-model".to_string(),
67 response_delay: None,
68 response_id: None,
69 effort_capture: None,
70 }
71 }
72}
73
74impl LlmSimConfig {
75 pub fn fixed(response: impl Into<String>) -> Self {
77 Self {
78 response: ResponseConfig::Fixed(response.into()),
79 ..Default::default()
80 }
81 }
82
83 pub fn echo() -> Self {
85 Self {
86 response: ResponseConfig::Echo,
87 ..Default::default()
88 }
89 }
90
91 pub fn lorem(target_tokens: usize) -> Self {
93 Self {
94 response: ResponseConfig::Lorem { target_tokens },
95 ..Default::default()
96 }
97 }
98
99 pub fn sequence(responses: Vec<String>) -> Self {
101 Self {
102 response: ResponseConfig::Sequence(responses),
103 ..Default::default()
104 }
105 }
106
107 pub fn scripted(turns: Vec<SimTurn>) -> Self {
109 Self {
110 response: ResponseConfig::Scripted {
111 turns,
112 on_exhausted: OnExhausted::default(),
113 },
114 ..Default::default()
115 }
116 }
117
118 pub fn with_on_exhausted(mut self, mode: OnExhausted) -> Self {
120 if let ResponseConfig::Scripted { on_exhausted, .. } = &mut self.response {
121 *on_exhausted = mode;
122 }
123 self
124 }
125
126 pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
128 self.tool_calls = Some(ToolCallConfig::Fixed(tool_calls));
129 self
130 }
131
132 pub fn with_tool_call_sequence(mut self, sequences: Vec<Vec<ToolCall>>) -> Self {
134 self.tool_calls = Some(ToolCallConfig::Sequence(sequences));
135 self
136 }
137
138 pub fn with_latency(mut self) -> Self {
140 self.simulate_latency = true;
141 self
142 }
143
144 pub fn with_model(mut self, model: impl Into<String>) -> Self {
146 self.model_name = model.into();
147 self
148 }
149
150 pub fn with_response_delay(mut self, delay: std::time::Duration) -> Self {
153 self.response_delay = Some(delay);
154 self
155 }
156
157 pub fn with_response_id(mut self, id: impl Into<String>) -> Self {
159 self.response_id = Some(id.into());
160 self
161 }
162
163 pub fn with_effort_capture(
167 mut self,
168 capture: Arc<std::sync::Mutex<Vec<Option<String>>>>,
169 ) -> Self {
170 self.effort_capture = Some(capture);
171 self
172 }
173
174 pub fn error(message: impl Into<String>) -> Self {
176 Self {
177 response: ResponseConfig::Error(message.into()),
178 ..Default::default()
179 }
180 }
181
182 pub fn model_not_available() -> Self {
184 Self {
185 response: ResponseConfig::ModelNotAvailable,
186 ..Default::default()
187 }
188 }
189}
190
191#[derive(Debug, Clone)]
193pub enum ResponseConfig {
194 Fixed(String),
196 Echo,
198 Lorem { target_tokens: usize },
200 Sequence(Vec<String>),
202 Scripted {
204 turns: Vec<SimTurn>,
205 on_exhausted: OnExhausted,
206 },
207 Empty,
209 Error(String),
211 ModelNotAvailable,
213}
214
215#[derive(Debug, Clone, PartialEq)]
217pub enum SimTurn {
218 Assistant(String),
220 ToolCalls(Vec<SimToolCall>),
222 Mixed {
224 text: String,
225 tool_calls: Vec<SimToolCall>,
226 },
227 Error(SimError),
229}
230
231#[derive(Debug, Clone, PartialEq)]
233pub struct SimToolCall {
234 pub name: String,
235 pub arguments: serde_json::Value,
236 pub id: Option<String>,
237}
238
239#[derive(Debug, Clone, PartialEq)]
241pub enum SimError {
242 RateLimit,
243 Timeout,
244 InvalidResponse(String),
245 Other(String),
246}
247
248impl SimError {
249 fn status_code(&self) -> u16 {
250 match self {
251 SimError::RateLimit => 429,
252 SimError::Timeout => 504,
253 SimError::InvalidResponse(_) => 400,
254 SimError::Other(_) => 500,
255 }
256 }
257
258 fn message(&self) -> String {
259 match self {
260 SimError::RateLimit => "Rate limit exceeded. Please retry after some time.".to_string(),
261 SimError::Timeout => "Request timed out".to_string(),
262 SimError::InvalidResponse(message) | SimError::Other(message) => message.clone(),
263 }
264 }
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
269pub enum OnExhausted {
270 #[default]
272 RepeatLast,
273 Error,
275 Loop,
277}
278
279#[derive(Debug, Clone)]
281pub enum ToolCallConfig {
282 Fixed(Vec<ToolCall>),
284 Sequence(Vec<Vec<ToolCall>>),
286 Conditional {
288 patterns: Vec<ToolCallPattern>,
290 },
291}
292
293#[derive(Debug, Clone)]
295pub struct ToolCallPattern {
296 pub contains: String,
298 pub tool_calls: Vec<ToolCall>,
300}
301
302impl ToolCallPattern {
303 pub fn new(contains: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
304 Self {
305 contains: contains.into(),
306 tool_calls,
307 }
308 }
309}
310
311fn materialize_scripted_tool_calls(
312 turn_index: usize,
313 calls: Vec<SimToolCall>,
314) -> Option<Vec<ToolCall>> {
315 if calls.is_empty() {
316 return None;
317 }
318
319 Some(
320 calls
321 .into_iter()
322 .enumerate()
323 .map(|(call_index, call)| ToolCall {
324 id: call
325 .id
326 .unwrap_or_else(|| auto_tool_call_id(turn_index, call_index)),
327 name: call.name,
328 arguments: call.arguments,
329 })
330 .collect(),
331 )
332}
333
334#[derive(Clone)]
367pub struct LlmSimDriver {
368 config: LlmSimConfig,
369 response_counter: Arc<AtomicUsize>,
371 tool_call_counter: Arc<AtomicUsize>,
373}
374
375struct GeneratedTurn {
376 text: String,
377 tool_calls: Option<Vec<ToolCall>>,
378}
379
380impl LlmSimDriver {
381 pub fn new(config: LlmSimConfig) -> Self {
383 Self {
384 config,
385 response_counter: Arc::new(AtomicUsize::new(0)),
386 tool_call_counter: Arc::new(AtomicUsize::new(0)),
387 }
388 }
389
390 pub fn default_driver() -> Self {
392 Self::new(LlmSimConfig::default())
393 }
394
395 fn generate_response(&self, messages: &[LlmMessage]) -> String {
397 match &self.config.response {
398 ResponseConfig::Fixed(text) => text.clone(),
399
400 ResponseConfig::Echo => {
401 let last_user = messages
403 .iter()
404 .rev()
405 .find(|m| m.role == LlmMessageRole::User)
406 .map(|m| m.content_as_text())
407 .unwrap_or_default();
408 format!("Echo: {}", last_user)
409 }
410
411 ResponseConfig::Lorem { target_tokens } => {
412 let generator = LoremGenerator::new(*target_tokens);
413 let request = self.to_chat_request(messages);
414 generator.generate(&request)
415 }
416
417 ResponseConfig::Sequence(responses) => {
418 if responses.is_empty() {
419 return String::new();
420 }
421 let idx = self.response_counter.fetch_add(1, Ordering::SeqCst);
422 responses[idx % responses.len()].clone()
423 }
424
425 ResponseConfig::Empty => String::new(),
426
427 ResponseConfig::Error(_)
429 | ResponseConfig::ModelNotAvailable
430 | ResponseConfig::Scripted { .. } => {
431 unreachable!("Special configs handled in chat_completion_stream")
432 }
433 }
434 }
435
436 fn get_tool_calls(&self, messages: &[LlmMessage]) -> Option<Vec<ToolCall>> {
438 match &self.config.tool_calls {
439 None => None,
440
441 Some(ToolCallConfig::Fixed(calls)) => {
442 if calls.is_empty() {
443 None
444 } else {
445 Some(calls.clone())
446 }
447 }
448
449 Some(ToolCallConfig::Sequence(sequences)) => {
450 if sequences.is_empty() {
451 return None;
452 }
453 let idx = self.tool_call_counter.fetch_add(1, Ordering::SeqCst);
454 let calls = &sequences[idx % sequences.len()];
455 if calls.is_empty() {
456 None
457 } else {
458 Some(calls.clone())
459 }
460 }
461
462 Some(ToolCallConfig::Conditional { patterns }) => {
463 for message in messages.iter().rev() {
472 if message.role != LlmMessageRole::User {
473 continue;
474 }
475 let text = message.content_as_text();
476 if let Some(pattern) = patterns.iter().find(|p| text.contains(&p.contains)) {
477 return if pattern.tool_calls.is_empty() {
478 None
479 } else {
480 Some(pattern.tool_calls.clone())
481 };
482 }
483 }
484 None
485 }
486 }
487 }
488
489 fn generate_turn(&self, messages: &[LlmMessage]) -> Result<GeneratedTurn> {
490 if let ResponseConfig::Scripted {
491 turns,
492 on_exhausted,
493 } = &self.config.response
494 {
495 return self.generate_scripted_turn(turns, *on_exhausted);
496 }
497
498 Ok(GeneratedTurn {
499 text: self.generate_response(messages),
500 tool_calls: self.get_tool_calls(messages),
501 })
502 }
503
504 fn generate_scripted_turn(
505 &self,
506 turns: &[SimTurn],
507 on_exhausted: OnExhausted,
508 ) -> Result<GeneratedTurn> {
509 if turns.is_empty() {
510 return Err(AgentLoopError::config(
511 "llmsim scripted config must contain at least one turn",
512 ));
513 }
514
515 let turn_index = self.response_counter.fetch_add(1, Ordering::SeqCst);
516 let turn = if turn_index < turns.len() {
517 turns[turn_index].clone()
518 } else {
519 match on_exhausted {
520 OnExhausted::RepeatLast => turns[turns.len() - 1].clone(),
521 OnExhausted::Loop => turns[turn_index % turns.len()].clone(),
522 OnExhausted::Error => {
523 return Err(AgentLoopError::config("llmsim scripted config exhausted"));
524 }
525 }
526 };
527
528 match turn {
529 SimTurn::Assistant(text) => Ok(GeneratedTurn {
530 text,
531 tool_calls: None,
532 }),
533 SimTurn::ToolCalls(calls) => Ok(GeneratedTurn {
534 text: String::new(),
535 tool_calls: materialize_scripted_tool_calls(turn_index, calls),
536 }),
537 SimTurn::Mixed { text, tool_calls } => Ok(GeneratedTurn {
538 text,
539 tool_calls: materialize_scripted_tool_calls(turn_index, tool_calls),
540 }),
541 SimTurn::Error(error) => Err(AgentLoopError::llm(format!(
542 "LlmSim scripted error ({}): {}",
543 error.status_code(),
544 error.message()
545 ))),
546 }
547 }
548
549 fn to_chat_request(&self, messages: &[LlmMessage]) -> ChatCompletionRequest {
551 let sim_messages: Vec<Message> = messages
552 .iter()
553 .map(|m| {
554 let role = match m.role {
555 LlmMessageRole::System => Role::System,
556 LlmMessageRole::User => Role::User,
557 LlmMessageRole::Assistant => Role::Assistant,
558 LlmMessageRole::Tool => Role::Tool,
559 };
560 Message {
561 role,
562 content: Some(m.content_as_text()),
563 name: None,
564 tool_calls: None,
565 tool_call_id: m.tool_call_id.clone(),
566 }
567 })
568 .collect();
569
570 ChatCompletionRequest {
571 model: self.config.model_name.clone(),
572 messages: sim_messages,
573 temperature: None,
574 top_p: None,
575 n: None,
576 max_tokens: None,
577 max_completion_tokens: None,
578 stream: true,
579 stop: None,
580 presence_penalty: None,
581 frequency_penalty: None,
582 logit_bias: None,
583 user: None,
584 tools: None,
585 tool_choice: None,
586 seed: None,
587 response_format: None,
588 }
589 }
590
591 fn resolve_latency_profile(&self, model_name: &str) -> LatencyProfile {
596 if self.config.simulate_latency || model_name.contains("-latency") {
597 LatencyProfile::fast()
598 } else {
599 LatencyProfile::instant()
600 }
601 }
602
603 fn estimate_tokens(text: &str) -> u32 {
605 (text.len() / 4).max(1) as u32
607 }
608}
609
610#[async_trait]
611impl ChatDriver for LlmSimDriver {
612 async fn chat_completion_stream(
613 &self,
614 messages: Vec<LlmMessage>,
615 config: &LlmCallConfig,
616 ) -> Result<LlmResponseStream> {
617 if let Some(capture) = &self.config.effort_capture
620 && let Ok(mut efforts) = capture.lock()
621 {
622 efforts.push(config.reasoning_effort.clone());
623 }
624
625 if let ResponseConfig::Error(error_msg) = &self.config.response {
627 return Err(anyhow::anyhow!("LLM error: {}", error_msg).into());
628 }
629 if matches!(self.config.response, ResponseConfig::ModelNotAvailable) {
630 return Err(AgentLoopError::model_not_available(config.model.clone()));
631 }
632
633 let delay = self
638 .config
639 .response_delay
640 .or_else(|| parse_ttft_from_model_name(&config.model));
641 if let Some(delay) = delay {
642 tokio::time::sleep(delay).await;
643 }
644
645 let generated_turn = self.generate_turn(&messages)?;
646 let response_text = generated_turn.text;
647 let tool_calls = generated_turn.tool_calls;
648 let model_name = config.model.clone();
649 let response_id_for_done = self.config.response_id.clone();
650 let latency_profile = self.resolve_latency_profile(&model_name);
651
652 let prompt_tokens: u32 = messages
654 .iter()
655 .map(|m| Self::estimate_tokens(&m.content_as_text()))
656 .sum();
657 let completion_tokens = Self::estimate_tokens(&response_text);
658
659 let usage = Usage {
662 prompt_tokens,
663 completion_tokens,
664 total_tokens: prompt_tokens + completion_tokens,
665 };
666
667 let chunk_stream = TokenStreamBuilder::new(&model_name, &response_text)
668 .latency(latency_profile)
669 .usage(usage)
670 .build()
671 .into_chunk_stream();
672
673 let tool_calls_tail = tool_calls;
676 let model_name_done = model_name.clone();
677 let event_stream = chunk_stream.flat_map(move |chunk| {
678 let mut events: Vec<Result<LlmStreamEvent>> = Vec::new();
679
680 for choice in &chunk.choices {
681 if let Some(content) = &choice.delta.content
682 && !content.is_empty()
683 {
684 events.push(Ok(LlmStreamEvent::TextDelta(content.clone())));
685 }
686 }
687
688 stream::iter(events)
689 });
690
691 let done_events: Vec<Result<LlmStreamEvent>> = {
693 let mut tail = Vec::new();
694 if let Some(calls) = tool_calls_tail {
695 tail.push(Ok(LlmStreamEvent::ToolCalls(calls)));
696 }
697 tail.push(Ok(LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
698 total_tokens: Some(prompt_tokens + completion_tokens),
699 prompt_tokens: Some(prompt_tokens),
700 completion_tokens: Some(completion_tokens),
701 cache_read_tokens: None,
702 cache_creation_tokens: None,
703 provider_cost_usd: None,
704 model: Some(model_name_done),
705 finish_reason: Some("stop".to_string()),
706 retry_metadata: None,
707 response_id: response_id_for_done,
708 phase: None,
709 }))));
710 tail
711 };
712
713 let full_stream = event_stream.chain(stream::iter(done_events));
714 Ok(Box::pin(full_stream))
715 }
716}
717
718impl std::fmt::Debug for LlmSimDriver {
719 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
720 f.debug_struct("LlmSimDriver")
721 .field("model", &self.config.model_name)
722 .field("simulate_latency", &self.config.simulate_latency)
723 .finish()
724 }
725}
726
727pub fn register_driver(registry: &mut DriverRegistry) {
747 registry.register(DriverId::LlmSim, |_config| {
748 Box::new(LlmSimDriver::default_driver()) as BoxedChatDriver
750 });
751}
752
753pub fn register_driver_with_config(registry: &mut DriverRegistry, config: LlmSimConfig) {
762 let driver = LlmSimDriver::new(config);
763 registry.register(DriverId::LlmSim, move |_config| {
764 Box::new(driver.clone()) as BoxedChatDriver
765 });
766}
767
768fn parse_ttft_from_model_name(model_name: &str) -> Option<std::time::Duration> {
774 if let Some(idx) = model_name.find("-ttft-") {
775 let after_ttft = &model_name[idx + 6..]; let ms_str: String = after_ttft
777 .chars()
778 .take_while(|c| c.is_ascii_digit())
779 .collect();
780 if let Ok(ms) = ms_str.parse::<u64>()
781 && ms > 0
782 {
783 return Some(std::time::Duration::from_millis(ms));
784 }
785 }
786 None
787}
788
789pub fn create_chat_driver(config: LlmSimConfig) -> BoxedChatDriver {
805 Box::new(LlmSimDriver::new(config))
806}
807
808pub fn auditor_demo_script() -> LlmSimConfig {
826 let turns = vec![
827 SimTurn::Mixed {
828 text: "Starting the audit. Listing EC2 instances first.".to_string(),
829 tool_calls: vec![SimToolCall {
830 name: "aws_list_ec2_instances".to_string(),
831 arguments: serde_json::json!({}),
832 id: Some("call_demo_ec2".to_string()),
833 }],
834 },
835 SimTurn::Mixed {
836 text: "EC2 inventory captured. Listing S3 buckets next.".to_string(),
837 tool_calls: vec![SimToolCall {
838 name: "aws_list_s3_buckets".to_string(),
839 arguments: serde_json::json!({}),
840 id: Some("call_demo_s3".to_string()),
841 }],
842 },
843 SimTurn::Assistant(
844 "Audit complete: inventoried EC2 instances and S3 buckets. \
845 See /workspace/.audit.log for the per-tool-call audit trail \
846 written by the post_tool_use hook bundle."
847 .to_string(),
848 ),
849 ];
850 LlmSimConfig::scripted(turns)
851}
852
853pub fn guarded_bash_demo_script() -> LlmSimConfig {
863 let turns = vec![
864 SimTurn::Mixed {
865 text: "Step 1: attempting a destructive command.".to_string(),
866 tool_calls: vec![SimToolCall {
867 name: "bash".to_string(),
868 arguments: serde_json::json!({ "commands": "rm -rf /" }),
869 id: Some("call_demo_rm".to_string()),
870 }],
871 },
872 SimTurn::Mixed {
873 text: "Step 2: trying a safe command.".to_string(),
874 tool_calls: vec![SimToolCall {
875 name: "bash".to_string(),
876 arguments: serde_json::json!({ "commands": "ls -la /workspace" }),
877 id: Some("call_demo_ls".to_string()),
878 }],
879 },
880 SimTurn::Assistant(
881 "Guarded-bash demo complete. The first tool call should be \
882 blocked by the pre_tool_use hook; the second should succeed."
883 .to_string(),
884 ),
885 ];
886 LlmSimConfig::scripted(turns)
887}
888
889pub fn session_tasks_demo_script() -> LlmSimConfig {
897 let turns = vec![
898 SimTurn::Mixed {
899 text: "Kicking off a background bash run.".to_string(),
900 tool_calls: vec![SimToolCall {
901 name: "spawn_background".to_string(),
902 arguments: serde_json::json!({
903 "tool": "bash",
904 "args": { "commands": "echo task demo start; echo task demo done" },
905 "title": "Demo background run",
906 "signal_on_completion": false,
907 }),
908 id: Some("call_demo_spawn".to_string()),
909 }],
910 },
911 SimTurn::Mixed {
912 text: "Checking the session task registry.".to_string(),
913 tool_calls: vec![SimToolCall {
914 name: "list_tasks".to_string(),
915 arguments: serde_json::json!({}),
916 id: Some("call_demo_list".to_string()),
917 }],
918 },
919 SimTurn::Assistant(
920 "Session tasks demo complete: a background run was started and \
921 tracked as a session task. Inspect it via \
922 GET /v1/sessions/{session_id}/tasks."
923 .to_string(),
924 ),
925 ];
926 LlmSimConfig::scripted(turns)
927}
928
929pub fn monitor_demo_script() -> LlmSimConfig {
934 let turns = vec![
935 SimTurn::Mixed {
936 text: "Setting up a recurring monitor.".to_string(),
937 tool_calls: vec![SimToolCall {
938 name: "spawn_background".to_string(),
939 arguments: serde_json::json!({
940 "tool": "bash",
941 "args": { "commands": "echo monitor check" },
942 "title": "Demo monitor",
943 "signal_on_completion": false,
944 "schedule": { "cron_expression": "0 * * * * * *", "timezone": "UTC" },
945 }),
946 id: Some("call_demo_monitor".to_string()),
947 }],
948 },
949 SimTurn::Assistant(
950 "Monitor demo complete: a recurring monitor was scheduled and tracked as a session task. Inspect it via GET /v1/sessions/{session_id}/tasks."
951 .to_string(),
952 ),
953 ];
954 LlmSimConfig::scripted(turns)
955}
956
957#[cfg(test)]
962mod tests {
963 use super::*;
964 use futures::StreamExt;
965
966 #[test]
967 fn auditor_demo_script_calls_ec2_then_s3_then_summarises() {
968 let config = auditor_demo_script();
969 let turns = match &config.response {
970 ResponseConfig::Scripted { turns, .. } => turns,
971 other => panic!("expected Scripted, got {other:?}"),
972 };
973 assert_eq!(turns.len(), 3, "script has three turns");
974 match &turns[0] {
975 SimTurn::Mixed { tool_calls, .. } => {
976 assert_eq!(tool_calls.len(), 1);
977 assert_eq!(tool_calls[0].name, "aws_list_ec2_instances");
978 }
979 other => panic!("turn 0 should be Mixed, got {other:?}"),
980 }
981 match &turns[1] {
982 SimTurn::Mixed { tool_calls, .. } => {
983 assert_eq!(tool_calls.len(), 1);
984 assert_eq!(tool_calls[0].name, "aws_list_s3_buckets");
985 }
986 other => panic!("turn 1 should be Mixed, got {other:?}"),
987 }
988 match &turns[2] {
989 SimTurn::Assistant(text) => {
990 assert!(
991 text.contains("/workspace/.audit.log"),
992 "summary mentions the audit log: {text:?}"
993 );
994 }
995 other => panic!("turn 2 should be Assistant, got {other:?}"),
996 }
997 }
998
999 fn make_config() -> LlmCallConfig {
1000 LlmCallConfig {
1001 speed: None,
1002 verbosity: None,
1003 model: "test-model".to_string(),
1004 temperature: None,
1005 max_tokens: None,
1006 tools: vec![],
1007 reasoning_effort: None,
1008 metadata: std::collections::HashMap::new(),
1009 previous_response_id: None,
1010 tool_search: None,
1011 prompt_cache: None,
1012 openrouter_routing: None,
1013 parallel_tool_calls: None,
1014 volatile_suffix_len: 0,
1015 }
1016 }
1017
1018 fn user_message(content: &str) -> LlmMessage {
1019 LlmMessage::text(LlmMessageRole::User, content)
1020 }
1021
1022 fn system_message(content: &str) -> LlmMessage {
1023 LlmMessage::text(LlmMessageRole::System, content)
1024 }
1025
1026 #[tokio::test]
1027 async fn test_fixed_response() {
1028 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hello, world!"));
1029 let messages = vec![user_message("Hi there")];
1030
1031 let response = driver
1032 .chat_completion(messages, &make_config())
1033 .await
1034 .unwrap();
1035
1036 assert_eq!(response.text, "Hello, world!");
1037 assert!(response.tool_calls.is_none());
1038 }
1039
1040 #[tokio::test]
1041 async fn test_echo_response() {
1042 let driver = LlmSimDriver::new(LlmSimConfig::echo());
1043 let messages = vec![
1044 system_message("You are a helpful assistant"),
1045 user_message("What is 2+2?"),
1046 ];
1047
1048 let response = driver
1049 .chat_completion(messages, &make_config())
1050 .await
1051 .unwrap();
1052
1053 assert_eq!(response.text, "Echo: What is 2+2?");
1054 }
1055
1056 #[tokio::test]
1057 async fn test_sequence_response() {
1058 let driver = LlmSimDriver::new(LlmSimConfig::sequence(vec![
1059 "First".to_string(),
1060 "Second".to_string(),
1061 "Third".to_string(),
1062 ]));
1063
1064 let messages = vec![user_message("test")];
1065
1066 let r1 = driver
1068 .chat_completion(messages.clone(), &make_config())
1069 .await
1070 .unwrap();
1071 assert_eq!(r1.text, "First");
1072
1073 let r2 = driver
1075 .chat_completion(messages.clone(), &make_config())
1076 .await
1077 .unwrap();
1078 assert_eq!(r2.text, "Second");
1079
1080 let r3 = driver
1082 .chat_completion(messages.clone(), &make_config())
1083 .await
1084 .unwrap();
1085 assert_eq!(r3.text, "Third");
1086
1087 let r4 = driver
1089 .chat_completion(messages.clone(), &make_config())
1090 .await
1091 .unwrap();
1092 assert_eq!(r4.text, "First");
1093 }
1094
1095 #[tokio::test]
1096 async fn test_lorem_response() {
1097 let driver = LlmSimDriver::new(LlmSimConfig::lorem(50));
1098 let messages = vec![user_message("Generate text")];
1099
1100 let response = driver
1101 .chat_completion(messages, &make_config())
1102 .await
1103 .unwrap();
1104
1105 assert!(!response.text.is_empty());
1107 assert!(response.text.split_whitespace().count() > 5);
1109 }
1110
1111 #[tokio::test]
1112 async fn test_fixed_tool_calls() {
1113 let tool_call = ToolCall {
1114 id: "call_123".to_string(),
1115 name: "get_weather".to_string(),
1116 arguments: serde_json::json!({"city": "NYC"}),
1117 };
1118
1119 let driver = LlmSimDriver::new(
1120 LlmSimConfig::fixed("Let me check the weather.")
1121 .with_tool_calls(vec![tool_call.clone()]),
1122 );
1123
1124 let messages = vec![user_message("What's the weather?")];
1125 let response = driver
1126 .chat_completion(messages, &make_config())
1127 .await
1128 .unwrap();
1129
1130 assert_eq!(response.text, "Let me check the weather.");
1131 let calls = response.tool_calls.expect("Expected tool calls");
1132 assert_eq!(calls.len(), 1);
1133 assert_eq!(calls[0].name, "get_weather");
1134 assert_eq!(calls[0].id, "call_123");
1135 }
1136
1137 #[tokio::test]
1138 async fn test_tool_call_sequence() {
1139 let call1 = ToolCall {
1140 id: "call_1".to_string(),
1141 name: "search".to_string(),
1142 arguments: serde_json::json!({"q": "rust"}),
1143 };
1144 let call2 = ToolCall {
1145 id: "call_2".to_string(),
1146 name: "fetch".to_string(),
1147 arguments: serde_json::json!({"url": "https://example.com"}),
1148 };
1149
1150 let driver = LlmSimDriver::new(
1151 LlmSimConfig::fixed("Processing...").with_tool_call_sequence(vec![
1152 vec![call1.clone()],
1153 vec![call2.clone()],
1154 vec![],
1155 ]),
1156 );
1157
1158 let messages = vec![user_message("test")];
1159
1160 let r1 = driver
1162 .chat_completion(messages.clone(), &make_config())
1163 .await
1164 .unwrap();
1165 let calls1 = r1.tool_calls.expect("Expected tool calls");
1166 assert_eq!(calls1[0].name, "search");
1167
1168 let r2 = driver
1170 .chat_completion(messages.clone(), &make_config())
1171 .await
1172 .unwrap();
1173 let calls2 = r2.tool_calls.expect("Expected tool calls");
1174 assert_eq!(calls2[0].name, "fetch");
1175
1176 let r3 = driver
1178 .chat_completion(messages.clone(), &make_config())
1179 .await
1180 .unwrap();
1181 assert!(r3.tool_calls.is_none());
1182 }
1183
1184 #[tokio::test]
1185 async fn test_scripted_multi_turn_tool_call_agent_sequence() {
1186 let driver = LlmSimDriver::new(
1187 LlmSimConfig::scripted(vec![
1188 SimTurn::ToolCalls(vec![SimToolCall {
1189 name: "bash".to_string(),
1190 arguments: serde_json::json!({"command": "echo hello > /tmp/x.txt"}),
1191 id: None,
1192 }]),
1193 SimTurn::ToolCalls(vec![SimToolCall {
1194 name: "bash".to_string(),
1195 arguments: serde_json::json!({"command": "sed -i s/hello/world/ /tmp/x.txt"}),
1196 id: None,
1197 }]),
1198 SimTurn::Assistant("done".to_string()),
1199 ])
1200 .with_on_exhausted(OnExhausted::Error),
1201 );
1202
1203 let messages = vec![user_message("create /tmp/x.txt then change hello to world")];
1204
1205 let first = driver
1206 .chat_completion(messages.clone(), &make_config())
1207 .await
1208 .unwrap();
1209 let first_calls = first.tool_calls.expect("first turn should call bash");
1210 assert_eq!(first.text, "");
1211 assert_eq!(first_calls[0].name, "bash");
1212 assert_eq!(first_calls[0].id, "call_llmsim_0_0");
1213
1214 let second = driver
1215 .chat_completion(messages.clone(), &make_config())
1216 .await
1217 .unwrap();
1218 let second_calls = second.tool_calls.expect("second turn should call bash");
1219 assert_eq!(second_calls[0].name, "bash");
1220 assert_eq!(second_calls[0].id, "call_llmsim_1_0");
1221
1222 let final_response = driver
1223 .chat_completion(messages.clone(), &make_config())
1224 .await
1225 .unwrap();
1226 assert_eq!(final_response.text, "done");
1227 assert!(final_response.tool_calls.is_none());
1228
1229 let exhausted = driver
1230 .chat_completion(messages, &make_config())
1231 .await
1232 .unwrap_err();
1233 assert!(matches!(exhausted, AgentLoopError::Configuration(_)));
1234 }
1235
1236 #[tokio::test]
1237 async fn test_scripted_mixed_turn_streams_text_and_tool_calls() {
1238 let driver = LlmSimDriver::new(LlmSimConfig::scripted(vec![SimTurn::Mixed {
1239 text: "Let me check".to_string(),
1240 tool_calls: vec![SimToolCall {
1241 name: "search".to_string(),
1242 arguments: serde_json::json!({"q": "rust"}),
1243 id: Some("call_search".to_string()),
1244 }],
1245 }]));
1246
1247 let mut stream = driver
1248 .chat_completion_stream(vec![user_message("find rust")], &make_config())
1249 .await
1250 .unwrap();
1251
1252 let mut text_parts = Vec::new();
1253 let mut tool_calls = None;
1254 while let Some(event) = stream.next().await {
1255 match event.unwrap() {
1256 LlmStreamEvent::TextDelta(text) => text_parts.push(text),
1257 LlmStreamEvent::ToolCalls(calls) => tool_calls = Some(calls),
1258 LlmStreamEvent::Done(_) => {}
1259 _ => {}
1260 }
1261 }
1262
1263 assert!(!text_parts.is_empty(), "scripted text should stream");
1264 assert_eq!(text_parts.join(""), "Let me check");
1265 let calls = tool_calls.expect("mixed turn should emit tool calls");
1266 assert_eq!(calls[0].id, "call_search");
1267 assert_eq!(calls[0].name, "search");
1268 }
1269
1270 #[tokio::test]
1271 async fn test_scripted_on_exhausted_modes() {
1272 let repeat = LlmSimDriver::new(LlmSimConfig::scripted(vec![
1273 SimTurn::Assistant("one".to_string()),
1274 SimTurn::Assistant("two".to_string()),
1275 ]));
1276 let messages = vec![user_message("test")];
1277 assert_eq!(
1278 repeat
1279 .chat_completion(messages.clone(), &make_config())
1280 .await
1281 .unwrap()
1282 .text,
1283 "one"
1284 );
1285 assert_eq!(
1286 repeat
1287 .chat_completion(messages.clone(), &make_config())
1288 .await
1289 .unwrap()
1290 .text,
1291 "two"
1292 );
1293 assert_eq!(
1294 repeat
1295 .chat_completion(messages.clone(), &make_config())
1296 .await
1297 .unwrap()
1298 .text,
1299 "two"
1300 );
1301
1302 let looping = LlmSimDriver::new(
1303 LlmSimConfig::scripted(vec![
1304 SimTurn::Assistant("a".to_string()),
1305 SimTurn::Assistant("b".to_string()),
1306 ])
1307 .with_on_exhausted(OnExhausted::Loop),
1308 );
1309 assert_eq!(
1310 looping
1311 .chat_completion(messages.clone(), &make_config())
1312 .await
1313 .unwrap()
1314 .text,
1315 "a"
1316 );
1317 assert_eq!(
1318 looping
1319 .chat_completion(messages.clone(), &make_config())
1320 .await
1321 .unwrap()
1322 .text,
1323 "b"
1324 );
1325 assert_eq!(
1326 looping
1327 .chat_completion(messages, &make_config())
1328 .await
1329 .unwrap()
1330 .text,
1331 "a"
1332 );
1333 }
1334
1335 #[tokio::test]
1336 async fn test_scripted_error_turn() {
1337 let driver = LlmSimDriver::new(LlmSimConfig::scripted(vec![SimTurn::Error(
1338 SimError::RateLimit,
1339 )]));
1340
1341 let err = driver
1342 .chat_completion(vec![user_message("test")], &make_config())
1343 .await
1344 .unwrap_err();
1345
1346 assert!(err.is_rate_limited());
1347 }
1348
1349 #[tokio::test]
1350 async fn test_conditional_tool_calls() {
1351 let weather_call = ToolCall {
1352 id: "call_w".to_string(),
1353 name: "get_weather".to_string(),
1354 arguments: serde_json::json!({}),
1355 };
1356 let search_call = ToolCall {
1357 id: "call_s".to_string(),
1358 name: "search".to_string(),
1359 arguments: serde_json::json!({}),
1360 };
1361
1362 let config = LlmSimConfig {
1363 response: ResponseConfig::Fixed("Response".to_string()),
1364 tool_calls: Some(ToolCallConfig::Conditional {
1365 patterns: vec![
1366 ToolCallPattern::new("weather", vec![weather_call]),
1367 ToolCallPattern::new("search", vec![search_call]),
1368 ],
1369 }),
1370 simulate_latency: false,
1371 model_name: "test".to_string(),
1372 response_delay: None,
1373 response_id: None,
1374 effort_capture: None,
1375 };
1376
1377 let driver = LlmSimDriver::new(config);
1378
1379 let r1 = driver
1381 .chat_completion(vec![user_message("What's the weather?")], &make_config())
1382 .await
1383 .unwrap();
1384 let calls1 = r1.tool_calls.expect("Expected weather tool");
1385 assert_eq!(calls1[0].name, "get_weather");
1386
1387 let r2 = driver
1389 .chat_completion(vec![user_message("search for rust")], &make_config())
1390 .await
1391 .unwrap();
1392 let calls2 = r2.tool_calls.expect("Expected search tool");
1393 assert_eq!(calls2[0].name, "search");
1394
1395 let r3 = driver
1397 .chat_completion(vec![user_message("hello world")], &make_config())
1398 .await
1399 .unwrap();
1400 assert!(r3.tool_calls.is_none());
1401 }
1402
1403 #[tokio::test]
1404 async fn test_streaming() {
1405 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hello world test"));
1406 let messages = vec![user_message("test")];
1407
1408 let mut stream = driver
1409 .chat_completion_stream(messages, &make_config())
1410 .await
1411 .unwrap();
1412
1413 let mut text_parts = Vec::new();
1414 let mut got_done = false;
1415
1416 while let Some(event) = stream.next().await {
1417 match event.unwrap() {
1418 LlmStreamEvent::TextDelta(text) => text_parts.push(text),
1419 LlmStreamEvent::Done(meta) => {
1420 got_done = true;
1421 assert!(meta.total_tokens.is_some());
1422 assert!(meta.model.is_some());
1423 }
1424 _ => {}
1425 }
1426 }
1427
1428 assert!(got_done);
1429 assert!(!text_parts.is_empty());
1431 assert_eq!(text_parts.join(""), "Hello world test");
1432 }
1433
1434 #[tokio::test]
1435 async fn test_metadata() {
1436 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hi").with_model("custom-model"));
1437 let messages = vec![user_message("test")];
1438
1439 let mut config = make_config();
1440 config.model = "request-model".to_string();
1441
1442 let response = driver.chat_completion(messages, &config).await.unwrap();
1443
1444 assert_eq!(response.metadata.model, Some("request-model".to_string()));
1446 assert!(response.metadata.prompt_tokens.is_some());
1447 assert!(response.metadata.completion_tokens.is_some());
1448 }
1449
1450 #[tokio::test]
1451 async fn test_register_driver() {
1452 let mut registry = DriverRegistry::new();
1453 register_driver(&mut registry);
1454
1455 assert!(registry.has_driver(&DriverId::LlmSim));
1456
1457 let config =
1459 crate::driver_registry::ProviderConfig::new(DriverId::LlmSim).with_api_key("fake-key");
1460 let driver = registry.create_chat_driver(&config);
1461 assert!(driver.is_ok());
1462 }
1463
1464 #[tokio::test]
1465 async fn test_empty_response() {
1466 let config = LlmSimConfig {
1467 response: ResponseConfig::Empty,
1468 tool_calls: None,
1469 simulate_latency: false,
1470 model_name: "test".to_string(),
1471 response_delay: None,
1472 response_id: None,
1473 effort_capture: None,
1474 };
1475
1476 let driver = LlmSimDriver::new(config);
1477 let messages = vec![user_message("test")];
1478
1479 let response = driver
1480 .chat_completion(messages, &make_config())
1481 .await
1482 .unwrap();
1483
1484 assert!(response.text.is_empty());
1485 }
1486
1487 #[test]
1488 fn test_driver_debug() {
1489 let driver = LlmSimDriver::new(LlmSimConfig::fixed("test").with_latency());
1490 let debug = format!("{:?}", driver);
1491
1492 assert!(debug.contains("LlmSimDriver"));
1493 assert!(debug.contains("simulate_latency"));
1494 }
1495
1496 #[test]
1497 fn test_default_config() {
1498 let config = LlmSimConfig::default();
1499 assert!(matches!(config.response, ResponseConfig::Fixed(_)));
1500 assert!(config.tool_calls.is_none());
1501 assert!(!config.simulate_latency);
1502 }
1503
1504 #[test]
1505 fn test_config_builder() {
1506 let tool_call = ToolCall {
1507 id: "call_1".to_string(),
1508 name: "get_weather".to_string(),
1509 arguments: serde_json::json!({"city": "NYC"}),
1510 };
1511
1512 let config = LlmSimConfig::fixed("Result")
1513 .with_tool_calls(vec![tool_call.clone()])
1514 .with_latency()
1515 .with_model("gpt-4")
1516 .with_response_delay(std::time::Duration::from_secs(2));
1517
1518 assert!(config.tool_calls.is_some());
1519 assert!(config.simulate_latency);
1520 assert_eq!(config.model_name, "gpt-4");
1521 assert_eq!(
1522 config.response_delay,
1523 Some(std::time::Duration::from_secs(2))
1524 );
1525 }
1526
1527 #[test]
1528 fn test_parse_ttft_from_model_name() {
1529 use super::parse_ttft_from_model_name;
1530
1531 assert_eq!(
1533 parse_ttft_from_model_name("llmsim-ttft-2000"),
1534 Some(std::time::Duration::from_millis(2000))
1535 );
1536 assert_eq!(
1537 parse_ttft_from_model_name("test-ttft-500-extra"),
1538 Some(std::time::Duration::from_millis(500))
1539 );
1540
1541 assert_eq!(parse_ttft_from_model_name("llmsim-model"), None);
1543 assert_eq!(parse_ttft_from_model_name("llmsim-ttft-0"), None);
1544 assert_eq!(parse_ttft_from_model_name("llmsim-ttft-abc"), None);
1545 }
1546
1547 #[test]
1548 fn test_resolve_latency_profile_from_model_name() {
1549 let driver = LlmSimDriver::new(LlmSimConfig::fixed("test"));
1550
1551 let profile = driver.resolve_latency_profile("llmsim-latency");
1553 assert!(profile.sample_ttft().as_nanos() > 0);
1554
1555 let profile = driver.resolve_latency_profile("llmsim-default");
1557 assert_eq!(profile.sample_ttft().as_nanos(), 0);
1558
1559 let driver = LlmSimDriver::new(LlmSimConfig::fixed("test").with_latency());
1561 let profile = driver.resolve_latency_profile("llmsim-default");
1562 assert!(profile.sample_ttft().as_nanos() > 0);
1563 }
1564
1565 #[tokio::test]
1566 async fn test_latency_streaming_from_model_name() {
1567 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hello world"));
1569 let messages = vec![user_message("test")];
1570
1571 let mut config = make_config();
1572 config.model = "llmsim-latency".to_string();
1573
1574 let start = std::time::Instant::now();
1575 let mut stream = driver
1576 .chat_completion_stream(messages, &config)
1577 .await
1578 .unwrap();
1579
1580 let mut text_parts = Vec::new();
1581 let mut got_done = false;
1582
1583 while let Some(event) = stream.next().await {
1584 match event.unwrap() {
1585 LlmStreamEvent::TextDelta(text) => text_parts.push(text),
1586 LlmStreamEvent::Done(meta) => {
1587 got_done = true;
1588 assert_eq!(meta.model, Some("llmsim-latency".to_string()));
1589 }
1590 _ => {}
1591 }
1592 }
1593
1594 assert!(got_done);
1595 assert_eq!(text_parts.join(""), "Hello world");
1596 assert!(
1599 start.elapsed().as_millis() > 0,
1600 "latency simulation should introduce delays"
1601 );
1602 }
1603
1604 #[tokio::test]
1605 async fn test_no_latency_streaming_is_instant() {
1606 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hello world"));
1607 let messages = vec![user_message("test")];
1608
1609 let mut config = make_config();
1610 config.model = "llmsim-default".to_string();
1611
1612 let start = std::time::Instant::now();
1613 let response = driver.chat_completion(messages, &config).await.unwrap();
1614 let elapsed = start.elapsed();
1615
1616 assert_eq!(response.text, "Hello world");
1617 assert!(
1619 elapsed.as_millis() < 50,
1620 "instant mode should have no delays, took {}ms",
1621 elapsed.as_millis()
1622 );
1623 }
1624}