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