aether_core/testing/
utils.rs1use crate::events::{ToolEvent, TurnEvent};
2use std::collections::BTreeMap;
3use std::error::Error;
4use std::sync::{Arc, Mutex};
5use std::time::Duration;
6use tokio::sync::mpsc;
7
8use futures::future::join_all;
9
10use crate::core::{RetryConfig, agent};
11use crate::events::{AgentEvent, AgentObserver, Command, UserCommand};
12use crate::mcp::mcp;
13use crate::testing::fake_mcp::fake_mcp;
14use crate::testing::{AgentTrace, FakeAgentObserver, FakeMcpServer};
15use llm::{Context, LlmError, LlmModel, LlmResponse};
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 struct TestAgentResult {
58 pub messages: Vec<AgentEvent>,
59 pub captured_contexts: Arc<Mutex<Vec<Context>>>,
60}
61
62type CancelPredicate = Box<dyn Fn(&AgentEvent) -> bool + Send>;
63
64pub struct TestAgentBuilder {
65 messages: Vec<Command>,
66 responses: Vec<Vec<Result<LlmResponse, LlmError>>>,
67 model: Option<LlmModel>,
68 context_window: Option<u32>,
69 timeout: Option<Duration>,
70 max_auto_continues: Option<u32>,
71 retry_config: Option<RetryConfig>,
72 observers: Vec<Box<dyn AgentObserver>>,
73 cancel_when: Option<CancelPredicate>,
74}
75
76impl Default for TestAgentBuilder {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl TestAgentBuilder {
83 pub fn new() -> Self {
84 Self {
85 messages: Vec::new(),
86 responses: Vec::new(),
87 model: None,
88 context_window: None,
89 timeout: None,
90 max_auto_continues: None,
91 retry_config: None,
92 observers: Vec::new(),
93 cancel_when: None,
94 }
95 }
96
97 pub fn user_messages(mut self, user_messages: Vec<Command>) -> Self {
98 self.messages = user_messages;
99 self
100 }
101
102 pub fn user_text(self, text: &str) -> Self {
103 self.user_messages(vec![Command::UserCommand(UserCommand::Text {
104 content: vec![llm::ContentBlock::text(text)],
105 })])
106 }
107
108 pub fn llm_responses(mut self, llm_responses: &[Vec<LlmResponse>]) -> Self {
109 self.responses = llm_responses.iter().map(|turn| turn.iter().cloned().map(Ok).collect()).collect();
110 self
111 }
112
113 pub fn llm_result_responses(mut self, llm_responses: &[Vec<Result<LlmResponse, LlmError>>]) -> Self {
114 self.responses = Vec::from(llm_responses);
115 self
116 }
117
118 pub fn model(mut self, model: LlmModel) -> Self {
119 self.model = Some(model);
120 self
121 }
122
123 pub fn context_window(mut self, window: u32) -> Self {
124 self.context_window = Some(window);
125 self
126 }
127
128 pub fn cancel_when(mut self, predicate: impl Fn(&AgentEvent) -> bool + Send + 'static) -> Self {
131 self.cancel_when = Some(Box::new(predicate));
132 self
133 }
134
135 pub fn tool_timeout(mut self, timeout: Duration) -> Self {
136 self.timeout = Some(timeout);
137 self
138 }
139
140 pub fn max_auto_continues(mut self, max: u32) -> Self {
141 self.max_auto_continues = Some(max);
142 self
143 }
144
145 pub fn retry_config(mut self, config: RetryConfig) -> Self {
146 self.retry_config = Some(config);
147 self
148 }
149
150 pub fn observer(mut self, observer: Box<dyn AgentObserver>) -> Self {
152 self.observers.push(observer);
153 self
154 }
155
156 pub async fn run(self) -> Result<Vec<AgentEvent>, Box<dyn Error>> {
157 let result = self.run_with_context().await?;
158 Ok(result.messages)
159 }
160
161 pub async fn run_trace(self) -> Result<AgentTrace, Box<dyn Error>> {
164 let observer = FakeAgentObserver::new();
165 let events = observer.events();
166 self.observer(Box::new(observer)).run().await?;
167 Ok(AgentTrace::from_observer_events(&events))
168 }
169
170 pub async fn run_with_context(self) -> Result<TestAgentResult, Box<dyn Error>> {
175 let mut llm = FakeLlmProvider::from_results(self.responses).with_context_window(self.context_window);
176 if let Some(model) = self.model {
177 llm = llm.with_model(model);
178 }
179 let captured_contexts = llm.captured_contexts();
180
181 let mut spawn = mcp("/workspace").with_servers(vec![fake_mcp("test", FakeMcpServer::new())]).spawn().await?;
182 let snapshot = spawn.block_until_ready().await.expect("bootstrap completes");
183
184 let mut builder = agent(llm).tools(spawn.command_tx, snapshot.tool_definitions);
185 if let Some(timeout) = self.timeout {
186 builder = builder.tool_timeout(timeout);
187 }
188 if let Some(max) = self.max_auto_continues {
189 builder = builder.max_auto_continues(max);
190 }
191 if let Some(retry) = self.retry_config {
192 builder = builder.retry(retry);
193 } else {
194 builder = builder.retry(RetryConfig::disabled());
195 }
196 for observer in self.observers {
197 builder = builder.observer(observer);
198 }
199
200 let (tx, mut rx, handle) = builder.spawn().await?;
201 let futures: Vec<_> = self.messages.into_iter().map(|m| tx.send(m)).collect();
202
203 join_all(futures).await;
204
205 let mut command_tx = if self.cancel_when.is_some() {
206 Some(tx)
207 } else {
208 drop(tx);
209 None
210 };
211 let mut messages = Vec::new();
212 while let Some(message) = rx.recv().await {
213 messages.push(message.clone());
214 if self.cancel_when.as_ref().is_some_and(|predicate| predicate(&message))
215 && let Some(tx) = command_tx.take()
216 {
217 tx.send(Command::UserCommand(UserCommand::Cancel)).await?;
218 }
219 if matches!(message, AgentEvent::Turn(TurnEvent::Ended { .. })) {
220 break;
221 }
222 }
223 drop(command_tx);
224
225 handle.await_completion().await;
226
227 Ok(TestAgentResult { messages, captured_contexts })
228 }
229}