1use mcp_utils::client::ElicitationSink;
2use mcp_utils::gateway::ServerSpec;
3use mcp_utils::model::ElicitResult;
4use mcp_utils::server::McpServer;
5use mcp_utils::testing::{CapturedElicitation, ElicitationScriptBuilder, FakeMcpServer};
6use std::future::Future;
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9use tokio::sync::{Notify, mpsc};
10
11use crate::context::CompactionConfig;
12use crate::core::{AgentError, McpInstructions, Prompt, RetryConfig, agent};
13use crate::events::{
14 AgentCommand, AgentEvent, AgentObserver, Command, ContextEvent, ToolEvent, TurnEvent, UserCommand,
15};
16use crate::mcp::{McpBuilder, McpCatalog, mcp};
17use crate::testing::{AgentTrace, FakeAgentObserver};
18use llm::{ChatMessage, Context, LlmError, LlmModel, LlmResponse, ModelSettings, StreamingModelProvider};
19
20use llm::testing::FakeLlmProvider;
21
22pub async fn drain_until(
23 receiver: &mut mpsc::Receiver<AgentEvent>,
24 predicate: impl Fn(&AgentEvent) -> bool,
25) -> Vec<AgentEvent> {
26 let mut events = Vec::new();
27 while let Some(event) = receiver.recv().await {
28 let matched = predicate(&event);
29 events.push(event);
30 if matched {
31 return events;
32 }
33 }
34 panic!("agent event channel closed before predicate matched");
35}
36
37pub fn content_events(events: Vec<AgentEvent>) -> Vec<AgentEvent> {
38 events
39 .into_iter()
40 .filter(|event| {
41 !matches!(
42 event,
43 AgentEvent::Turn(
44 TurnEvent::Started { .. }
45 | TurnEvent::UserMessageInserted { .. }
46 | TurnEvent::UserMessageDiscarded { .. }
47 | TurnEvent::LlmCallStarted { .. }
48 | TurnEvent::LlmCallEnded { .. }
49 ) | AgentEvent::Tool(ToolEvent::DefinitionsUpdated { .. })
50 )
51 })
52 .collect()
53}
54
55pub async fn ready_catalog(builder: McpBuilder) -> McpCatalog {
56 let catalog = builder.spawn().expect("the MCP catalog spawns");
57 catalog.ready().await;
58 catalog
59}
60
61pub fn mcp_instructions(entries: &[(&str, &str)]) -> McpInstructions {
62 let servers = entries.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect();
63 McpInstructions { servers, progressive_discovery: false }
64}
65
66pub fn fast_retry(max_attempts: u32) -> RetryConfig {
68 RetryConfig { max_attempts, base_delay: Duration::from_millis(1), max_delay: Duration::from_millis(5) }
69}
70
71pub fn test_agent() -> TestAgentBuilder {
72 TestAgentBuilder::new()
73}
74
75pub enum TestAgentStep {
77 Send(Command),
78 WaitFor(Box<dyn Fn(&AgentEvent) -> bool + Send>),
79 Perform(Box<dyn FnOnce() + Send>),
81}
82
83impl TestAgentStep {
84 pub fn send(command: Command) -> Self {
85 Self::Send(command)
86 }
87
88 pub fn user_text(text: impl Into<String>) -> Self {
89 Self::send(Command::text(text))
90 }
91
92 pub fn cancel() -> Self {
93 Self::send(Command::UserCommand(UserCommand::Cancel))
94 }
95
96 pub fn switch_model(provider: impl StreamingModelProvider + 'static) -> Self {
97 Self::send(Command::AgentCommand(AgentCommand::SwitchModel(Box::new(provider))))
98 }
99
100 pub fn replace_conversation(messages: Vec<ChatMessage>) -> Self {
101 Self::send(Command::AgentCommand(AgentCommand::ReplaceConversation(messages)))
102 }
103
104 pub fn perform(action: impl FnOnce() + Send + 'static) -> Self {
105 Self::Perform(Box::new(action))
106 }
107
108 pub fn wait_for(predicate: impl Fn(&AgentEvent) -> bool + Send + 'static) -> Self {
109 Self::WaitFor(Box::new(predicate))
110 }
111
112 pub fn wait_for_turn_end() -> Self {
113 Self::wait_for(|event| matches!(event, AgentEvent::Turn(TurnEvent::Ended { .. })))
114 }
115
116 pub fn wait_for_compaction_start() -> Self {
117 Self::wait_for(|event| matches!(event, AgentEvent::Context(ContextEvent::CompactionStarted { .. })))
118 }
119
120 pub fn wait_for_retry(attempt: u32) -> Self {
121 Self::wait_for(
122 move |event| matches!(event, AgentEvent::Turn(TurnEvent::RetryScheduled { attempt: actual, .. }) if *actual == attempt),
123 )
124 }
125}
126
127#[derive(Default)]
129pub struct TestScenario {
130 steps: Vec<TestAgentStep>,
131}
132
133impl TestScenario {
134 pub fn new() -> Self {
135 Self::default()
136 }
137
138 pub fn send(mut self, command: Command) -> Self {
139 self.steps.push(TestAgentStep::send(command));
140 self
141 }
142
143 pub fn user_text(mut self, text: impl Into<String>) -> Self {
144 self.steps.push(TestAgentStep::user_text(text));
145 self
146 }
147
148 pub fn cancel(mut self) -> Self {
149 self.steps.push(TestAgentStep::cancel());
150 self
151 }
152
153 pub fn switch_model(mut self, provider: impl StreamingModelProvider + 'static) -> Self {
154 self.steps.push(TestAgentStep::switch_model(provider));
155 self
156 }
157
158 pub fn replace_conversation(mut self, messages: Vec<ChatMessage>) -> Self {
159 self.steps.push(TestAgentStep::replace_conversation(messages));
160 self
161 }
162
163 pub fn wait_for(mut self, predicate: impl Fn(&AgentEvent) -> bool + Send + 'static) -> Self {
164 self.steps.push(TestAgentStep::wait_for(predicate));
165 self
166 }
167
168 pub fn wait_for_turn_end(mut self) -> Self {
169 self.steps.push(TestAgentStep::wait_for_turn_end());
170 self
171 }
172
173 pub fn wait_for_compaction_start(mut self) -> Self {
174 self.steps.push(TestAgentStep::wait_for_compaction_start());
175 self
176 }
177
178 pub fn wait_for_retry(mut self, attempt: u32) -> Self {
179 self.steps.push(TestAgentStep::wait_for_retry(attempt));
180 self
181 }
182
183 pub fn perform(mut self, action: impl FnOnce() + Send + 'static) -> Self {
186 self.steps.push(TestAgentStep::perform(action));
187 self
188 }
189}
190
191impl From<Vec<TestAgentStep>> for TestScenario {
192 fn from(steps: Vec<TestAgentStep>) -> Self {
193 Self { steps }
194 }
195}
196
197pub struct TestAgentResult {
199 pub messages: Vec<AgentEvent>,
200 pub captured_contexts: Arc<Mutex<Vec<Context>>>,
201 pub elicitations: Vec<CapturedElicitation>,
202}
203
204#[derive(Debug, thiserror::Error)]
207pub enum TestAgentError {
208 #[error(transparent)]
209 Agent(#[from] AgentError),
210 #[error("failed to send command to the test agent: {0}")]
211 SendCommand(#[from] mpsc::error::SendError<Command>),
212}
213
214pub type TestResult<T> = std::result::Result<T, TestAgentError>;
215
216struct ProviderTestConfig {
217 responses: Vec<Vec<Result<LlmResponse, LlmError>>>,
218 model: Option<LlmModel>,
219 context_window: Option<u32>,
220 pause: Option<(usize, usize, Arc<Notify>)>,
221}
222
223struct AgentTestConfig {
224 context_window_override: Option<u32>,
225 timeout: Option<Duration>,
226 max_auto_continues: Option<u32>,
227 retry_config: Option<RetryConfig>,
228 observers: Vec<Box<dyn AgentObserver>>,
229 mcp_servers: Option<Vec<ServerSpec>>,
231 elicitations: Option<ElicitationScriptBuilder>,
232 initial_messages: Vec<ChatMessage>,
233 system_prompts: Vec<Prompt>,
234 session_affinity_key: Option<String>,
235 compaction: Option<CompactionConfig>,
236 model_settings: Option<ModelSettings>,
237}
238
239enum TestExecution {
240 CommandsUntilTurnEnd(Vec<Command>),
241 Scenario(TestScenario),
242}
243
244pub struct TestAgentBuilder {
245 provider: ProviderTestConfig,
246 agent: AgentTestConfig,
247 execution: Option<TestExecution>,
248}
249
250impl Default for TestAgentBuilder {
251 fn default() -> Self {
252 Self::new()
253 }
254}
255
256impl TestAgentBuilder {
257 pub fn new() -> Self {
258 Self {
259 provider: ProviderTestConfig { responses: Vec::new(), model: None, context_window: None, pause: None },
260 agent: AgentTestConfig {
261 context_window_override: None,
262 timeout: None,
263 max_auto_continues: None,
264 retry_config: None,
265 observers: Vec::new(),
266 mcp_servers: None,
267 elicitations: None,
268 initial_messages: Vec::new(),
269 system_prompts: Vec::new(),
270 session_affinity_key: None,
271 compaction: None,
272 model_settings: None,
273 },
274 execution: None,
275 }
276 }
277
278 pub fn commands(self, commands: Vec<Command>) -> Self {
279 self.with_execution(TestExecution::CommandsUntilTurnEnd(commands))
280 }
281
282 pub fn scenario(self, scenario: impl Into<TestScenario>) -> Self {
283 self.with_execution(TestExecution::Scenario(scenario.into()))
284 }
285
286 pub fn user_text(self, text: &str) -> Self {
287 self.commands(vec![Command::text(text)])
288 }
289
290 pub fn llm_responses(mut self, llm_responses: &[Vec<LlmResponse>]) -> Self {
291 self.provider.responses = llm_responses.iter().map(|turn| turn.iter().cloned().map(Ok).collect()).collect();
292 self
293 }
294
295 pub fn llm_result_responses(mut self, llm_responses: &[Vec<Result<LlmResponse, LlmError>>]) -> Self {
296 self.provider.responses = Vec::from(llm_responses);
297 self
298 }
299
300 pub fn model(mut self, model: LlmModel) -> Self {
301 self.provider.model = Some(model);
302 self
303 }
304
305 pub fn provider_context_window(mut self, window: Option<u32>) -> Self {
306 self.provider.context_window = window;
307 self
308 }
309
310 pub fn context_window_override(mut self, window: u32) -> Self {
311 self.agent.context_window_override = Some(window);
312 self
313 }
314
315 pub fn tool_timeout(mut self, timeout: Duration) -> Self {
316 self.agent.timeout = Some(timeout);
317 self
318 }
319
320 pub fn max_auto_continues(mut self, max: u32) -> Self {
321 self.agent.max_auto_continues = Some(max);
322 self
323 }
324
325 pub fn retry_config(mut self, config: RetryConfig) -> Self {
326 self.agent.retry_config = Some(config);
327 self
328 }
329
330 pub fn without_mcp(mut self) -> Self {
332 self.agent.mcp_servers = Some(Vec::new());
333 self
334 }
335
336 pub fn mcp_server(mut self, name: &str, server: impl Into<McpServer>) -> Self {
337 self.agent.mcp_servers.get_or_insert_with(Vec::new).push(ServerSpec::in_process(name, server));
338 self
339 }
340
341 pub fn elicitation_response(mut self, response: ElicitResult) -> Self {
342 self.agent.elicitations = Some(self.agent.elicitations.take().unwrap_or_default().response(response));
343 self
344 }
345
346 pub fn on_url_elicitation<F, Fut>(mut self, handler: F) -> Self
347 where
348 F: Fn(String, String) -> Fut + Send + Sync + 'static,
349 Fut: Future<Output = ()> + Send + 'static,
350 {
351 self.agent.elicitations = Some(self.agent.elicitations.take().unwrap_or_default().on_url(handler));
352 self
353 }
354
355 pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
357 self.agent.initial_messages = messages;
358 self
359 }
360
361 pub fn system_prompt(mut self, prompt: Prompt) -> Self {
364 self.agent.system_prompts.push(prompt);
365 self
366 }
367
368 pub fn session_affinity_key(mut self, key: impl Into<String>) -> Self {
371 self.agent.session_affinity_key = Some(key.into());
372 self
373 }
374
375 pub fn compaction_config(mut self, config: CompactionConfig) -> Self {
377 self.agent.compaction = Some(config);
378 self
379 }
380
381 pub fn model_settings(mut self, settings: ModelSettings) -> Self {
383 self.agent.model_settings = Some(settings);
384 self
385 }
386
387 pub fn pause_turn_after(mut self, turn_index: usize, chunk_index: usize, release: Arc<Notify>) -> Self {
390 self.provider.pause = Some((turn_index, chunk_index, release));
391 self
392 }
393
394 pub fn observer(mut self, observer: Box<dyn AgentObserver>) -> Self {
396 self.agent.observers.push(observer);
397 self
398 }
399
400 pub async fn run(self) -> TestResult<Vec<AgentEvent>> {
401 let result = self.run_with_context().await?;
402 Ok(result.messages)
403 }
404
405 pub async fn run_trace(self) -> TestResult<AgentTrace> {
408 let observer = FakeAgentObserver::new();
409 let events = observer.events();
410 self.observer(Box::new(observer)).run().await?;
411 Ok(AgentTrace::from_observer_events(&events))
412 }
413
414 pub async fn run_with_context(self) -> TestResult<TestAgentResult> {
419 let Self { provider, agent: config, execution } = self;
420 let mut llm = FakeLlmProvider::from_results(provider.responses).with_context_window(provider.context_window);
421 if let Some(model) = provider.model {
422 llm = llm.with_model(model);
423 }
424 if let Some((turn_index, chunk_index, release)) = provider.pause {
425 llm = llm.pause_turn_after(turn_index, chunk_index, release);
426 }
427 let captured_contexts = llm.captured_contexts();
428
429 let servers = config.mcp_servers.unwrap_or_else(|| vec![ServerSpec::in_process("test", FakeMcpServer::math())]);
430 let mut mcp = mcp("/workspace").with_servers(servers);
431 let mut elicitations = None;
432 if let Some(script) = config.elicitations {
433 let (sink, events) = mpsc::channel(32);
434 mcp = mcp.with_elicitation(ElicitationSink::form_and_url(sink));
435 elicitations = Some(script.spawn(events));
436 }
437 let mcp = ready_catalog(mcp).await;
438
439 let mut builder = agent(llm).mcp(mcp);
440 if let Some(timeout) = config.timeout {
441 builder = builder.tool_timeout(timeout);
442 }
443 if let Some(max) = config.max_auto_continues {
444 builder = builder.max_auto_continues(max);
445 }
446 if let Some(retry) = config.retry_config {
447 builder = builder.retry(retry);
448 } else {
449 builder = builder.retry(RetryConfig::disabled());
450 }
451 for prompt in config.system_prompts {
452 builder = builder.system_prompt(prompt);
453 }
454 if let Some(key) = config.session_affinity_key {
455 builder = builder.session_affinity_key(key);
456 }
457 if let Some(compaction) = config.compaction {
458 builder = builder.compaction(compaction);
459 }
460 if let Some(settings) = config.model_settings {
461 builder = builder.model_settings(settings);
462 }
463 builder = builder.context_window(config.context_window_override);
464 if !config.initial_messages.is_empty() {
465 builder = builder.messages(config.initial_messages);
466 }
467 for observer in config.observers {
468 builder = builder.observer(observer);
469 }
470
471 let steps = match execution.expect("test agent requires commands(), user_text(), or scenario()") {
472 TestExecution::CommandsUntilTurnEnd(commands) => {
473 assert!(!commands.is_empty(), "commands() requires at least one command");
474 let mut steps = commands.into_iter().map(TestAgentStep::send).collect::<Vec<_>>();
475 steps.push(TestAgentStep::wait_for_turn_end());
476 steps
477 }
478 TestExecution::Scenario(scenario) => {
479 assert!(!scenario.steps.is_empty(), "scenario() requires at least one step");
480 scenario.steps
481 }
482 };
483 let (tx, mut rx, handle) = builder.spawn().await?;
484 let mut messages = Vec::new();
485
486 for step in steps {
487 match step {
488 TestAgentStep::Send(command) => tx.send(command).await?,
489 TestAgentStep::WaitFor(predicate) => loop {
490 let message = rx.recv().await.expect("agent event channel closed before scenario step matched");
491 let matched = predicate(&message);
492 messages.push(message);
493 if matched {
494 break;
495 }
496 },
497 TestAgentStep::Perform(action) => action(),
498 }
499 }
500 drop(tx);
501
502 handle.await_completion().await;
503
504 let elicitations = elicitations.map(|script| script.captured()).unwrap_or_default();
505 Ok(TestAgentResult { messages, captured_contexts, elicitations })
506 }
507
508 fn with_execution(mut self, execution: TestExecution) -> Self {
509 assert!(self.execution.is_none(), "commands(), user_text(), and scenario() are mutually exclusive");
510 self.execution = Some(execution);
511 self
512 }
513}