Skip to main content

batty_cli/shim/
common.rs

1//! Shared utilities used by both PTY runtime and SDK runtime.
2
3use std::collections::VecDeque;
4
5use super::protocol::{Event, ShimState};
6
7// ---------------------------------------------------------------------------
8// Constants
9// ---------------------------------------------------------------------------
10
11/// Maximum number of messages that can be queued while the agent is working.
12pub const MAX_QUEUE_DEPTH: usize = 16;
13
14/// How often to report session stats (secs).
15pub const SESSION_STATS_INTERVAL_SECS: u64 = 10;
16
17/// Maximum number of automatic fix-and-retest loops before escalation.
18pub const TEST_FAILURE_MAX_ITERATIONS: u8 = 5;
19
20// ---------------------------------------------------------------------------
21// Message formatting
22// ---------------------------------------------------------------------------
23
24pub 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// ---------------------------------------------------------------------------
40// Queued message
41// ---------------------------------------------------------------------------
42
43#[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
58// ---------------------------------------------------------------------------
59// Queue drain helper
60// ---------------------------------------------------------------------------
61
62/// Drain all queued messages, emitting an Error event for each one.
63/// Used when the agent enters a terminal state (Dead, ContextExhausted).
64pub 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
84// ---------------------------------------------------------------------------
85// Context exhaustion detection
86// ---------------------------------------------------------------------------
87
88const 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
99/// Check if text contains known context exhaustion phrases (hard failure).
100pub fn detect_context_exhausted(text: &str) -> bool {
101    let lower = text.to_lowercase();
102    EXHAUSTION_PATTERNS.iter().any(|p| lower.contains(p))
103}
104
105// ---------------------------------------------------------------------------
106// Context approaching-limit detection (early warning)
107// ---------------------------------------------------------------------------
108
109const 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
120/// Check if text contains signals that the context window is under pressure
121/// but not yet fully exhausted. Returns true for early-warning patterns
122/// (e.g. automatic compression notifications from Claude).
123pub 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
152/// Check if text looks like a real test runner failure instead of a prose mention.
153pub 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// ---------------------------------------------------------------------------
213// Tests
214// ---------------------------------------------------------------------------
215
216#[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}