1use std::collections::BTreeMap;
2use std::error::Error;
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5use tokio::sync::{Notify, mpsc};
6
7use crate::context::CompactionConfig;
8use crate::core::{Prompt, RetryConfig, agent};
9use crate::events::{
10 AgentCommand, AgentEvent, AgentObserver, Command, ContextEvent, ToolEvent, TurnEvent, UserCommand,
11};
12use crate::mcp::mcp;
13use crate::testing::fake_mcp::fake_mcp;
14use crate::testing::{AgentTrace, FakeAgentObserver, FakeMcpServer};
15use llm::{ChatMessage, Context, LlmError, LlmModel, LlmResponse, ModelSettings, StreamingModelProvider};
16
17use llm::testing::FakeLlmProvider;
18
19pub async fn drain_until(
20 receiver: &mut mpsc::Receiver<AgentEvent>,
21 predicate: impl Fn(&AgentEvent) -> bool,
22) -> Vec<AgentEvent> {
23 let mut events = Vec::new();
24 while let Some(event) = receiver.recv().await {
25 let matched = predicate(&event);
26 events.push(event);
27 if matched {
28 return events;
29 }
30 }
31 panic!("agent event channel closed before predicate matched");
32}
33
34pub fn content_events(events: Vec<AgentEvent>) -> Vec<AgentEvent> {
35 events
36 .into_iter()
37 .filter(|event| {
38 !matches!(
39 event,
40 AgentEvent::Turn(
41 TurnEvent::Started { .. } | TurnEvent::LlmCallStarted { .. } | TurnEvent::LlmCallEnded { .. }
42 ) | AgentEvent::Tool(ToolEvent::ExecutionStarted { .. } | ToolEvent::DefinitionsUpdated { .. })
43 )
44 })
45 .collect()
46}
47
48pub fn mcp_instructions(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
49 entries.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect()
50}
51
52pub fn test_agent() -> TestAgentBuilder {
53 TestAgentBuilder::new()
54}
55
56pub enum TestAgentStep {
58 Send(Command),
59 WaitFor(Box<dyn Fn(&AgentEvent) -> bool + Send>),
60 Perform(Box<dyn FnOnce() + Send>),
62}
63
64impl TestAgentStep {
65 pub fn send(command: Command) -> Self {
66 Self::Send(command)
67 }
68
69 pub fn user_text(text: impl Into<String>) -> Self {
70 Self::send(Command::UserCommand(UserCommand::Text { content: vec![llm::ContentBlock::text(text.into())] }))
71 }
72
73 pub fn cancel() -> Self {
74 Self::send(Command::UserCommand(UserCommand::Cancel))
75 }
76
77 pub fn switch_model(provider: impl StreamingModelProvider + 'static) -> Self {
78 Self::send(Command::AgentCommand(AgentCommand::SwitchModel(Box::new(provider))))
79 }
80
81 pub fn replace_conversation(messages: Vec<ChatMessage>) -> Self {
82 Self::send(Command::AgentCommand(AgentCommand::ReplaceConversation(messages)))
83 }
84
85 pub fn perform(action: impl FnOnce() + Send + 'static) -> Self {
86 Self::Perform(Box::new(action))
87 }
88
89 pub fn wait_for(predicate: impl Fn(&AgentEvent) -> bool + Send + 'static) -> Self {
90 Self::WaitFor(Box::new(predicate))
91 }
92
93 pub fn wait_for_turn_end() -> Self {
94 Self::wait_for(|event| matches!(event, AgentEvent::Turn(TurnEvent::Ended { .. })))
95 }
96
97 pub fn wait_for_compaction_start() -> Self {
98 Self::wait_for(|event| matches!(event, AgentEvent::Context(ContextEvent::CompactionStarted { .. })))
99 }
100
101 pub fn wait_for_retry(attempt: u32) -> Self {
102 Self::wait_for(
103 move |event| matches!(event, AgentEvent::Turn(TurnEvent::RetryScheduled { attempt: actual, .. }) if *actual == attempt),
104 )
105 }
106}
107
108#[derive(Default)]
110pub struct TestScenario {
111 steps: Vec<TestAgentStep>,
112}
113
114impl TestScenario {
115 pub fn new() -> Self {
116 Self::default()
117 }
118
119 pub fn send(mut self, command: Command) -> Self {
120 self.steps.push(TestAgentStep::send(command));
121 self
122 }
123
124 pub fn user_text(mut self, text: impl Into<String>) -> Self {
125 self.steps.push(TestAgentStep::user_text(text));
126 self
127 }
128
129 pub fn cancel(mut self) -> Self {
130 self.steps.push(TestAgentStep::cancel());
131 self
132 }
133
134 pub fn switch_model(mut self, provider: impl StreamingModelProvider + 'static) -> Self {
135 self.steps.push(TestAgentStep::switch_model(provider));
136 self
137 }
138
139 pub fn replace_conversation(mut self, messages: Vec<ChatMessage>) -> Self {
140 self.steps.push(TestAgentStep::replace_conversation(messages));
141 self
142 }
143
144 pub fn wait_for(mut self, predicate: impl Fn(&AgentEvent) -> bool + Send + 'static) -> Self {
145 self.steps.push(TestAgentStep::wait_for(predicate));
146 self
147 }
148
149 pub fn wait_for_turn_end(mut self) -> Self {
150 self.steps.push(TestAgentStep::wait_for_turn_end());
151 self
152 }
153
154 pub fn wait_for_compaction_start(mut self) -> Self {
155 self.steps.push(TestAgentStep::wait_for_compaction_start());
156 self
157 }
158
159 pub fn wait_for_retry(mut self, attempt: u32) -> Self {
160 self.steps.push(TestAgentStep::wait_for_retry(attempt));
161 self
162 }
163
164 pub fn perform(mut self, action: impl FnOnce() + Send + 'static) -> Self {
167 self.steps.push(TestAgentStep::perform(action));
168 self
169 }
170}
171
172impl From<Vec<TestAgentStep>> for TestScenario {
173 fn from(steps: Vec<TestAgentStep>) -> Self {
174 Self { steps }
175 }
176}
177
178pub struct TestAgentResult {
180 pub messages: Vec<AgentEvent>,
181 pub captured_contexts: Arc<Mutex<Vec<Context>>>,
182}
183
184struct ProviderTestConfig {
185 responses: Vec<Vec<Result<LlmResponse, LlmError>>>,
186 model: Option<LlmModel>,
187 context_window: Option<u32>,
188 pause: Option<(usize, usize, Arc<Notify>)>,
189}
190
191struct AgentTestConfig {
192 context_window_override: Option<u32>,
193 timeout: Option<Duration>,
194 max_auto_continues: Option<u32>,
195 retry_config: Option<RetryConfig>,
196 observers: Vec<Box<dyn AgentObserver>>,
197 include_fake_mcp: bool,
198 initial_messages: Vec<ChatMessage>,
199 system_prompt: Option<Prompt>,
200 compaction: Option<CompactionConfig>,
201 model_settings: Option<ModelSettings>,
202}
203
204enum TestExecution {
205 CommandsUntilTurnEnd(Vec<Command>),
206 Scenario(TestScenario),
207}
208
209pub struct TestAgentBuilder {
210 provider: ProviderTestConfig,
211 agent: AgentTestConfig,
212 execution: Option<TestExecution>,
213}
214
215impl Default for TestAgentBuilder {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221impl TestAgentBuilder {
222 pub fn new() -> Self {
223 Self {
224 provider: ProviderTestConfig { responses: Vec::new(), model: None, context_window: None, pause: None },
225 agent: AgentTestConfig {
226 context_window_override: None,
227 timeout: None,
228 max_auto_continues: None,
229 retry_config: None,
230 observers: Vec::new(),
231 include_fake_mcp: true,
232 initial_messages: Vec::new(),
233 system_prompt: None,
234 compaction: None,
235 model_settings: None,
236 },
237 execution: None,
238 }
239 }
240
241 pub fn commands(self, commands: Vec<Command>) -> Self {
242 self.with_execution(TestExecution::CommandsUntilTurnEnd(commands))
243 }
244
245 pub fn scenario(self, scenario: impl Into<TestScenario>) -> Self {
246 self.with_execution(TestExecution::Scenario(scenario.into()))
247 }
248
249 pub fn user_text(self, text: &str) -> Self {
250 self.commands(vec![Command::UserCommand(UserCommand::Text { content: vec![llm::ContentBlock::text(text)] })])
251 }
252
253 pub fn llm_responses(mut self, llm_responses: &[Vec<LlmResponse>]) -> Self {
254 self.provider.responses = llm_responses.iter().map(|turn| turn.iter().cloned().map(Ok).collect()).collect();
255 self
256 }
257
258 pub fn llm_result_responses(mut self, llm_responses: &[Vec<Result<LlmResponse, LlmError>>]) -> Self {
259 self.provider.responses = Vec::from(llm_responses);
260 self
261 }
262
263 pub fn model(mut self, model: LlmModel) -> Self {
264 self.provider.model = Some(model);
265 self
266 }
267
268 pub fn provider_context_window(mut self, window: Option<u32>) -> Self {
269 self.provider.context_window = window;
270 self
271 }
272
273 pub fn context_window_override(mut self, window: u32) -> Self {
274 self.agent.context_window_override = Some(window);
275 self
276 }
277
278 pub fn tool_timeout(mut self, timeout: Duration) -> Self {
279 self.agent.timeout = Some(timeout);
280 self
281 }
282
283 pub fn max_auto_continues(mut self, max: u32) -> Self {
284 self.agent.max_auto_continues = Some(max);
285 self
286 }
287
288 pub fn retry_config(mut self, config: RetryConfig) -> Self {
289 self.agent.retry_config = Some(config);
290 self
291 }
292
293 pub fn without_mcp(mut self) -> Self {
295 self.agent.include_fake_mcp = false;
296 self
297 }
298
299 pub fn messages(mut self, messages: Vec<ChatMessage>) -> Self {
301 self.agent.initial_messages = messages;
302 self
303 }
304
305 pub fn system_prompt(mut self, prompt: Prompt) -> Self {
307 self.agent.system_prompt = Some(prompt);
308 self
309 }
310
311 pub fn compaction_config(mut self, config: CompactionConfig) -> Self {
313 self.agent.compaction = Some(config);
314 self
315 }
316
317 pub fn model_settings(mut self, settings: ModelSettings) -> Self {
319 self.agent.model_settings = Some(settings);
320 self
321 }
322
323 pub fn pause_turn_after(mut self, turn_index: usize, chunk_index: usize, release: Arc<Notify>) -> Self {
326 self.provider.pause = Some((turn_index, chunk_index, release));
327 self
328 }
329
330 pub fn observer(mut self, observer: Box<dyn AgentObserver>) -> Self {
332 self.agent.observers.push(observer);
333 self
334 }
335
336 pub async fn run(self) -> Result<Vec<AgentEvent>, Box<dyn Error>> {
337 let result = self.run_with_context().await?;
338 Ok(result.messages)
339 }
340
341 pub async fn run_trace(self) -> Result<AgentTrace, Box<dyn Error>> {
344 let observer = FakeAgentObserver::new();
345 let events = observer.events();
346 self.observer(Box::new(observer)).run().await?;
347 Ok(AgentTrace::from_observer_events(&events))
348 }
349
350 pub async fn run_with_context(self) -> Result<TestAgentResult, Box<dyn Error>> {
355 let Self { provider, agent: config, execution } = self;
356 let mut llm = FakeLlmProvider::from_results(provider.responses).with_context_window(provider.context_window);
357 if let Some(model) = provider.model {
358 llm = llm.with_model(model);
359 }
360 if let Some((turn_index, chunk_index, release)) = provider.pause {
361 llm = llm.pause_turn_after(turn_index, chunk_index, release);
362 }
363 let captured_contexts = llm.captured_contexts();
364
365 let mut mcp_spawn = if config.include_fake_mcp {
366 Some(mcp("/workspace").with_servers(vec![fake_mcp("test", FakeMcpServer::new())]).spawn().await?)
367 } else {
368 None
369 };
370
371 let mut builder = agent(llm);
372 if let Some(spawn) = &mut mcp_spawn {
373 let snapshot = spawn.block_until_ready().await.expect("bootstrap completes");
374 builder = builder.tools(spawn.command_tx.clone(), snapshot.tool_definitions);
375 }
376 if let Some(timeout) = config.timeout {
377 builder = builder.tool_timeout(timeout);
378 }
379 if let Some(max) = config.max_auto_continues {
380 builder = builder.max_auto_continues(max);
381 }
382 if let Some(retry) = config.retry_config {
383 builder = builder.retry(retry);
384 } else {
385 builder = builder.retry(RetryConfig::disabled());
386 }
387 if let Some(prompt) = config.system_prompt {
388 builder = builder.system_prompt(prompt);
389 }
390 if let Some(compaction) = config.compaction {
391 builder = builder.compaction(compaction);
392 }
393 if let Some(settings) = config.model_settings {
394 builder = builder.model_settings(settings);
395 }
396 builder = builder.context_window(config.context_window_override);
397 if !config.initial_messages.is_empty() {
398 builder = builder.messages(config.initial_messages);
399 }
400 for observer in config.observers {
401 builder = builder.observer(observer);
402 }
403
404 let steps = match execution.expect("test agent requires commands(), user_text(), or scenario()") {
405 TestExecution::CommandsUntilTurnEnd(commands) => {
406 assert!(!commands.is_empty(), "commands() requires at least one command");
407 let mut steps = commands.into_iter().map(TestAgentStep::send).collect::<Vec<_>>();
408 steps.push(TestAgentStep::wait_for_turn_end());
409 steps
410 }
411 TestExecution::Scenario(scenario) => {
412 assert!(!scenario.steps.is_empty(), "scenario() requires at least one step");
413 scenario.steps
414 }
415 };
416 let (tx, mut rx, handle) = builder.spawn().await?;
417 let mut messages = Vec::new();
418
419 for step in steps {
420 match step {
421 TestAgentStep::Send(command) => tx.send(command).await?,
422 TestAgentStep::WaitFor(predicate) => loop {
423 let message = rx.recv().await.expect("agent event channel closed before scenario step matched");
424 let matched = predicate(&message);
425 messages.push(message);
426 if matched {
427 break;
428 }
429 },
430 TestAgentStep::Perform(action) => action(),
431 }
432 }
433 drop(tx);
434
435 handle.await_completion().await;
436
437 Ok(TestAgentResult { messages, captured_contexts })
438 }
439
440 fn with_execution(mut self, execution: TestExecution) -> Self {
441 assert!(self.execution.is_none(), "commands(), user_text(), and scenario() are mutually exclusive");
442 self.execution = Some(execution);
443 self
444 }
445}