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;
8use std::time::Duration;
9use tokio::sync::{Notify, mpsc};
10
11use crate::context::CompactionConfig;
12use crate::core::{AgentBuilder, 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::default()
73}
74
75#[derive(Default)]
77pub struct TestScenario {
78 steps: Vec<Step>,
79}
80
81impl TestScenario {
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub fn send(self, command: Command) -> Self {
87 self.step(Step::Send(command))
88 }
89
90 pub fn user_text(self, text: impl Into<String>) -> Self {
91 self.send(Command::text(text))
92 }
93
94 pub fn cancel(self) -> Self {
95 self.send(Command::UserCommand(UserCommand::Cancel))
96 }
97
98 pub fn switch_model(self, provider: impl StreamingModelProvider + 'static) -> Self {
99 self.send(Command::AgentCommand(AgentCommand::SwitchModel(Box::new(provider))))
100 }
101
102 pub fn replace_conversation(self, messages: Vec<ChatMessage>) -> Self {
103 self.send(Command::AgentCommand(AgentCommand::ReplaceConversation(messages)))
104 }
105
106 pub fn wait_for(self, predicate: impl Fn(&AgentEvent) -> bool + Send + 'static) -> Self {
107 self.step(Step::WaitFor(Box::new(predicate)))
108 }
109
110 pub fn wait_for_turn_end(self) -> Self {
111 self.wait_for(|event| matches!(event, AgentEvent::Turn(TurnEvent::Ended { .. })))
112 }
113
114 pub fn wait_for_compaction_start(self) -> Self {
115 self.wait_for(|event| matches!(event, AgentEvent::Context(ContextEvent::CompactionStarted { .. })))
116 }
117
118 pub fn wait_for_retry(self, attempt: u32) -> Self {
119 self.wait_for(
120 move |event| matches!(event, AgentEvent::Turn(TurnEvent::RetryScheduled { attempt: actual, .. }) if *actual == attempt),
121 )
122 }
123
124 pub fn perform(self, action: impl FnOnce() + Send + 'static) -> Self {
127 self.step(Step::Perform(Box::new(action)))
128 }
129
130 fn step(mut self, step: Step) -> Self {
131 self.steps.push(step);
132 self
133 }
134}
135
136pub struct TestAgentResult {
138 pub messages: Vec<AgentEvent>,
139 pub contexts: Vec<Context>,
140 pub elicitations: Vec<CapturedElicitation>,
141}
142
143#[derive(Debug, thiserror::Error)]
146pub enum TestAgentError {
147 #[error(transparent)]
148 Agent(#[from] AgentError),
149 #[error("failed to send command to the test agent: {0}")]
150 SendCommand(#[from] mpsc::error::SendError<Command>),
151}
152
153pub type TestResult<T> = std::result::Result<T, TestAgentError>;
154
155#[derive(Default)]
156pub struct TestAgentBuilder {
157 responses: Vec<Vec<Result<LlmResponse, LlmError>>>,
158 llm_config: Vec<Configure<FakeLlmProvider>>,
159 agent_config: Vec<Configure<AgentBuilder>>,
160 mcp_servers: Option<Vec<ServerSpec>>,
162 elicitations: Option<ElicitationScriptBuilder>,
163 scenario: Option<TestScenario>,
164}
165
166impl TestAgentBuilder {
167 pub fn commands(self, commands: Vec<Command>) -> Self {
168 assert!(!commands.is_empty(), "commands() requires at least one command");
169 let scenario = commands.into_iter().fold(TestScenario::new(), TestScenario::send);
170 self.scenario(scenario.wait_for_turn_end())
171 }
172
173 pub fn scenario(mut self, scenario: TestScenario) -> Self {
174 assert!(!scenario.steps.is_empty(), "scenario() requires at least one step");
175 assert!(self.scenario.is_none(), "commands(), user_text(), and scenario() are mutually exclusive");
176 self.scenario = Some(scenario);
177 self
178 }
179
180 pub fn user_text(self, text: &str) -> Self {
181 self.commands(vec![Command::text(text)])
182 }
183
184 pub fn llm_responses(mut self, llm_responses: &[Vec<LlmResponse>]) -> Self {
185 self.responses = llm_responses.iter().map(|turn| turn.iter().cloned().map(Ok).collect()).collect();
186 self
187 }
188
189 pub fn llm_result_responses(mut self, llm_responses: &[Vec<Result<LlmResponse, LlmError>>]) -> Self {
190 self.responses = Vec::from(llm_responses);
191 self
192 }
193
194 pub fn model(self, model: LlmModel) -> Self {
195 self.configure_llm(|llm| llm.with_model(model))
196 }
197
198 pub fn provider_context_window(self, window: Option<u32>) -> Self {
199 self.configure_llm(move |llm| llm.with_context_window(window))
200 }
201
202 pub fn context_window_override(self, window: u32) -> Self {
203 self.configure(move |agent| agent.context_window(Some(window)))
204 }
205
206 pub fn tool_timeout(self, timeout: Duration) -> Self {
207 self.configure(move |agent| agent.tool_timeout(timeout))
208 }
209
210 pub fn max_auto_continues(self, max: u32) -> Self {
211 self.configure(move |agent| agent.max_auto_continues(max))
212 }
213
214 pub fn retry_config(self, config: RetryConfig) -> Self {
215 self.configure(move |agent| agent.retry(config))
216 }
217
218 pub fn without_mcp(mut self) -> Self {
220 self.mcp_servers = Some(Vec::new());
221 self
222 }
223
224 pub fn mcp_server(mut self, name: &str, server: impl Into<McpServer>) -> Self {
225 self.mcp_servers.get_or_insert_with(Vec::new).push(ServerSpec::in_process(name, server));
226 self
227 }
228
229 pub fn elicitation_response(mut self, response: ElicitResult) -> Self {
230 self.elicitations = Some(self.elicitations.take().unwrap_or_default().response(response));
231 self
232 }
233
234 pub fn on_url_elicitation<F, Fut>(mut self, handler: F) -> Self
235 where
236 F: Fn(String, String) -> Fut + Send + Sync + 'static,
237 Fut: Future<Output = ()> + Send + 'static,
238 {
239 self.elicitations = Some(self.elicitations.take().unwrap_or_default().on_url(handler));
240 self
241 }
242
243 pub fn messages(self, messages: Vec<ChatMessage>) -> Self {
245 self.configure(|agent| agent.messages(messages))
246 }
247
248 pub fn system_prompt(self, prompt: Prompt) -> Self {
250 self.configure(|agent| agent.system_prompt(prompt))
251 }
252
253 pub fn session_affinity_key(self, key: impl Into<String>) -> Self {
256 let key = key.into();
257 self.configure(|agent| agent.session_affinity_key(key))
258 }
259
260 pub fn compaction_config(self, config: CompactionConfig) -> Self {
262 self.configure(|agent| agent.compaction(config))
263 }
264
265 pub fn model_settings(self, settings: ModelSettings) -> Self {
267 self.configure(|agent| agent.model_settings(settings))
268 }
269
270 pub fn pause_turn_after(self, turn_index: usize, chunk_index: usize, release: Arc<Notify>) -> Self {
273 self.configure_llm(move |llm| llm.pause_turn_after(turn_index, chunk_index, release))
274 }
275
276 pub fn observer(self, observer: Box<dyn AgentObserver>) -> Self {
278 self.configure(|agent| agent.observer(observer))
279 }
280
281 pub async fn run(self) -> TestResult<Vec<AgentEvent>> {
282 let result = self.run_with_context().await?;
283 Ok(result.messages)
284 }
285
286 pub async fn run_trace(self) -> TestResult<AgentTrace> {
289 let observer = FakeAgentObserver::new();
290 let events = observer.events();
291 self.observer(Box::new(observer)).run().await?;
292 Ok(AgentTrace::from_observer_events(&events))
293 }
294
295 pub async fn run_with_context(self) -> TestResult<TestAgentResult> {
300 let Self { responses, llm_config, agent_config, mcp_servers, elicitations, scenario } = self;
301 let scenario = scenario.expect("test agent requires commands(), user_text(), or scenario()");
302 let llm =
303 llm_config.into_iter().fold(FakeLlmProvider::from_results(responses), |llm, configure| configure(llm));
304 let captured_contexts = llm.captured_contexts();
305
306 let servers = mcp_servers.unwrap_or_else(|| vec![ServerSpec::in_process("test", FakeMcpServer::math())]);
307 let mut mcp = mcp("/workspace").with_servers(servers);
308 let mut script = None;
309 if let Some(elicitations) = elicitations {
310 let (sink, events) = mpsc::channel(32);
311 mcp = mcp.with_elicitation(ElicitationSink::form_and_url(sink));
312 script = Some(elicitations.spawn(events));
313 }
314
315 let builder = agent(llm).mcp(ready_catalog(mcp).await).retry(RetryConfig::disabled());
316 let builder = agent_config.into_iter().fold(builder, |builder, configure| configure(builder));
317 let (tx, mut rx, handle) = builder.spawn().await?;
318 let mut messages = Vec::new();
319
320 for step in scenario.steps {
321 match step {
322 Step::Send(command) => tx.send(command).await?,
323 Step::WaitFor(predicate) => loop {
324 let message = rx.recv().await.expect("agent event channel closed before scenario step matched");
325 let matched = predicate(&message);
326 messages.push(message);
327 if matched {
328 break;
329 }
330 },
331 Step::Perform(action) => action(),
332 }
333 }
334 drop(tx);
335
336 handle.await_completion().await;
337
338 let contexts = captured_contexts.lock().expect("captured contexts lock poisoned").clone();
339 let elicitations = script.map(|script| script.captured()).unwrap_or_default();
340 Ok(TestAgentResult { messages, contexts, elicitations })
341 }
342
343 fn configure(mut self, configure: impl FnOnce(AgentBuilder) -> AgentBuilder + Send + 'static) -> Self {
344 self.agent_config.push(Box::new(configure));
345 self
346 }
347
348 fn configure_llm(mut self, configure: impl FnOnce(FakeLlmProvider) -> FakeLlmProvider + Send + 'static) -> Self {
349 self.llm_config.push(Box::new(configure));
350 self
351 }
352}
353
354type Configure<T> = Box<dyn FnOnce(T) -> T + Send>;
355
356enum Step {
357 Send(Command),
358 WaitFor(Box<dyn Fn(&AgentEvent) -> bool + Send>),
359 Perform(Box<dyn FnOnce() + Send>),
360}