Skip to main content

ralph_core/
event_parser.rs

1//! Event parsing from CLI output.
2//!
3//! Parses XML-style event tags from agent output:
4//! ```text
5//! <event topic="impl.done">payload</event>
6//! <event topic="handoff" target="reviewer">payload</event>
7//! ```
8
9use ralph_proto::{Event, HatId};
10
11/// Strips ANSI escape sequences from a string.
12///
13/// Handles CSI sequences (\x1b[...m), OSC sequences (\x1b]...\x07),
14/// and simple escape sequences (\x1b followed by a single char).
15fn strip_ansi(s: &str) -> String {
16    let bytes = s.as_bytes();
17    let mut result = Vec::with_capacity(bytes.len());
18    let mut i = 0;
19
20    while i < bytes.len() {
21        if bytes[i] == 0x1b {
22            // ESC character - start of escape sequence
23            i += 1;
24            if i >= bytes.len() {
25                break;
26            }
27
28            match bytes[i] {
29                b'[' => {
30                    // CSI sequence: ESC [ ... (final byte in 0x40-0x7E range)
31                    i += 1;
32                    while i < bytes.len() && !(0x40..=0x7E).contains(&bytes[i]) {
33                        i += 1;
34                    }
35                    if i < bytes.len() {
36                        i += 1; // Skip final byte
37                    }
38                }
39                b']' => {
40                    // OSC sequence: ESC ] ... (terminated by BEL or ST)
41                    i += 1;
42                    while i < bytes.len() {
43                        if bytes[i] == 0x07 {
44                            i += 1;
45                            break;
46                        }
47                        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
48                            i += 2;
49                            break;
50                        }
51                        i += 1;
52                    }
53                }
54                _ => {
55                    // Simple escape sequence: ESC + single char
56                    i += 1;
57                }
58            }
59        } else {
60            result.push(bytes[i]);
61            i += 1;
62        }
63    }
64
65    String::from_utf8_lossy(&result).into_owned()
66}
67
68/// Evidence of backpressure checks for build.done events.
69#[derive(Debug, Clone, PartialEq)]
70pub struct BackpressureEvidence {
71    pub tests_passed: bool,
72    pub lint_passed: bool,
73    pub typecheck_passed: bool,
74}
75
76impl BackpressureEvidence {
77    /// Returns true if all checks passed.
78    pub fn all_passed(&self) -> bool {
79        self.tests_passed && self.lint_passed && self.typecheck_passed
80    }
81}
82
83/// Parser for extracting events from CLI output.
84#[derive(Debug, Default)]
85pub struct EventParser {
86    /// The source hat ID to attach to parsed events.
87    source: Option<HatId>,
88}
89
90impl EventParser {
91    /// Creates a new event parser.
92    pub fn new() -> Self {
93        Self::default()
94    }
95
96    /// Sets the source hat for parsed events.
97    pub fn with_source(mut self, source: impl Into<HatId>) -> Self {
98        self.source = Some(source.into());
99        self
100    }
101
102    /// Parses events from CLI output text.
103    ///
104    /// Returns a list of parsed events.
105    pub fn parse(&self, output: &str) -> Vec<Event> {
106        let mut events = Vec::new();
107        let mut remaining = output;
108
109        while let Some(start_idx) = remaining.find("<event ") {
110            let after_start = &remaining[start_idx..];
111
112            // Find the end of the opening tag
113            let Some(tag_end) = after_start.find('>') else {
114                remaining = &remaining[start_idx + 7..];
115                continue;
116            };
117
118            let opening_tag = &after_start[..tag_end + 1];
119
120            // Parse attributes from opening tag
121            let topic = Self::extract_attr(opening_tag, "topic");
122            let target = Self::extract_attr(opening_tag, "target");
123
124            let Some(topic) = topic else {
125                remaining = &remaining[start_idx + tag_end + 1..];
126                continue;
127            };
128
129            // Find the closing tag
130            let content_start = &after_start[tag_end + 1..];
131            let Some(close_idx) = content_start.find("</event>") else {
132                remaining = &remaining[start_idx + tag_end + 1..];
133                continue;
134            };
135
136            let payload = content_start[..close_idx].trim().to_string();
137
138            let mut event = Event::new(topic, payload);
139
140            if let Some(source) = &self.source {
141                event = event.with_source(source.clone());
142            }
143
144            if let Some(target) = target {
145                event = event.with_target(target);
146            }
147
148            events.push(event);
149
150            // Move past this event
151            let total_consumed = start_idx + tag_end + 1 + close_idx + 8; // 8 = "</event>".len()
152            remaining = &remaining[total_consumed..];
153        }
154
155        events
156    }
157
158    /// Extracts an attribute value from an XML-like tag.
159    fn extract_attr(tag: &str, attr: &str) -> Option<String> {
160        let pattern = format!("{attr}=\"");
161        let start = tag.find(&pattern)?;
162        let value_start = start + pattern.len();
163        let rest = &tag[value_start..];
164        let end = rest.find('"')?;
165        Some(rest[..end].to_string())
166    }
167
168    /// Parses backpressure evidence from build.done event payload.
169    ///
170    /// Expected format:
171    /// ```text
172    /// tests: pass
173    /// lint: pass
174    /// typecheck: pass
175    /// ```
176    ///
177    /// Note: ANSI escape codes are stripped before parsing to handle
178    /// colorized CLI output.
179    pub fn parse_backpressure_evidence(payload: &str) -> Option<BackpressureEvidence> {
180        // Strip ANSI codes before checking for evidence strings
181        let clean_payload = strip_ansi(payload);
182
183        let tests_passed = clean_payload.contains("tests: pass");
184        let lint_passed = clean_payload.contains("lint: pass");
185        let typecheck_passed = clean_payload.contains("typecheck: pass");
186
187        // Only return evidence if at least one check is mentioned
188        if clean_payload.contains("tests:")
189            || clean_payload.contains("lint:")
190            || clean_payload.contains("typecheck:")
191        {
192            Some(BackpressureEvidence {
193                tests_passed,
194                lint_passed,
195                typecheck_passed,
196            })
197        } else {
198            None
199        }
200    }
201
202    /// Checks if output contains the completion promise.
203    ///
204    /// Per spec: The promise must appear in the agent's final output,
205    /// not inside an `<event>` tag payload. This function:
206    /// 1. Returns false if the promise appears inside ANY event tag
207    ///    (prevents accidental completion when agents discuss the promise)
208    /// 2. Otherwise, checks for the promise in the stripped output
209    pub fn contains_promise(output: &str, promise: &str) -> bool {
210        // Safety check: if promise appears inside any event tag, never complete
211        if Self::promise_in_event_tags(output, promise) {
212            return false;
213        }
214        let stripped = Self::strip_event_tags(output);
215        stripped.contains(promise)
216    }
217
218    /// Checks if the promise appears inside any event tag payload.
219    pub fn promise_in_event_tags(output: &str, promise: &str) -> bool {
220        let mut remaining = output;
221
222        while let Some(start_idx) = remaining.find("<event ") {
223            let after_start = &remaining[start_idx..];
224
225            // Find the end of the opening tag
226            let Some(tag_end) = after_start.find('>') else {
227                remaining = &remaining[start_idx + 7..];
228                continue;
229            };
230
231            // Find the closing tag
232            let content_start = &after_start[tag_end + 1..];
233            let Some(close_idx) = content_start.find("</event>") else {
234                remaining = &remaining[start_idx + tag_end + 1..];
235                continue;
236            };
237
238            let payload = &content_start[..close_idx];
239            if payload.contains(promise) {
240                return true;
241            }
242
243            // Move past this event
244            let total_consumed = start_idx + tag_end + 1 + close_idx + 8;
245            remaining = &remaining[total_consumed..];
246        }
247
248        false
249    }
250
251    /// Strips all `<event ...>...</event>` blocks from output.
252    ///
253    /// Returns the output with event tags removed, leaving only
254    /// the "final output" text that should be checked for promises.
255    fn strip_event_tags(output: &str) -> String {
256        let mut result = String::with_capacity(output.len());
257        let mut remaining = output;
258
259        while let Some(start_idx) = remaining.find("<event ") {
260            // Add everything before this event tag
261            result.push_str(&remaining[..start_idx]);
262
263            let after_start = &remaining[start_idx..];
264
265            // Find the closing tag
266            if let Some(close_idx) = after_start.find("</event>") {
267                // Skip past the entire event block
268                remaining = &after_start[close_idx + 8..]; // 8 = "</event>".len()
269            } else {
270                // Malformed: no closing tag, keep the rest and stop
271                result.push_str(after_start);
272                remaining = "";
273                break;
274            }
275        }
276
277        // Add any remaining content after the last event
278        result.push_str(remaining);
279        result
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_parse_single_event() {
289        let output = r#"
290Some preamble text.
291<event topic="impl.done">
292Implemented the authentication module.
293</event>
294Some trailing text.
295"#;
296        let parser = EventParser::new();
297        let events = parser.parse(output);
298
299        assert_eq!(events.len(), 1);
300        assert_eq!(events[0].topic.as_str(), "impl.done");
301        assert!(events[0].payload.contains("authentication module"));
302    }
303
304    #[test]
305    fn test_parse_event_with_target() {
306        let output = r#"<event topic="handoff" target="reviewer">Please review</event>"#;
307        let parser = EventParser::new();
308        let events = parser.parse(output);
309
310        assert_eq!(events.len(), 1);
311        assert_eq!(events[0].target.as_ref().unwrap().as_str(), "reviewer");
312    }
313
314    #[test]
315    fn test_parse_multiple_events() {
316        let output = r#"
317<event topic="impl.started">Starting work</event>
318Working on implementation...
319<event topic="impl.done">Finished</event>
320"#;
321        let parser = EventParser::new();
322        let events = parser.parse(output);
323
324        assert_eq!(events.len(), 2);
325        assert_eq!(events[0].topic.as_str(), "impl.started");
326        assert_eq!(events[1].topic.as_str(), "impl.done");
327    }
328
329    #[test]
330    fn test_parse_with_source() {
331        let output = r#"<event topic="impl.done">Done</event>"#;
332        let parser = EventParser::new().with_source("implementer");
333        let events = parser.parse(output);
334
335        assert_eq!(events[0].source.as_ref().unwrap().as_str(), "implementer");
336    }
337
338    #[test]
339    fn test_no_events() {
340        let output = "Just regular output with no events.";
341        let parser = EventParser::new();
342        let events = parser.parse(output);
343
344        assert!(events.is_empty());
345    }
346
347    #[test]
348    fn test_contains_promise() {
349        assert!(EventParser::contains_promise(
350            "LOOP_COMPLETE",
351            "LOOP_COMPLETE"
352        ));
353        assert!(EventParser::contains_promise(
354            "prefix LOOP_COMPLETE suffix",
355            "LOOP_COMPLETE"
356        ));
357        assert!(!EventParser::contains_promise(
358            "No promise here",
359            "LOOP_COMPLETE"
360        ));
361    }
362
363    #[test]
364    fn test_contains_promise_ignores_event_payloads() {
365        // Promise inside event payload should NOT be detected
366        let output = r#"<event topic="build.task">Fix LOOP_COMPLETE detection</event>"#;
367        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
368
369        // Promise inside event with acceptance criteria mentioning LOOP_COMPLETE
370        let output = r#"<event topic="build.task">
371## Task: Fix completion promise detection
372- Given LOOP_COMPLETE appears inside an event tag
373- Then it should be ignored
374</event>"#;
375        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
376    }
377
378    #[test]
379    fn test_contains_promise_detects_outside_events() {
380        // Promise outside event tags should be detected
381        let output = r#"<event topic="build.done">Task complete</event>
382All done! LOOP_COMPLETE"#;
383        assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
384
385        // Promise before event tags
386        let output = r#"LOOP_COMPLETE
387<event topic="summary">Final summary</event>"#;
388        assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
389    }
390
391    #[test]
392    fn test_contains_promise_mixed_content() {
393        // Promise only in event payload, not in surrounding text
394        let output = r#"Working on task...
395<event topic="build.task">Fix LOOP_COMPLETE bug</event>
396Still working..."#;
397        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
398
399        // Promise in both event and surrounding text - should NOT complete
400        // because promise appears inside an event tag (safety mechanism)
401        let output = r#"All tasks done. LOOP_COMPLETE
402<event topic="summary">Completed LOOP_COMPLETE task</event>"#;
403        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
404    }
405
406    #[test]
407    fn test_promise_in_event_tags() {
408        // Promise inside event payload
409        let output = r#"<event topic="build.task">Fix LOOP_COMPLETE bug</event>"#;
410        assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
411
412        // Promise not in any event
413        let output = r#"<event topic="build.done">Task complete</event>"#;
414        assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
415
416        // No events at all
417        let output = "Just regular text with LOOP_COMPLETE";
418        assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
419
420        // Multiple events, promise in second
421        let output = r#"<event topic="a">first</event>
422<event topic="b">contains LOOP_COMPLETE</event>"#;
423        assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
424    }
425
426    #[test]
427    fn test_strip_event_tags() {
428        // Single event
429        let output = r#"before <event topic="test">payload</event> after"#;
430        let stripped = EventParser::strip_event_tags(output);
431        assert_eq!(stripped, "before  after");
432        assert!(!stripped.contains("payload"));
433
434        // Multiple events
435        let output =
436            r#"start <event topic="a">one</event> middle <event topic="b">two</event> end"#;
437        let stripped = EventParser::strip_event_tags(output);
438        assert_eq!(stripped, "start  middle  end");
439
440        // No events
441        let output = "just plain text";
442        let stripped = EventParser::strip_event_tags(output);
443        assert_eq!(stripped, "just plain text");
444    }
445
446    #[test]
447    fn test_parse_backpressure_evidence_all_pass() {
448        let payload = "tests: pass\nlint: pass\ntypecheck: pass";
449        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
450        assert!(evidence.tests_passed);
451        assert!(evidence.lint_passed);
452        assert!(evidence.typecheck_passed);
453        assert!(evidence.all_passed());
454    }
455
456    #[test]
457    fn test_parse_backpressure_evidence_some_fail() {
458        let payload = "tests: pass\nlint: fail\ntypecheck: pass";
459        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
460        assert!(evidence.tests_passed);
461        assert!(!evidence.lint_passed);
462        assert!(evidence.typecheck_passed);
463        assert!(!evidence.all_passed());
464    }
465
466    #[test]
467    fn test_parse_backpressure_evidence_missing() {
468        let payload = "Task completed successfully";
469        let evidence = EventParser::parse_backpressure_evidence(payload);
470        assert!(evidence.is_none());
471    }
472
473    #[test]
474    fn test_parse_backpressure_evidence_partial() {
475        let payload = "tests: pass\nSome other text";
476        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
477        assert!(evidence.tests_passed);
478        assert!(!evidence.lint_passed);
479        assert!(!evidence.typecheck_passed);
480        assert!(!evidence.all_passed());
481    }
482
483    #[test]
484    fn test_parse_backpressure_evidence_with_ansi_codes() {
485        let payload = "\x1b[0mtests: pass\x1b[0m\n\x1b[32mlint: pass\x1b[0m\ntypecheck: pass";
486        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
487        assert!(evidence.tests_passed);
488        assert!(evidence.lint_passed);
489        assert!(evidence.typecheck_passed);
490        assert!(evidence.all_passed());
491    }
492
493    #[test]
494    fn test_strip_ansi_function() {
495        // Test the internal strip_ansi function via parse_backpressure_evidence
496        // Simple CSI reset sequence
497        let payload = "\x1b[0mtests: pass\x1b[0m";
498        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
499        assert!(evidence.tests_passed);
500
501        // Bold green text
502        let payload = "\x1b[1m\x1b[32mtests: pass\x1b[0m";
503        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
504        assert!(evidence.tests_passed);
505
506        // Multiple sequences mixed with content
507        let payload = "\x1b[31mtests: fail\x1b[0m\n\x1b[32mlint: pass\x1b[0m";
508        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
509        assert!(!evidence.tests_passed); // "tests: fail" not "tests: pass"
510        assert!(evidence.lint_passed);
511    }
512}