1use std::collections::VecDeque;
4
5use super::protocol::{Event, ShimState};
6
7pub const MAX_QUEUE_DEPTH: usize = 16;
13
14pub const SESSION_STATS_INTERVAL_SECS: u64 = 10;
16
17pub const TEST_FAILURE_MAX_ITERATIONS: u8 = 5;
19
20pub fn reply_target_for(sender: &str) -> &str {
25 sender
26}
27
28pub fn format_injected_message(sender: &str, body: &str) -> String {
29 let reply_to = reply_target_for(sender);
30 format!(
31 "--- Message from {sender} ---\n\
32 Reply-To: {reply_to}\n\
33 If you need to reply, use: batty send {reply_to} \"<your reply>\"\n\
34 \n\
35 {body}"
36 )
37}
38
39#[derive(Debug, Clone)]
44pub struct QueuedMessage {
45 pub from: String,
46 pub body: String,
47 pub message_id: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct TestFailureFollowup {
52 pub body: String,
53 pub notice: String,
54 pub next_iteration_count: u8,
55 pub escalate: bool,
56}
57
58pub fn drain_queue_errors(
65 queue: &mut VecDeque<QueuedMessage>,
66 terminal_state: ShimState,
67) -> Vec<Event> {
68 let mut events = Vec::new();
69 while let Some(msg) = queue.pop_front() {
70 events.push(Event::Error {
71 command: "SendMessage".into(),
72 reason: format!(
73 "agent entered {} state, queued message dropped{}",
74 terminal_state,
75 msg.message_id
76 .map(|id| format!(" (id: {id})"))
77 .unwrap_or_default(),
78 ),
79 });
80 }
81 events
82}
83
84const EXHAUSTION_PATTERNS: &[&str] = &[
89 "context window exceeded",
90 "context window is full",
91 "conversation is too long",
92 "maximum context length",
93 "context limit reached",
94 "truncated due to context limit",
95 "input exceeds the model",
96 "prompt is too long",
97];
98
99pub fn detect_context_exhausted(text: &str) -> bool {
101 let lower = text.to_lowercase();
102 EXHAUSTION_PATTERNS.iter().any(|p| lower.contains(p))
103}
104
105const CONTEXT_APPROACHING_PATTERNS: &[&str] = &[
110 "automatically compress prior messages",
111 "context window is approaching",
112 "approaching context limit",
113 "context is getting large",
114 "conversation history has been compressed",
115 "messages were compressed",
116 "running low on context",
117 "nearing the context limit",
118];
119
120pub fn detect_context_approaching_limit(text: &str) -> bool {
124 let lower = text.to_lowercase();
125 CONTEXT_APPROACHING_PATTERNS
126 .iter()
127 .any(|p| lower.contains(p))
128}
129
130const TEST_COMMAND_PATTERNS: &[&str] = &[
131 "cargo test",
132 "cargo nextest",
133 "pytest",
134 "npm test",
135 "pnpm test",
136 "yarn test",
137 "go test",
138 "bundle exec rspec",
139 "mix test",
140 "test result:",
141];
142
143const TEST_FAILURE_PATTERNS: &[&str] = &[
144 "test result: failed",
145 "error: test failed",
146 "failing tests:",
147 "failures:",
148 "failures (",
149 "test failed, to rerun pass",
150];
151
152pub fn detect_test_failure(text: &str) -> bool {
154 let lower = text.to_ascii_lowercase();
155 let has_test_context = TEST_COMMAND_PATTERNS
156 .iter()
157 .any(|pattern| lower.contains(pattern));
158 if !has_test_context {
159 return false;
160 }
161
162 TEST_FAILURE_PATTERNS
163 .iter()
164 .any(|pattern| lower.contains(pattern))
165 || lower
166 .lines()
167 .map(str::trim)
168 .any(|line| line.starts_with("test ") && line.ends_with("... failed"))
169}
170
171pub fn detect_test_failure_followup(
172 text: &str,
173 iteration_count: u8,
174) -> Option<TestFailureFollowup> {
175 if !detect_test_failure(text) {
176 return None;
177 }
178
179 if iteration_count < TEST_FAILURE_MAX_ITERATIONS {
180 let attempt = iteration_count + 1;
181 return Some(TestFailureFollowup {
182 body: format!(
183 "tests failed — fix and retest before reporting completion.\n\
184 Attempt {attempt}/{TEST_FAILURE_MAX_ITERATIONS}.\n\
185 Re-run cargo test after fixing the failures.\n\
186 Do not send a completion packet unless tests_passed=true."
187 ),
188 notice: format!(
189 "tests failed — fix and retest (attempt {attempt}/{TEST_FAILURE_MAX_ITERATIONS})"
190 ),
191 next_iteration_count: attempt,
192 escalate: false,
193 });
194 }
195
196 Some(TestFailureFollowup {
197 body: format!(
198 "tests failed — fix and retest loop exhausted after \
199 {TEST_FAILURE_MAX_ITERATIONS} attempts.\n\
200 Stop reporting completion, send a blocker or escalation with the failing \
201 test summary, and wait for direction."
202 ),
203 notice: format!(
204 "tests failed repeatedly — escalation required after \
205 {TEST_FAILURE_MAX_ITERATIONS} attempts"
206 ),
207 next_iteration_count: TEST_FAILURE_MAX_ITERATIONS,
208 escalate: true,
209 })
210}
211
212#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn format_message_includes_sender_and_body() {
222 let msg = format_injected_message("manager", "Fix the bug");
223 assert!(msg.contains("Message from manager"));
224 assert!(msg.contains("Reply-To: manager"));
225 assert!(msg.contains("Fix the bug"));
226 assert!(msg.contains("batty send manager"));
227 }
228
229 #[test]
230 fn reply_target_is_identity() {
231 assert_eq!(reply_target_for("architect"), "architect");
232 assert_eq!(reply_target_for("eng-1-1"), "eng-1-1");
233 }
234
235 #[test]
236 fn drain_queue_errors_empties_queue() {
237 let mut queue = VecDeque::new();
238 queue.push_back(QueuedMessage {
239 from: "mgr".into(),
240 body: "task 1".into(),
241 message_id: Some("id-1".into()),
242 });
243 queue.push_back(QueuedMessage {
244 from: "mgr".into(),
245 body: "task 2".into(),
246 message_id: None,
247 });
248 let events = drain_queue_errors(&mut queue, ShimState::Dead);
249 assert_eq!(events.len(), 2);
250 assert!(queue.is_empty());
251
252 match &events[0] {
253 Event::Error { reason, .. } => {
254 assert!(reason.contains("dead"));
255 assert!(reason.contains("id-1"));
256 }
257 _ => panic!("expected Error event"),
258 }
259 }
260
261 #[test]
262 fn drain_queue_empty_is_noop() {
263 let mut queue = VecDeque::new();
264 let events = drain_queue_errors(&mut queue, ShimState::Dead);
265 assert!(events.is_empty());
266 }
267
268 #[test]
269 fn context_exhaustion_detected() {
270 assert!(detect_context_exhausted("Error: context window exceeded"));
271 assert!(detect_context_exhausted(
272 "The CONVERSATION IS TOO LONG to continue"
273 ));
274 assert!(detect_context_exhausted("maximum context length reached"));
275 }
276
277 #[test]
278 fn context_exhaustion_not_detected_for_normal_text() {
279 assert!(!detect_context_exhausted("Writing function to parse YAML"));
280 assert!(!detect_context_exhausted("context manager initialized"));
281 }
282
283 #[test]
284 fn context_approaching_limit_detected() {
285 assert!(detect_context_approaching_limit(
286 "The system will automatically compress prior messages in your conversation"
287 ));
288 assert!(detect_context_approaching_limit(
289 "conversation history has been compressed to save space"
290 ));
291 assert!(detect_context_approaching_limit(
292 "Context window is approaching its maximum capacity"
293 ));
294 }
295
296 #[test]
297 fn context_approaching_not_detected_for_normal_text() {
298 assert!(!detect_context_approaching_limit(
299 "context manager initialized"
300 ));
301 assert!(!detect_context_approaching_limit(
302 "compression algorithm works"
303 ));
304 }
305
306 #[test]
307 fn test_failure_detected_for_cargo_test_output() {
308 let output = "running 2 tests\n\
309 test foo::bar ... FAILED\n\
310 failures:\n\
311 test result: FAILED. 1 passed; 1 failed; 0 ignored;";
312 assert!(detect_test_failure(output));
313 }
314
315 #[test]
316 fn test_failure_not_detected_for_prompt_text_only() {
317 assert!(!detect_test_failure(
318 "tests failed — fix and retest before reporting completion"
319 ));
320 }
321
322 #[test]
323 fn test_failure_followup_retries_before_escalating() {
324 let output = "cargo test\nfailures:\ntest result: FAILED.";
325 let followup = detect_test_failure_followup(output, 0).expect("followup");
326 assert!(!followup.escalate);
327 assert_eq!(followup.next_iteration_count, 1);
328 assert!(followup.notice.contains("attempt 1/5"));
329
330 let escalation =
331 detect_test_failure_followup(output, TEST_FAILURE_MAX_ITERATIONS).expect("escalation");
332 assert!(escalation.escalate);
333 assert_eq!(escalation.next_iteration_count, TEST_FAILURE_MAX_ITERATIONS);
334 assert!(escalation.notice.contains("escalation required"));
335 }
336}