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/// Evidence of verification for review.done events.
84///
85/// Enforces that review hats actually ran verification commands rather
86/// than just asserting "looks good". At minimum, tests must have been run.
87#[derive(Debug, Clone, PartialEq)]
88pub struct ReviewEvidence {
89    pub tests_passed: bool,
90    pub build_passed: bool,
91}
92
93impl ReviewEvidence {
94    /// Returns true if the review has sufficient verification.
95    ///
96    /// Both tests and build must pass to constitute a verified review.
97    pub fn is_verified(&self) -> bool {
98        self.tests_passed && self.build_passed
99    }
100}
101
102/// Parser for extracting events from CLI output.
103#[derive(Debug, Default)]
104pub struct EventParser {
105    /// The source hat ID to attach to parsed events.
106    source: Option<HatId>,
107}
108
109impl EventParser {
110    /// Creates a new event parser.
111    pub fn new() -> Self {
112        Self::default()
113    }
114
115    /// Sets the source hat for parsed events.
116    pub fn with_source(mut self, source: impl Into<HatId>) -> Self {
117        self.source = Some(source.into());
118        self
119    }
120
121    /// Parses events from CLI output text.
122    ///
123    /// Returns a list of parsed events.
124    pub fn parse(&self, output: &str) -> Vec<Event> {
125        let mut events = Vec::new();
126        let mut remaining = output;
127
128        while let Some(start_idx) = remaining.find("<event ") {
129            let after_start = &remaining[start_idx..];
130
131            // Find the end of the opening tag
132            let Some(tag_end) = after_start.find('>') else {
133                remaining = &remaining[start_idx + 7..];
134                continue;
135            };
136
137            let opening_tag = &after_start[..tag_end + 1];
138
139            // Parse attributes from opening tag
140            let topic = Self::extract_attr(opening_tag, "topic");
141            let target = Self::extract_attr(opening_tag, "target");
142
143            let Some(topic) = topic else {
144                remaining = &remaining[start_idx + tag_end + 1..];
145                continue;
146            };
147
148            // Find the closing tag
149            let content_start = &after_start[tag_end + 1..];
150            let Some(close_idx) = content_start.find("</event>") else {
151                remaining = &remaining[start_idx + tag_end + 1..];
152                continue;
153            };
154
155            let payload = content_start[..close_idx].trim().to_string();
156
157            let mut event = Event::new(topic, payload);
158
159            if let Some(source) = &self.source {
160                event = event.with_source(source.clone());
161            }
162
163            if let Some(target) = target {
164                event = event.with_target(target);
165            }
166
167            events.push(event);
168
169            // Move past this event
170            let total_consumed = start_idx + tag_end + 1 + close_idx + 8; // 8 = "</event>".len()
171            remaining = &remaining[total_consumed..];
172        }
173
174        events
175    }
176
177    /// Extracts an attribute value from an XML-like tag.
178    fn extract_attr(tag: &str, attr: &str) -> Option<String> {
179        let pattern = format!("{attr}=\"");
180        let start = tag.find(&pattern)?;
181        let value_start = start + pattern.len();
182        let rest = &tag[value_start..];
183        let end = rest.find('"')?;
184        Some(rest[..end].to_string())
185    }
186
187    /// Parses backpressure evidence from build.done event payload.
188    ///
189    /// Expected format:
190    /// ```text
191    /// tests: pass
192    /// lint: pass
193    /// typecheck: pass
194    /// ```
195    ///
196    /// Note: ANSI escape codes are stripped before parsing to handle
197    /// colorized CLI output.
198    pub fn parse_backpressure_evidence(payload: &str) -> Option<BackpressureEvidence> {
199        // Strip ANSI codes before checking for evidence strings
200        let clean_payload = strip_ansi(payload);
201
202        let tests_passed = clean_payload.contains("tests: pass");
203        let lint_passed = clean_payload.contains("lint: pass");
204        let typecheck_passed = clean_payload.contains("typecheck: pass");
205
206        // Only return evidence if at least one check is mentioned
207        if clean_payload.contains("tests:")
208            || clean_payload.contains("lint:")
209            || clean_payload.contains("typecheck:")
210        {
211            Some(BackpressureEvidence {
212                tests_passed,
213                lint_passed,
214                typecheck_passed,
215            })
216        } else {
217            None
218        }
219    }
220
221    /// Parses review evidence from review.done event payload.
222    ///
223    /// Expected format (subset of backpressure evidence):
224    /// ```text
225    /// tests: pass
226    /// build: pass
227    /// ```
228    ///
229    /// Note: ANSI escape codes are stripped before parsing.
230    pub fn parse_review_evidence(payload: &str) -> Option<ReviewEvidence> {
231        let clean_payload = strip_ansi(payload);
232
233        let tests_passed = clean_payload.contains("tests: pass");
234        let build_passed = clean_payload.contains("build: pass");
235
236        // Only return evidence if at least one check is mentioned
237        if clean_payload.contains("tests:") || clean_payload.contains("build:") {
238            Some(ReviewEvidence {
239                tests_passed,
240                build_passed,
241            })
242        } else {
243            None
244        }
245    }
246
247    /// Checks if output contains the completion promise.
248    ///
249    /// Per spec: The promise must appear in the agent's final output,
250    /// not inside an `<event>` tag payload. This function:
251    /// 1. Returns false if the promise appears inside ANY event tag
252    ///    (prevents accidental completion when agents discuss the promise)
253    /// 2. Otherwise, checks for the promise in the stripped output
254    pub fn contains_promise(output: &str, promise: &str) -> bool {
255        // Safety check: if promise appears inside any event tag, never complete
256        if Self::promise_in_event_tags(output, promise) {
257            return false;
258        }
259        let stripped = Self::strip_event_tags(output);
260        stripped.contains(promise)
261    }
262
263    /// Checks if the promise appears inside any event tag payload.
264    pub fn promise_in_event_tags(output: &str, promise: &str) -> bool {
265        let mut remaining = output;
266
267        while let Some(start_idx) = remaining.find("<event ") {
268            let after_start = &remaining[start_idx..];
269
270            // Find the end of the opening tag
271            let Some(tag_end) = after_start.find('>') else {
272                remaining = &remaining[start_idx + 7..];
273                continue;
274            };
275
276            // Find the closing tag
277            let content_start = &after_start[tag_end + 1..];
278            let Some(close_idx) = content_start.find("</event>") else {
279                remaining = &remaining[start_idx + tag_end + 1..];
280                continue;
281            };
282
283            let payload = &content_start[..close_idx];
284            if payload.contains(promise) {
285                return true;
286            }
287
288            // Move past this event
289            let total_consumed = start_idx + tag_end + 1 + close_idx + 8;
290            remaining = &remaining[total_consumed..];
291        }
292
293        false
294    }
295
296    /// Strips all `<event ...>...</event>` blocks from output.
297    ///
298    /// Returns the output with event tags removed, leaving only
299    /// the "final output" text that should be checked for promises.
300    fn strip_event_tags(output: &str) -> String {
301        let mut result = String::with_capacity(output.len());
302        let mut remaining = output;
303
304        while let Some(start_idx) = remaining.find("<event ") {
305            // Add everything before this event tag
306            result.push_str(&remaining[..start_idx]);
307
308            let after_start = &remaining[start_idx..];
309
310            // Find the closing tag
311            if let Some(close_idx) = after_start.find("</event>") {
312                // Skip past the entire event block
313                remaining = &after_start[close_idx + 8..]; // 8 = "</event>".len()
314            } else {
315                // Malformed: no closing tag, keep the rest and stop
316                result.push_str(after_start);
317                remaining = "";
318                break;
319            }
320        }
321
322        // Add any remaining content after the last event
323        result.push_str(remaining);
324        result
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_parse_single_event() {
334        let output = r#"
335Some preamble text.
336<event topic="impl.done">
337Implemented the authentication module.
338</event>
339Some trailing text.
340"#;
341        let parser = EventParser::new();
342        let events = parser.parse(output);
343
344        assert_eq!(events.len(), 1);
345        assert_eq!(events[0].topic.as_str(), "impl.done");
346        assert!(events[0].payload.contains("authentication module"));
347    }
348
349    #[test]
350    fn test_parse_event_with_target() {
351        let output = r#"<event topic="handoff" target="reviewer">Please review</event>"#;
352        let parser = EventParser::new();
353        let events = parser.parse(output);
354
355        assert_eq!(events.len(), 1);
356        assert_eq!(events[0].target.as_ref().unwrap().as_str(), "reviewer");
357    }
358
359    #[test]
360    fn test_parse_multiple_events() {
361        let output = r#"
362<event topic="impl.started">Starting work</event>
363Working on implementation...
364<event topic="impl.done">Finished</event>
365"#;
366        let parser = EventParser::new();
367        let events = parser.parse(output);
368
369        assert_eq!(events.len(), 2);
370        assert_eq!(events[0].topic.as_str(), "impl.started");
371        assert_eq!(events[1].topic.as_str(), "impl.done");
372    }
373
374    #[test]
375    fn test_parse_with_source() {
376        let output = r#"<event topic="impl.done">Done</event>"#;
377        let parser = EventParser::new().with_source("implementer");
378        let events = parser.parse(output);
379
380        assert_eq!(events[0].source.as_ref().unwrap().as_str(), "implementer");
381    }
382
383    #[test]
384    fn test_no_events() {
385        let output = "Just regular output with no events.";
386        let parser = EventParser::new();
387        let events = parser.parse(output);
388
389        assert!(events.is_empty());
390    }
391
392    #[test]
393    fn test_contains_promise() {
394        assert!(EventParser::contains_promise(
395            "LOOP_COMPLETE",
396            "LOOP_COMPLETE"
397        ));
398        assert!(EventParser::contains_promise(
399            "prefix LOOP_COMPLETE suffix",
400            "LOOP_COMPLETE"
401        ));
402        assert!(!EventParser::contains_promise(
403            "No promise here",
404            "LOOP_COMPLETE"
405        ));
406    }
407
408    #[test]
409    fn test_contains_promise_ignores_event_payloads() {
410        // Promise inside event payload should NOT be detected
411        let output = r#"<event topic="build.task">Fix LOOP_COMPLETE detection</event>"#;
412        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
413
414        // Promise inside event with acceptance criteria mentioning LOOP_COMPLETE
415        let output = r#"<event topic="build.task">
416## Task: Fix completion promise detection
417- Given LOOP_COMPLETE appears inside an event tag
418- Then it should be ignored
419</event>"#;
420        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
421    }
422
423    #[test]
424    fn test_contains_promise_detects_outside_events() {
425        // Promise outside event tags should be detected
426        let output = r#"<event topic="build.done">Task complete</event>
427All done! LOOP_COMPLETE"#;
428        assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
429
430        // Promise before event tags
431        let output = r#"LOOP_COMPLETE
432<event topic="summary">Final summary</event>"#;
433        assert!(EventParser::contains_promise(output, "LOOP_COMPLETE"));
434    }
435
436    #[test]
437    fn test_contains_promise_mixed_content() {
438        // Promise only in event payload, not in surrounding text
439        let output = r#"Working on task...
440<event topic="build.task">Fix LOOP_COMPLETE bug</event>
441Still working..."#;
442        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
443
444        // Promise in both event and surrounding text - should NOT complete
445        // because promise appears inside an event tag (safety mechanism)
446        let output = r#"All tasks done. LOOP_COMPLETE
447<event topic="summary">Completed LOOP_COMPLETE task</event>"#;
448        assert!(!EventParser::contains_promise(output, "LOOP_COMPLETE"));
449    }
450
451    #[test]
452    fn test_promise_in_event_tags() {
453        // Promise inside event payload
454        let output = r#"<event topic="build.task">Fix LOOP_COMPLETE bug</event>"#;
455        assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
456
457        // Promise not in any event
458        let output = r#"<event topic="build.done">Task complete</event>"#;
459        assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
460
461        // No events at all
462        let output = "Just regular text with LOOP_COMPLETE";
463        assert!(!EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
464
465        // Multiple events, promise in second
466        let output = r#"<event topic="a">first</event>
467<event topic="b">contains LOOP_COMPLETE</event>"#;
468        assert!(EventParser::promise_in_event_tags(output, "LOOP_COMPLETE"));
469    }
470
471    #[test]
472    fn test_strip_event_tags() {
473        // Single event
474        let output = r#"before <event topic="test">payload</event> after"#;
475        let stripped = EventParser::strip_event_tags(output);
476        assert_eq!(stripped, "before  after");
477        assert!(!stripped.contains("payload"));
478
479        // Multiple events
480        let output =
481            r#"start <event topic="a">one</event> middle <event topic="b">two</event> end"#;
482        let stripped = EventParser::strip_event_tags(output);
483        assert_eq!(stripped, "start  middle  end");
484
485        // No events
486        let output = "just plain text";
487        let stripped = EventParser::strip_event_tags(output);
488        assert_eq!(stripped, "just plain text");
489    }
490
491    #[test]
492    fn test_parse_backpressure_evidence_all_pass() {
493        let payload = "tests: pass\nlint: pass\ntypecheck: pass";
494        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
495        assert!(evidence.tests_passed);
496        assert!(evidence.lint_passed);
497        assert!(evidence.typecheck_passed);
498        assert!(evidence.all_passed());
499    }
500
501    #[test]
502    fn test_parse_backpressure_evidence_some_fail() {
503        let payload = "tests: pass\nlint: fail\ntypecheck: pass";
504        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
505        assert!(evidence.tests_passed);
506        assert!(!evidence.lint_passed);
507        assert!(evidence.typecheck_passed);
508        assert!(!evidence.all_passed());
509    }
510
511    #[test]
512    fn test_parse_backpressure_evidence_missing() {
513        let payload = "Task completed successfully";
514        let evidence = EventParser::parse_backpressure_evidence(payload);
515        assert!(evidence.is_none());
516    }
517
518    #[test]
519    fn test_parse_backpressure_evidence_partial() {
520        let payload = "tests: pass\nSome other text";
521        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
522        assert!(evidence.tests_passed);
523        assert!(!evidence.lint_passed);
524        assert!(!evidence.typecheck_passed);
525        assert!(!evidence.all_passed());
526    }
527
528    #[test]
529    fn test_parse_backpressure_evidence_with_ansi_codes() {
530        let payload = "\x1b[0mtests: pass\x1b[0m\n\x1b[32mlint: pass\x1b[0m\ntypecheck: pass";
531        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
532        assert!(evidence.tests_passed);
533        assert!(evidence.lint_passed);
534        assert!(evidence.typecheck_passed);
535        assert!(evidence.all_passed());
536    }
537
538    #[test]
539    fn test_parse_review_evidence_all_pass() {
540        let payload = "tests: pass\nbuild: pass";
541        let evidence = EventParser::parse_review_evidence(payload).unwrap();
542        assert!(evidence.tests_passed);
543        assert!(evidence.build_passed);
544        assert!(evidence.is_verified());
545    }
546
547    #[test]
548    fn test_parse_review_evidence_tests_fail() {
549        let payload = "tests: fail\nbuild: pass";
550        let evidence = EventParser::parse_review_evidence(payload).unwrap();
551        assert!(!evidence.tests_passed);
552        assert!(evidence.build_passed);
553        assert!(!evidence.is_verified());
554    }
555
556    #[test]
557    fn test_parse_review_evidence_build_fail() {
558        let payload = "tests: pass\nbuild: fail";
559        let evidence = EventParser::parse_review_evidence(payload).unwrap();
560        assert!(evidence.tests_passed);
561        assert!(!evidence.build_passed);
562        assert!(!evidence.is_verified());
563    }
564
565    #[test]
566    fn test_parse_review_evidence_missing() {
567        let payload = "Looks good, approved!";
568        let evidence = EventParser::parse_review_evidence(payload);
569        assert!(evidence.is_none());
570    }
571
572    #[test]
573    fn test_parse_review_evidence_partial() {
574        let payload = "tests: pass\nLGTM";
575        let evidence = EventParser::parse_review_evidence(payload).unwrap();
576        assert!(evidence.tests_passed);
577        assert!(!evidence.build_passed);
578        assert!(!evidence.is_verified());
579    }
580
581    #[test]
582    fn test_parse_review_evidence_with_ansi_codes() {
583        let payload = "\x1b[32mtests: pass\x1b[0m\n\x1b[32mbuild: pass\x1b[0m";
584        let evidence = EventParser::parse_review_evidence(payload).unwrap();
585        assert!(evidence.tests_passed);
586        assert!(evidence.build_passed);
587        assert!(evidence.is_verified());
588    }
589
590    #[test]
591    fn test_strip_ansi_function() {
592        // Test the internal strip_ansi function via parse_backpressure_evidence
593        // Simple CSI reset sequence
594        let payload = "\x1b[0mtests: pass\x1b[0m";
595        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
596        assert!(evidence.tests_passed);
597
598        // Bold green text
599        let payload = "\x1b[1m\x1b[32mtests: pass\x1b[0m";
600        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
601        assert!(evidence.tests_passed);
602
603        // Multiple sequences mixed with content
604        let payload = "\x1b[31mtests: fail\x1b[0m\n\x1b[32mlint: pass\x1b[0m";
605        let evidence = EventParser::parse_backpressure_evidence(payload).unwrap();
606        assert!(!evidence.tests_passed); // "tests: fail" not "tests: pass"
607        assert!(evidence.lint_passed);
608    }
609}