1use crate::config::{HatBackend, RalphConfig};
6use crate::event_parser::EventParser;
7use crate::event_reader::EventReader;
8use crate::hat_registry::HatRegistry;
9use crate::hatless_ralph::HatlessRalph;
10use crate::instructions::InstructionBuilder;
11use ralph_proto::{Event, EventBus, Hat, HatId};
12use std::collections::HashMap;
13use std::time::{Duration, Instant};
14use tracing::{debug, info, warn};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum TerminationReason {
19 CompletionPromise,
21 MaxIterations,
23 MaxRuntime,
25 MaxCost,
27 ConsecutiveFailures,
29 LoopThrashing,
31 ValidationFailure,
33 Stopped,
35 Interrupted,
37}
38
39impl TerminationReason {
40 pub fn exit_code(&self) -> i32 {
48 match self {
49 TerminationReason::CompletionPromise => 0,
50 TerminationReason::ConsecutiveFailures
51 | TerminationReason::LoopThrashing
52 | TerminationReason::ValidationFailure
53 | TerminationReason::Stopped => 1,
54 TerminationReason::MaxIterations
55 | TerminationReason::MaxRuntime
56 | TerminationReason::MaxCost => 2,
57 TerminationReason::Interrupted => 130,
58 }
59 }
60
61 pub fn as_str(&self) -> &'static str {
66 match self {
67 TerminationReason::CompletionPromise => "completed",
68 TerminationReason::MaxIterations => "max_iterations",
69 TerminationReason::MaxRuntime => "max_runtime",
70 TerminationReason::MaxCost => "max_cost",
71 TerminationReason::ConsecutiveFailures => "consecutive_failures",
72 TerminationReason::LoopThrashing => "loop_thrashing",
73 TerminationReason::ValidationFailure => "validation_failure",
74 TerminationReason::Stopped => "stopped",
75 TerminationReason::Interrupted => "interrupted",
76 }
77 }
78}
79
80#[derive(Debug)]
82pub struct LoopState {
83 pub iteration: u32,
85 pub consecutive_failures: u32,
87 pub cumulative_cost: f64,
89 pub started_at: Instant,
91 pub last_hat: Option<HatId>,
93 pub consecutive_blocked: u32,
95 pub last_blocked_hat: Option<HatId>,
97 pub task_block_counts: HashMap<String, u32>,
99 pub abandoned_tasks: Vec<String>,
101 pub abandoned_task_redispatches: u32,
103 pub completion_confirmations: u32,
105 pub consecutive_malformed_events: u32,
107}
108
109impl Default for LoopState {
110 fn default() -> Self {
111 Self {
112 iteration: 0,
113 consecutive_failures: 0,
114 cumulative_cost: 0.0,
115 started_at: Instant::now(),
116 last_hat: None,
117 consecutive_blocked: 0,
118 last_blocked_hat: None,
119 task_block_counts: HashMap::new(),
120 abandoned_tasks: Vec::new(),
121 abandoned_task_redispatches: 0,
122 completion_confirmations: 0,
123 consecutive_malformed_events: 0,
124 }
125 }
126}
127
128impl LoopState {
129 pub fn new() -> Self {
131 Self::default()
132 }
133
134 pub fn elapsed(&self) -> Duration {
136 self.started_at.elapsed()
137 }
138}
139
140pub struct EventLoop {
142 config: RalphConfig,
143 registry: HatRegistry,
144 bus: EventBus,
145 state: LoopState,
146 instruction_builder: InstructionBuilder,
147 ralph: HatlessRalph,
148 event_reader: EventReader,
149}
150
151impl EventLoop {
152 pub fn new(config: RalphConfig) -> Self {
154 let registry = HatRegistry::from_config(&config);
155 let instruction_builder = InstructionBuilder::with_events(
156 &config.event_loop.completion_promise,
157 config.core.clone(),
158 config.events.clone(),
159 );
160
161 let mut bus = EventBus::new();
162
163 for hat in registry.all() {
167 bus.register(hat.clone());
168 }
169
170 let ralph_hat = ralph_proto::Hat::new("ralph", "Ralph").subscribe("*"); bus.register(ralph_hat);
174
175 if registry.is_empty() {
176 debug!("Solo mode: Ralph is the only coordinator");
177 } else {
178 debug!(
179 "Multi-hat mode: {} custom hats + Ralph as fallback",
180 registry.len()
181 );
182 }
183
184 let ralph = HatlessRalph::new(
185 config.event_loop.completion_promise.clone(),
186 config.core.clone(),
187 ®istry,
188 config.event_loop.starting_event.clone(),
189 );
190
191 let events_path = std::fs::read_to_string(".ralph/current-events")
194 .map(|s| s.trim().to_string())
195 .unwrap_or_else(|_| ".ralph/events.jsonl".to_string());
196 let event_reader = EventReader::new(&events_path);
197
198 Self {
199 config,
200 registry,
201 bus,
202 state: LoopState::new(),
203 instruction_builder,
204 ralph,
205 event_reader,
206 }
207 }
208
209 pub fn state(&self) -> &LoopState {
211 &self.state
212 }
213
214 pub fn config(&self) -> &RalphConfig {
216 &self.config
217 }
218
219 pub fn registry(&self) -> &HatRegistry {
221 &self.registry
222 }
223
224 pub fn get_hat_backend(&self, hat_id: &HatId) -> Option<&HatBackend> {
229 self.registry
230 .get_config(hat_id)
231 .and_then(|config| config.backend.as_ref())
232 }
233
234 pub fn add_observer<F>(&mut self, observer: F)
239 where
240 F: Fn(&Event) + Send + 'static,
241 {
242 self.bus.add_observer(observer);
243 }
244
245 #[deprecated(since = "2.0.0", note = "Use add_observer instead")]
249 pub fn set_observer<F>(&mut self, observer: F)
250 where
251 F: Fn(&Event) + Send + 'static,
252 {
253 #[allow(deprecated)]
254 self.bus.set_observer(observer);
255 }
256
257 pub fn check_termination(&self) -> Option<TerminationReason> {
259 let cfg = &self.config.event_loop;
260
261 if self.state.iteration >= cfg.max_iterations {
262 return Some(TerminationReason::MaxIterations);
263 }
264
265 if self.state.elapsed().as_secs() >= cfg.max_runtime_seconds {
266 return Some(TerminationReason::MaxRuntime);
267 }
268
269 if let Some(max_cost) = cfg.max_cost_usd
270 && self.state.cumulative_cost >= max_cost
271 {
272 return Some(TerminationReason::MaxCost);
273 }
274
275 if self.state.consecutive_failures >= cfg.max_consecutive_failures {
276 return Some(TerminationReason::ConsecutiveFailures);
277 }
278
279 if self.state.abandoned_task_redispatches >= 3 {
281 return Some(TerminationReason::LoopThrashing);
282 }
283
284 if self.state.consecutive_malformed_events >= 3 {
286 return Some(TerminationReason::ValidationFailure);
287 }
288
289 None
290 }
291
292 pub fn initialize(&mut self, prompt_content: &str) {
294 self.initialize_with_topic("task.start", prompt_content);
295 }
296
297 pub fn initialize_resume(&mut self, prompt_content: &str) {
302 self.initialize_with_topic("task.resume", prompt_content);
303 }
304
305 fn initialize_with_topic(&mut self, topic: &str, prompt_content: &str) {
307 let start_event = Event::new(topic, prompt_content);
311 self.bus.publish(start_event);
312 debug!(topic = topic, "Published {} event", topic);
313 }
314
315 pub fn next_hat(&self) -> Option<&HatId> {
324 let next = self.bus.next_hat_with_pending();
325
326 next.as_ref()?;
328
329 if self.registry.is_empty() {
332 next
334 } else {
335 self.bus.hat_ids().find(|id| id.as_str() == "ralph")
338 }
339 }
340
341 pub fn has_pending_events(&self) -> bool {
346 self.bus.next_hat_with_pending().is_some()
347 }
348
349 pub fn get_hat_publishes(&self, hat_id: &HatId) -> Vec<String> {
353 self.registry
354 .get(hat_id)
355 .map(|hat| hat.publishes.iter().map(|t| t.to_string()).collect())
356 .unwrap_or_default()
357 }
358
359 pub fn inject_fallback_event(&mut self) -> bool {
366 let fallback_event = Event::new(
367 "task.resume",
368 "RECOVERY: Previous iteration did not publish an event. \
369 Review the scratchpad and either dispatch the next task or complete the loop.",
370 );
371
372 let fallback_event = match &self.state.last_hat {
375 Some(hat_id) if hat_id.as_str() != "ralph" => {
376 debug!(
377 hat = %hat_id.as_str(),
378 "Injecting fallback event to recover - targeting last hat with task.resume"
379 );
380 fallback_event.with_target(hat_id.clone())
381 }
382 _ => {
383 debug!("Injecting fallback event to recover - triggering Ralph with task.resume");
384 fallback_event
385 }
386 };
387
388 self.bus.publish(fallback_event);
389 true
390 }
391
392 pub fn build_prompt(&mut self, hat_id: &HatId) -> Option<String> {
402 if hat_id.as_str() == "ralph" {
405 if self.registry.is_empty() {
406 let events = self.bus.take_pending(&hat_id.clone());
408 let events_context = events
409 .iter()
410 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
411 .collect::<Vec<_>>()
412 .join("\n");
413
414 debug!("build_prompt: routing to HatlessRalph (solo mode)");
415 return Some(self.ralph.build_prompt(&events_context, &[]));
416 } else {
417 let all_hat_ids: Vec<HatId> = self.bus.hat_ids().cloned().collect();
419 let mut all_events = Vec::new();
420 for id in all_hat_ids {
421 all_events.extend(self.bus.take_pending(&id));
422 }
423
424 let active_hats = self.determine_active_hats(&all_events);
426
427 let events_context = all_events
429 .iter()
430 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
431 .collect::<Vec<_>>()
432 .join("\n");
433
434 debug!(
436 "build_prompt: routing to HatlessRalph (multi-hat coordinator mode), active_hats: {:?}",
437 active_hats
438 .iter()
439 .map(|h| h.id.as_str())
440 .collect::<Vec<_>>()
441 );
442 return Some(self.ralph.build_prompt(&events_context, &active_hats));
443 }
444 }
445
446 let events = self.bus.take_pending(&hat_id.clone());
450 let events_context = events
451 .iter()
452 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
453 .collect::<Vec<_>>()
454 .join("\n");
455
456 let hat = self.registry.get(hat_id)?;
457
458 debug!(
460 "build_prompt: hat_id='{}', instructions.is_empty()={}",
461 hat_id.as_str(),
462 hat.instructions.is_empty()
463 );
464
465 debug!(
467 "build_prompt: routing to build_custom_hat() for '{}'",
468 hat_id.as_str()
469 );
470 Some(
471 self.instruction_builder
472 .build_custom_hat(hat, &events_context),
473 )
474 }
475
476 pub fn build_ralph_prompt(&self, prompt_content: &str) -> String {
478 self.ralph.build_prompt(prompt_content, &[])
479 }
480
481 fn determine_active_hats(&self, events: &[Event]) -> Vec<&Hat> {
484 let mut active_hats = Vec::new();
485 for event in events {
486 if let Some(hat) = self.registry.get_for_topic(event.topic.as_str()) {
487 if !active_hats.iter().any(|h: &&Hat| h.id == hat.id) {
489 active_hats.push(hat);
490 }
491 }
492 }
493 active_hats
494 }
495
496 pub fn get_active_hat_id(&self) -> HatId {
499 for hat_id in self.bus.hat_ids() {
501 let Some(events) = self.bus.peek_pending(hat_id) else {
502 continue;
503 };
504 let Some(event) = events.first() else {
505 continue;
506 };
507 if let Some(active_hat) = self.registry.get_for_topic(event.topic.as_str()) {
508 return active_hat.id.clone();
509 }
510 }
511 HatId::new("ralph")
512 }
513
514 pub fn record_event_count(&mut self) -> usize {
519 self.event_reader
520 .read_new_events()
521 .map(|r| r.events.len())
522 .unwrap_or(0)
523 }
524
525 pub fn check_default_publishes(&mut self, hat_id: &HatId, _events_before: usize) {
531 let events_after = self
532 .event_reader
533 .read_new_events()
534 .map(|r| r.events.len())
535 .unwrap_or(0);
536
537 if events_after == 0
538 && let Some(config) = self.registry.get_config(hat_id)
539 && let Some(default_topic) = &config.default_publishes
540 {
541 let default_event = Event::new(default_topic.as_str(), "").with_source(hat_id.clone());
543
544 debug!(
545 hat = %hat_id.as_str(),
546 topic = %default_topic,
547 "No events written by hat, injecting default_publishes event"
548 );
549
550 self.bus.publish(default_event);
551 }
552 }
553
554 pub fn process_output(
558 &mut self,
559 hat_id: &HatId,
560 output: &str,
561 success: bool,
562 ) -> Option<TerminationReason> {
563 self.state.iteration += 1;
564 self.state.last_hat = Some(hat_id.clone());
565
566 if success {
568 self.state.consecutive_failures = 0;
569 } else {
570 self.state.consecutive_failures += 1;
571 }
572
573 if hat_id.as_str() == "ralph"
576 && EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
577 {
578 match self.verify_scratchpad_complete() {
580 Ok(true) => {
581 self.state.completion_confirmations += 1;
583
584 if self.state.completion_confirmations >= 2 {
585 info!(
587 confirmations = self.state.completion_confirmations,
588 "Completion confirmed on consecutive iterations - terminating"
589 );
590 return Some(TerminationReason::CompletionPromise);
591 }
592 info!(
594 confirmations = self.state.completion_confirmations,
595 "Completion detected but requires consecutive confirmation - continuing"
596 );
597 }
598 Ok(false) => {
599 debug!(
601 "Completion promise detected but scratchpad has pending [ ] tasks - rejected"
602 );
603 self.state.completion_confirmations = 0;
604 }
605 Err(e) => {
606 debug!(
608 error = %e,
609 "Completion promise detected but scratchpad verification failed - rejected"
610 );
611 self.state.completion_confirmations = 0;
612 }
613 }
614 }
615
616 let parser = EventParser::new().with_source(hat_id.clone());
618 let events = parser.parse(output);
619
620 let mut validated_events = Vec::new();
622 for event in events {
623 if event.topic.as_str() == "build.done" {
624 if let Some(evidence) = EventParser::parse_backpressure_evidence(&event.payload) {
625 if evidence.all_passed() {
626 validated_events.push(event);
627 } else {
628 warn!(
630 hat = %hat_id.as_str(),
631 tests = evidence.tests_passed,
632 lint = evidence.lint_passed,
633 typecheck = evidence.typecheck_passed,
634 "build.done rejected: backpressure checks failed"
635 );
636 let blocked = Event::new(
637 "build.blocked",
638 "Backpressure checks failed. Fix tests/lint/typecheck before emitting build.done."
639 ).with_source(hat_id.clone());
640 validated_events.push(blocked);
641 }
642 } else {
643 warn!(
645 hat = %hat_id.as_str(),
646 "build.done rejected: missing backpressure evidence"
647 );
648 let blocked = Event::new(
649 "build.blocked",
650 "Missing backpressure evidence. Include 'tests: pass', 'lint: pass', 'typecheck: pass' in build.done payload."
651 ).with_source(hat_id.clone());
652 validated_events.push(blocked);
653 }
654 } else {
655 validated_events.push(event);
656 }
657 }
658
659 let blocked_events: Vec<_> = validated_events
661 .iter()
662 .filter(|e| e.topic == "build.blocked".into())
663 .collect();
664
665 for blocked_event in &blocked_events {
666 let task_id = Self::extract_task_id(&blocked_event.payload);
668
669 let count = self
671 .state
672 .task_block_counts
673 .entry(task_id.clone())
674 .or_insert(0);
675 *count += 1;
676
677 debug!(
678 task_id = %task_id,
679 block_count = *count,
680 "Task blocked"
681 );
682
683 if *count >= 3 && !self.state.abandoned_tasks.contains(&task_id) {
685 warn!(
686 task_id = %task_id,
687 "Task abandoned after 3 consecutive blocks"
688 );
689
690 self.state.abandoned_tasks.push(task_id.clone());
691
692 let abandoned_event = Event::new(
693 "build.task.abandoned",
694 format!(
695 "Task '{}' abandoned after 3 consecutive build.blocked events",
696 task_id
697 ),
698 )
699 .with_source(hat_id.clone());
700
701 self.bus.publish(abandoned_event);
702 }
703 }
704
705 let task_events: Vec<_> = validated_events
707 .iter()
708 .filter(|e| e.topic == "build.task".into())
709 .collect();
710
711 for task_event in task_events {
712 let task_id = Self::extract_task_id(&task_event.payload);
713
714 if self.state.abandoned_tasks.contains(&task_id) {
716 self.state.abandoned_task_redispatches += 1;
717 warn!(
718 task_id = %task_id,
719 redispatch_count = self.state.abandoned_task_redispatches,
720 "Planner redispatched abandoned task"
721 );
722 } else {
723 self.state.abandoned_task_redispatches = 0;
725 }
726 }
727
728 let has_blocked_event = !blocked_events.is_empty();
730
731 if has_blocked_event {
732 if self.state.last_blocked_hat.as_ref() == Some(hat_id) {
734 self.state.consecutive_blocked += 1;
735 } else {
736 self.state.consecutive_blocked = 1;
737 self.state.last_blocked_hat = Some(hat_id.clone());
738 }
739 } else {
740 self.state.consecutive_blocked = 0;
742 self.state.last_blocked_hat = None;
743 }
744
745 for event in validated_events {
746 debug!(
747 topic = %event.topic,
748 source = ?event.source,
749 target = ?event.target,
750 "Publishing event from output"
751 );
752 let topic = event.topic.clone();
753 let recipients = self.bus.publish(event);
754
755 if recipients.is_empty() {
757 warn!(
758 topic = %topic,
759 source = %hat_id.as_str(),
760 "Event has no subscribers - will be dropped. Check hat triggers configuration."
761 );
762 }
763 }
764
765 self.check_termination()
767 }
768
769 fn extract_task_id(payload: &str) -> String {
772 payload
773 .lines()
774 .next()
775 .unwrap_or("unknown")
776 .trim()
777 .to_string()
778 }
779
780 pub fn add_cost(&mut self, cost: f64) {
782 self.state.cumulative_cost += cost;
783 }
784
785 fn verify_scratchpad_complete(&self) -> Result<bool, std::io::Error> {
792 use std::path::Path;
793
794 let scratchpad_path = Path::new(&self.config.core.scratchpad);
795
796 if !scratchpad_path.exists() {
797 return Err(std::io::Error::new(
798 std::io::ErrorKind::NotFound,
799 "Scratchpad does not exist",
800 ));
801 }
802
803 let content = std::fs::read_to_string(scratchpad_path)?;
804
805 let has_pending = content
806 .lines()
807 .any(|line| line.trim_start().starts_with("- [ ]"));
808
809 Ok(!has_pending)
810 }
811
812 pub fn process_events_from_jsonl(&mut self) -> std::io::Result<bool> {
821 let result = self.event_reader.read_new_events()?;
822
823 for malformed in &result.malformed {
825 let payload = format!(
826 "Line {}: {}\nContent: {}",
827 malformed.line_number, malformed.error, &malformed.content
828 );
829 let event = Event::new("event.malformed", &payload);
830 self.bus.publish(event);
831 self.state.consecutive_malformed_events += 1;
832 warn!(
833 line = malformed.line_number,
834 consecutive = self.state.consecutive_malformed_events,
835 "Malformed event line detected"
836 );
837 }
838
839 if !result.events.is_empty() {
841 self.state.consecutive_malformed_events = 0;
842 }
843
844 if result.events.is_empty() && result.malformed.is_empty() {
845 return Ok(false);
846 }
847
848 let mut has_orphans = false;
849
850 for event in result.events {
851 if self.registry.has_subscriber(&event.topic) {
853 let proto_event = if let Some(payload) = event.payload {
855 Event::new(event.topic.as_str(), &payload)
856 } else {
857 Event::new(event.topic.as_str(), "")
858 };
859 self.bus.publish(proto_event);
860 } else {
861 debug!(
863 topic = %event.topic,
864 "Event has no subscriber - will be handled by Ralph"
865 );
866 has_orphans = true;
867 }
868 }
869
870 Ok(has_orphans)
871 }
872
873 pub fn check_ralph_completion(&self, output: &str) -> bool {
877 EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
878 }
879
880 pub fn publish_terminate_event(&mut self, reason: &TerminationReason) -> Event {
887 let elapsed = self.state.elapsed();
888 let duration_str = format_duration(elapsed);
889
890 let payload = format!(
891 "## Reason\n{}\n\n## Status\n{}\n\n## Summary\n- Iterations: {}\n- Duration: {}\n- Exit code: {}",
892 reason.as_str(),
893 termination_status_text(reason),
894 self.state.iteration,
895 duration_str,
896 reason.exit_code()
897 );
898
899 let event = Event::new("loop.terminate", &payload);
900
901 self.bus.publish(event.clone());
903
904 info!(
905 reason = %reason.as_str(),
906 iterations = self.state.iteration,
907 duration = %duration_str,
908 "Wrapping up: {}. {} iterations in {}.",
909 reason.as_str(),
910 self.state.iteration,
911 duration_str
912 );
913
914 event
915 }
916}
917
918fn format_duration(d: Duration) -> String {
920 let total_secs = d.as_secs();
921 let hours = total_secs / 3600;
922 let minutes = (total_secs % 3600) / 60;
923 let seconds = total_secs % 60;
924
925 if hours > 0 {
926 format!("{}h {}m {}s", hours, minutes, seconds)
927 } else if minutes > 0 {
928 format!("{}m {}s", minutes, seconds)
929 } else {
930 format!("{}s", seconds)
931 }
932}
933
934fn termination_status_text(reason: &TerminationReason) -> &'static str {
936 match reason {
937 TerminationReason::CompletionPromise => "All tasks completed successfully.",
938 TerminationReason::MaxIterations => "Stopped at iteration limit.",
939 TerminationReason::MaxRuntime => "Stopped at runtime limit.",
940 TerminationReason::MaxCost => "Stopped at cost limit.",
941 TerminationReason::ConsecutiveFailures => "Too many consecutive failures.",
942 TerminationReason::LoopThrashing => {
943 "Loop thrashing detected - same hat repeatedly blocked."
944 }
945 TerminationReason::ValidationFailure => "Too many consecutive malformed JSONL events.",
946 TerminationReason::Stopped => "Manually stopped.",
947 TerminationReason::Interrupted => "Interrupted by signal.",
948 }
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954
955 #[test]
956 fn test_initialization_routes_to_ralph_in_multihat_mode() {
957 let yaml = r#"
960hats:
961 planner:
962 name: "Planner"
963 triggers: ["task.start", "build.done", "build.blocked"]
964 publishes: ["build.task"]
965"#;
966 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
967 let mut event_loop = EventLoop::new(config);
968
969 event_loop.initialize("Test prompt");
970
971 let next = event_loop.next_hat();
973 assert!(next.is_some());
974 assert_eq!(
975 next.unwrap().as_str(),
976 "ralph",
977 "Multi-hat mode should route to Ralph"
978 );
979
980 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
982 assert!(
983 prompt.contains("task.start"),
984 "Ralph's prompt should include the event"
985 );
986 }
987
988 #[test]
989 fn test_termination_max_iterations() {
990 let yaml = r"
991event_loop:
992 max_iterations: 2
993";
994 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
995 let mut event_loop = EventLoop::new(config);
996 event_loop.state.iteration = 2;
997
998 assert_eq!(
999 event_loop.check_termination(),
1000 Some(TerminationReason::MaxIterations)
1001 );
1002 }
1003
1004 #[test]
1005 fn test_completion_promise_detection() {
1006 use std::fs;
1007 use std::path::Path;
1008
1009 let config = RalphConfig::default();
1010 let mut event_loop = EventLoop::new(config);
1011 event_loop.initialize("Test");
1012
1013 let scratchpad_path = Path::new(".agent/scratchpad.md");
1015 fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
1016 fs::write(
1017 scratchpad_path,
1018 "## Tasks\n- [x] Task 1 done\n- [x] Task 2 done\n",
1019 )
1020 .unwrap();
1021
1022 let hat_id = HatId::new("ralph");
1024
1025 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1027 assert_eq!(reason, None, "First confirmation should not terminate");
1028
1029 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1031 assert_eq!(
1032 reason,
1033 Some(TerminationReason::CompletionPromise),
1034 "Second consecutive confirmation should terminate"
1035 );
1036
1037 fs::remove_file(scratchpad_path).ok();
1039 }
1040
1041 #[test]
1042 fn test_builder_cannot_terminate_loop() {
1043 let config = RalphConfig::default();
1045 let mut event_loop = EventLoop::new(config);
1046 event_loop.initialize("Test");
1047
1048 let hat_id = HatId::new("builder");
1050 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1051
1052 assert_eq!(reason, None);
1054 }
1055
1056 #[test]
1057 fn test_build_prompt_uses_ghuntley_style_for_all_hats() {
1058 let yaml = r#"
1060hats:
1061 planner:
1062 name: "Planner"
1063 triggers: ["task.start", "build.done", "build.blocked"]
1064 publishes: ["build.task"]
1065 builder:
1066 name: "Builder"
1067 triggers: ["build.task"]
1068 publishes: ["build.done", "build.blocked"]
1069"#;
1070 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1071 let mut event_loop = EventLoop::new(config);
1072 event_loop.initialize("Test task");
1073
1074 let planner_id = HatId::new("planner");
1076 let planner_prompt = event_loop.build_prompt(&planner_id).unwrap();
1077
1078 assert!(
1080 planner_prompt.contains("### 0. ORIENTATION"),
1081 "Planner should use ghuntley-style orientation phase"
1082 );
1083 assert!(
1084 planner_prompt.contains("### GUARDRAILS"),
1085 "Planner prompt should have guardrails section"
1086 );
1087 assert!(
1088 planner_prompt.contains("Fresh context each iteration"),
1089 "Planner prompt should have ghuntley identity"
1090 );
1091
1092 let hat_id = HatId::new("builder");
1094 event_loop
1095 .bus
1096 .publish(Event::new("build.task", "Build something"));
1097
1098 let builder_prompt = event_loop.build_prompt(&hat_id).unwrap();
1099
1100 assert!(
1102 builder_prompt.contains("### 0. ORIENTATION"),
1103 "Builder should use ghuntley-style orientation phase"
1104 );
1105 assert!(
1106 builder_prompt.contains("Only 1 subagent for build/tests"),
1107 "Builder prompt should have subagent limit"
1108 );
1109 }
1110
1111 #[test]
1112 fn test_build_prompt_uses_custom_hat_for_non_defaults() {
1113 let yaml = r#"
1115mode: "multi"
1116hats:
1117 reviewer:
1118 name: "Code Reviewer"
1119 triggers: ["review.request"]
1120 instructions: "Review code quality."
1121"#;
1122 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1123 let mut event_loop = EventLoop::new(config);
1124
1125 event_loop
1127 .bus
1128 .publish(Event::new("review.request", "Review PR #123"));
1129
1130 let reviewer_id = HatId::new("reviewer");
1131 let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
1132
1133 assert!(
1135 prompt.contains("Code Reviewer"),
1136 "Custom hat should use its name"
1137 );
1138 assert!(
1139 prompt.contains("Review code quality"),
1140 "Custom hat should include its instructions"
1141 );
1142 assert!(
1144 !prompt.contains("PLANNER MODE"),
1145 "Custom hat should not use planner prompt"
1146 );
1147 assert!(
1148 !prompt.contains("BUILDER MODE"),
1149 "Custom hat should not use builder prompt"
1150 );
1151 }
1152
1153 #[test]
1154 fn test_exit_codes_per_spec() {
1155 assert_eq!(TerminationReason::CompletionPromise.exit_code(), 0);
1161 assert_eq!(TerminationReason::ConsecutiveFailures.exit_code(), 1);
1162 assert_eq!(TerminationReason::LoopThrashing.exit_code(), 1);
1163 assert_eq!(TerminationReason::Stopped.exit_code(), 1);
1164 assert_eq!(TerminationReason::MaxIterations.exit_code(), 2);
1165 assert_eq!(TerminationReason::MaxRuntime.exit_code(), 2);
1166 assert_eq!(TerminationReason::MaxCost.exit_code(), 2);
1167 assert_eq!(TerminationReason::Interrupted.exit_code(), 130);
1168 }
1169
1170 #[test]
1171 fn test_loop_thrashing_detection() {
1172 let config = RalphConfig::default();
1173 let mut event_loop = EventLoop::new(config);
1174 event_loop.initialize("Test");
1175
1176 let planner_id = HatId::new("planner");
1177 let builder_id = HatId::new("builder");
1178
1179 event_loop.process_output(
1181 &planner_id,
1182 "<event topic=\"build.task\">Fix bug</event>",
1183 true,
1184 );
1185
1186 event_loop.process_output(
1188 &builder_id,
1189 "<event topic=\"build.blocked\">Fix bug\nCan't compile</event>",
1190 true,
1191 );
1192 event_loop.process_output(
1193 &builder_id,
1194 "<event topic=\"build.blocked\">Fix bug\nStill can't compile</event>",
1195 true,
1196 );
1197 event_loop.process_output(
1198 &builder_id,
1199 "<event topic=\"build.blocked\">Fix bug\nReally stuck</event>",
1200 true,
1201 );
1202
1203 assert!(
1205 event_loop
1206 .state
1207 .abandoned_tasks
1208 .contains(&"Fix bug".to_string())
1209 );
1210 assert_eq!(event_loop.state.abandoned_task_redispatches, 0);
1211
1212 event_loop.process_output(
1214 &planner_id,
1215 "<event topic=\"build.task\">Fix bug</event>",
1216 true,
1217 );
1218 assert_eq!(event_loop.state.abandoned_task_redispatches, 1);
1219
1220 event_loop.process_output(
1222 &planner_id,
1223 "<event topic=\"build.task\">Fix bug</event>",
1224 true,
1225 );
1226 assert_eq!(event_loop.state.abandoned_task_redispatches, 2);
1227
1228 let reason = event_loop.process_output(
1230 &planner_id,
1231 "<event topic=\"build.task\">Fix bug</event>",
1232 true,
1233 );
1234 assert_eq!(reason, Some(TerminationReason::LoopThrashing));
1235 assert_eq!(event_loop.state.abandoned_task_redispatches, 3);
1236 }
1237
1238 #[test]
1239 fn test_thrashing_counter_resets_on_different_hat() {
1240 let config = RalphConfig::default();
1241 let mut event_loop = EventLoop::new(config);
1242 event_loop.initialize("Test");
1243
1244 let planner_id = HatId::new("planner");
1245 let builder_id = HatId::new("builder");
1246
1247 event_loop.process_output(
1249 &planner_id,
1250 "<event topic=\"build.blocked\">Stuck</event>",
1251 true,
1252 );
1253 event_loop.process_output(
1254 &planner_id,
1255 "<event topic=\"build.blocked\">Still stuck</event>",
1256 true,
1257 );
1258 assert_eq!(event_loop.state.consecutive_blocked, 2);
1259
1260 event_loop.process_output(
1262 &builder_id,
1263 "<event topic=\"build.blocked\">Builder stuck</event>",
1264 true,
1265 );
1266 assert_eq!(event_loop.state.consecutive_blocked, 1);
1267 assert_eq!(event_loop.state.last_blocked_hat, Some(builder_id));
1268 }
1269
1270 #[test]
1271 fn test_thrashing_counter_resets_on_non_blocked_event() {
1272 let config = RalphConfig::default();
1273 let mut event_loop = EventLoop::new(config);
1274 event_loop.initialize("Test");
1275
1276 let planner_id = HatId::new("planner");
1277
1278 event_loop.process_output(
1280 &planner_id,
1281 "<event topic=\"build.blocked\">Stuck</event>",
1282 true,
1283 );
1284 event_loop.process_output(
1285 &planner_id,
1286 "<event topic=\"build.blocked\">Still stuck</event>",
1287 true,
1288 );
1289 assert_eq!(event_loop.state.consecutive_blocked, 2);
1290
1291 event_loop.process_output(
1293 &planner_id,
1294 "<event topic=\"build.task\">Working now</event>",
1295 true,
1296 );
1297 assert_eq!(event_loop.state.consecutive_blocked, 0);
1298 assert_eq!(event_loop.state.last_blocked_hat, None);
1299 }
1300
1301 #[test]
1302 fn test_custom_hat_with_instructions_uses_build_custom_hat() {
1303 let yaml = r#"
1305hats:
1306 reviewer:
1307 name: "Code Reviewer"
1308 triggers: ["review.request"]
1309 instructions: "Review code for quality and security issues."
1310"#;
1311 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1312 let mut event_loop = EventLoop::new(config);
1313
1314 event_loop
1316 .bus
1317 .publish(Event::new("review.request", "Review PR #123"));
1318
1319 let reviewer_id = HatId::new("reviewer");
1320 let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
1321
1322 assert!(
1324 prompt.contains("Code Reviewer"),
1325 "Should include custom hat name"
1326 );
1327 assert!(
1328 prompt.contains("Review code for quality and security issues"),
1329 "Should include custom instructions"
1330 );
1331 assert!(
1332 prompt.contains("### 0. ORIENTATION"),
1333 "Should include ghuntley-style orientation"
1334 );
1335 assert!(
1336 prompt.contains("### 1. EXECUTE"),
1337 "Should use ghuntley-style execute phase"
1338 );
1339 assert!(
1340 prompt.contains("### GUARDRAILS"),
1341 "Should include guardrails section"
1342 );
1343
1344 assert!(
1346 prompt.contains("Review PR #123"),
1347 "Should include event context"
1348 );
1349 }
1350
1351 #[test]
1352 fn test_custom_hat_instructions_included_in_prompt() {
1353 let yaml = r#"
1355hats:
1356 tester:
1357 name: "Test Engineer"
1358 triggers: ["test.request"]
1359 instructions: |
1360 Run comprehensive tests including:
1361 - Unit tests
1362 - Integration tests
1363 - Security scans
1364 Report results with detailed coverage metrics.
1365"#;
1366 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1367 let mut event_loop = EventLoop::new(config);
1368
1369 event_loop
1371 .bus
1372 .publish(Event::new("test.request", "Test the auth module"));
1373
1374 let tester_id = HatId::new("tester");
1375 let prompt = event_loop.build_prompt(&tester_id).unwrap();
1376
1377 assert!(prompt.contains("Run comprehensive tests including"));
1379 assert!(prompt.contains("Unit tests"));
1380 assert!(prompt.contains("Integration tests"));
1381 assert!(prompt.contains("Security scans"));
1382 assert!(prompt.contains("detailed coverage metrics"));
1383
1384 assert!(prompt.contains("Test the auth module"));
1386 }
1387
1388 #[test]
1389 fn test_custom_hat_topology_visible_to_ralph() {
1390 let yaml = r#"
1394hats:
1395 deployer:
1396 name: "Deployment Manager"
1397 triggers: ["deploy.request", "deploy.rollback"]
1398 publishes: ["deploy.done", "deploy.failed"]
1399 instructions: "Handle deployment operations safely."
1400"#;
1401 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1402 let mut event_loop = EventLoop::new(config);
1403
1404 event_loop
1406 .bus
1407 .publish(Event::new("deploy.request", "Deploy to staging"));
1408
1409 let next_hat = event_loop.next_hat();
1411 assert_eq!(
1412 next_hat.unwrap().as_str(),
1413 "ralph",
1414 "Multi-hat mode routes to Ralph"
1415 );
1416
1417 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1419
1420 assert!(
1423 prompt.contains("deploy.request"),
1424 "Ralph's prompt should include the event topic"
1425 );
1426
1427 assert!(
1429 prompt.contains("## HATS"),
1430 "Ralph's prompt should include hat topology"
1431 );
1432 assert!(
1433 prompt.contains("Deployment Manager"),
1434 "Hat topology should include hat name"
1435 );
1436 assert!(
1437 prompt.contains("deploy.request"),
1438 "Hat triggers should be in topology"
1439 );
1440 }
1441
1442 #[test]
1443 fn test_default_hat_with_custom_instructions_uses_build_custom_hat() {
1444 let yaml = r#"
1446hats:
1447 planner:
1448 name: "Custom Planner"
1449 triggers: ["task.start", "build.done"]
1450 instructions: "Custom planning instructions with special focus on security."
1451"#;
1452 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1453 let mut event_loop = EventLoop::new(config);
1454
1455 event_loop.initialize("Test task");
1456
1457 let planner_id = HatId::new("planner");
1458 let prompt = event_loop.build_prompt(&planner_id).unwrap();
1459
1460 assert!(prompt.contains("Custom Planner"), "Should use custom name");
1462 assert!(
1463 prompt.contains("Custom planning instructions with special focus on security"),
1464 "Should include custom instructions"
1465 );
1466 assert!(
1467 prompt.contains("### 1. EXECUTE"),
1468 "Should use ghuntley-style execute phase"
1469 );
1470 assert!(
1471 prompt.contains("### GUARDRAILS"),
1472 "Should include guardrails section"
1473 );
1474 }
1475
1476 #[test]
1477 fn test_custom_hat_without_instructions_gets_default_behavior() {
1478 let yaml = r#"
1480hats:
1481 monitor:
1482 name: "System Monitor"
1483 triggers: ["monitor.request"]
1484"#;
1485 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1486 let mut event_loop = EventLoop::new(config);
1487
1488 event_loop
1489 .bus
1490 .publish(Event::new("monitor.request", "Check system health"));
1491
1492 let monitor_id = HatId::new("monitor");
1493 let prompt = event_loop.build_prompt(&monitor_id).unwrap();
1494
1495 assert!(
1497 prompt.contains("System Monitor"),
1498 "Should include custom hat name"
1499 );
1500 assert!(
1501 prompt.contains("Follow the incoming event instructions"),
1502 "Should have default instructions when none provided"
1503 );
1504 assert!(
1505 prompt.contains("### 0. ORIENTATION"),
1506 "Should include ghuntley-style orientation"
1507 );
1508 assert!(
1509 prompt.contains("### GUARDRAILS"),
1510 "Should include guardrails section"
1511 );
1512 assert!(
1513 prompt.contains("Check system health"),
1514 "Should include event context"
1515 );
1516 }
1517
1518 #[test]
1519 fn test_task_cancellation_with_tilde_marker() {
1520 let config = RalphConfig::default();
1522 let mut event_loop = EventLoop::new(config);
1523 event_loop.initialize("Test task");
1524
1525 let ralph_id = HatId::new("ralph");
1526
1527 let output = r"
1529## Tasks
1530- [x] Task 1 completed
1531- [~] Task 2 cancelled (too complex for current scope)
1532- [ ] Task 3 pending
1533";
1534
1535 let reason = event_loop.process_output(&ralph_id, output, true);
1537 assert_eq!(reason, None, "Should not terminate with pending tasks");
1538 }
1539
1540 #[test]
1541 fn test_partial_completion_with_cancelled_tasks() {
1542 use std::fs;
1543 use std::path::Path;
1544
1545 let yaml = r#"
1547hats:
1548 builder:
1549 name: "Builder"
1550 triggers: ["build.task"]
1551 publishes: ["build.done"]
1552"#;
1553 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1554 let mut event_loop = EventLoop::new(config);
1555 event_loop.initialize("Test task");
1556
1557 let ralph_id = HatId::new("ralph");
1559
1560 let scratchpad_path = Path::new(".agent/scratchpad.md");
1562 fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
1563 let scratchpad_content = r"## Tasks
1564- [x] Core feature implemented
1565- [x] Tests added
1566- [~] Documentation update (cancelled: out of scope)
1567- [~] Performance optimization (cancelled: not needed)
1568";
1569 fs::write(scratchpad_path, scratchpad_content).unwrap();
1570
1571 let output = "All done! LOOP_COMPLETE";
1573
1574 let reason = event_loop.process_output(&ralph_id, output, true);
1576 assert_eq!(reason, None, "First confirmation should not terminate");
1577
1578 let reason = event_loop.process_output(&ralph_id, output, true);
1580 assert_eq!(
1581 reason,
1582 Some(TerminationReason::CompletionPromise),
1583 "Should complete with partial completion"
1584 );
1585
1586 fs::remove_file(scratchpad_path).ok();
1588 }
1589
1590 #[test]
1591 fn test_planner_auto_cancellation_after_three_blocks() {
1592 let config = RalphConfig::default();
1594 let mut event_loop = EventLoop::new(config);
1595 event_loop.initialize("Test task");
1596
1597 let builder_id = HatId::new("builder");
1598 let planner_id = HatId::new("planner");
1599
1600 let reason = event_loop.process_output(
1602 &builder_id,
1603 "<event topic=\"build.blocked\">Task X\nmissing dependency</event>",
1604 true,
1605 );
1606 assert_eq!(reason, None);
1607 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&1));
1608
1609 let reason = event_loop.process_output(
1611 &builder_id,
1612 "<event topic=\"build.blocked\">Task X\ndependency issue persists</event>",
1613 true,
1614 );
1615 assert_eq!(reason, None);
1616 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&2));
1617
1618 let reason = event_loop.process_output(
1620 &builder_id,
1621 "<event topic=\"build.blocked\">Task X\nsame dependency issue</event>",
1622 true,
1623 );
1624 assert_eq!(reason, None, "Should not terminate, just abandon task");
1625 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&3));
1626 assert!(
1627 event_loop
1628 .state
1629 .abandoned_tasks
1630 .contains(&"Task X".to_string()),
1631 "Task X should be abandoned"
1632 );
1633
1634 event_loop.process_output(
1637 &planner_id,
1638 "<event topic=\"build.task\">Task X</event>",
1639 true,
1640 );
1641 assert_eq!(event_loop.state.abandoned_task_redispatches, 1);
1642
1643 event_loop.process_output(
1644 &planner_id,
1645 "<event topic=\"build.task\">Task X</event>",
1646 true,
1647 );
1648 assert_eq!(event_loop.state.abandoned_task_redispatches, 2);
1649
1650 let reason = event_loop.process_output(
1651 &planner_id,
1652 "<event topic=\"build.task\">Task X</event>",
1653 true,
1654 );
1655 assert_eq!(
1656 reason,
1657 Some(TerminationReason::LoopThrashing),
1658 "Should terminate after 3 redispatches of abandoned task"
1659 );
1660 }
1661
1662 #[test]
1663 fn test_default_publishes_injects_when_no_events() {
1664 use std::collections::HashMap;
1665 use tempfile::tempdir;
1666
1667 let temp_dir = tempdir().unwrap();
1668 let events_path = temp_dir.path().join("events.jsonl");
1669
1670 let mut config = RalphConfig::default();
1671 let mut hats = HashMap::new();
1672 hats.insert(
1673 "test-hat".to_string(),
1674 crate::config::HatConfig {
1675 name: "test-hat".to_string(),
1676 description: Some("Test hat for default publishes".to_string()),
1677 triggers: vec!["task.start".to_string()],
1678 publishes: vec!["task.done".to_string()],
1679 instructions: "Test hat".to_string(),
1680 backend: None,
1681 default_publishes: Some("task.done".to_string()),
1682 },
1683 );
1684 config.hats = hats;
1685
1686 let mut event_loop = EventLoop::new(config);
1687 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1688 event_loop.initialize("Test");
1689
1690 let hat_id = HatId::new("test-hat");
1691
1692 let before = event_loop.record_event_count();
1694
1695 event_loop.check_default_publishes(&hat_id, before);
1700
1701 assert!(
1703 event_loop.has_pending_events(),
1704 "Default event should be injected"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_default_publishes_not_injected_when_events_written() {
1710 use std::collections::HashMap;
1711 use std::io::Write;
1712 use tempfile::tempdir;
1713
1714 let temp_dir = tempdir().unwrap();
1715 let events_path = temp_dir.path().join("events.jsonl");
1716
1717 let mut config = RalphConfig::default();
1718 let mut hats = HashMap::new();
1719 hats.insert(
1720 "test-hat".to_string(),
1721 crate::config::HatConfig {
1722 name: "test-hat".to_string(),
1723 description: Some("Test hat for default publishes".to_string()),
1724 triggers: vec!["task.start".to_string()],
1725 publishes: vec!["task.done".to_string()],
1726 instructions: "Test hat".to_string(),
1727 backend: None,
1728 default_publishes: Some("task.done".to_string()),
1729 },
1730 );
1731 config.hats = hats;
1732
1733 let mut event_loop = EventLoop::new(config);
1734 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1735 event_loop.initialize("Test");
1736
1737 let hat_id = HatId::new("test-hat");
1738
1739 let before = event_loop.record_event_count();
1741
1742 let mut file = std::fs::File::create(&events_path).unwrap();
1744 writeln!(
1745 file,
1746 r#"{{"topic":"task.done","ts":"2024-01-01T00:00:00Z"}}"#
1747 )
1748 .unwrap();
1749 file.flush().unwrap();
1750
1751 event_loop.check_default_publishes(&hat_id, before);
1753
1754 }
1757
1758 #[test]
1759 fn test_default_publishes_not_injected_when_not_configured() {
1760 use std::collections::HashMap;
1761 use tempfile::tempdir;
1762
1763 let temp_dir = tempdir().unwrap();
1764 let events_path = temp_dir.path().join("events.jsonl");
1765
1766 let mut config = RalphConfig::default();
1767 let mut hats = HashMap::new();
1768 hats.insert(
1769 "test-hat".to_string(),
1770 crate::config::HatConfig {
1771 name: "test-hat".to_string(),
1772 description: Some("Test hat for default publishes".to_string()),
1773 triggers: vec!["task.start".to_string()],
1774 publishes: vec!["task.done".to_string()],
1775 instructions: "Test hat".to_string(),
1776 backend: None,
1777 default_publishes: None, },
1779 );
1780 config.hats = hats;
1781
1782 let mut event_loop = EventLoop::new(config);
1783 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1784 event_loop.initialize("Test");
1785
1786 let hat_id = HatId::new("test-hat");
1787
1788 let _ = event_loop.build_prompt(&hat_id);
1790
1791 let before = event_loop.record_event_count();
1793
1794 event_loop.check_default_publishes(&hat_id, before);
1798
1799 assert!(
1801 !event_loop.has_pending_events(),
1802 "No default should be injected"
1803 );
1804 }
1805
1806 #[test]
1807 fn test_get_hat_backend_with_named_backend() {
1808 let yaml = r#"
1809hats:
1810 builder:
1811 name: "Builder"
1812 triggers: ["build.task"]
1813 backend: "claude"
1814"#;
1815 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1816 let event_loop = EventLoop::new(config);
1817
1818 let hat_id = HatId::new("builder");
1819 let backend = event_loop.get_hat_backend(&hat_id);
1820
1821 assert!(backend.is_some());
1822 match backend.unwrap() {
1823 HatBackend::Named(name) => assert_eq!(name, "claude"),
1824 _ => panic!("Expected Named backend"),
1825 }
1826 }
1827
1828 #[test]
1829 fn test_get_hat_backend_with_kiro_agent() {
1830 let yaml = r#"
1831hats:
1832 builder:
1833 name: "Builder"
1834 triggers: ["build.task"]
1835 backend:
1836 type: "kiro"
1837 agent: "my-agent"
1838"#;
1839 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1840 let event_loop = EventLoop::new(config);
1841
1842 let hat_id = HatId::new("builder");
1843 let backend = event_loop.get_hat_backend(&hat_id);
1844
1845 assert!(backend.is_some());
1846 match backend.unwrap() {
1847 HatBackend::KiroAgent { agent, .. } => assert_eq!(agent, "my-agent"),
1848 _ => panic!("Expected KiroAgent backend"),
1849 }
1850 }
1851
1852 #[test]
1853 fn test_get_hat_backend_inherits_global() {
1854 let yaml = r#"
1855cli:
1856 backend: "gemini"
1857hats:
1858 builder:
1859 name: "Builder"
1860 triggers: ["build.task"]
1861"#;
1862 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1863 let event_loop = EventLoop::new(config);
1864
1865 let hat_id = HatId::new("builder");
1866 let backend = event_loop.get_hat_backend(&hat_id);
1867
1868 assert!(backend.is_none());
1870 }
1871
1872 #[test]
1873 fn test_hatless_mode_registers_ralph_catch_all() {
1874 let config = RalphConfig::default();
1876 let mut event_loop = EventLoop::new(config);
1877
1878 assert!(event_loop.registry().is_empty());
1880
1881 event_loop.initialize("Test prompt");
1883
1884 let next_hat = event_loop.next_hat();
1886 assert!(next_hat.is_some(), "Should have pending events for ralph");
1887 assert_eq!(next_hat.unwrap().as_str(), "ralph");
1888 }
1889
1890 #[test]
1891 fn test_hatless_mode_builds_ralph_prompt() {
1892 let config = RalphConfig::default();
1894 let mut event_loop = EventLoop::new(config);
1895 event_loop.initialize("Test prompt");
1896
1897 let ralph_id = HatId::new("ralph");
1898 let prompt = event_loop.build_prompt(&ralph_id);
1899
1900 assert!(prompt.is_some(), "Should build prompt for ralph");
1901 let prompt = prompt.unwrap();
1902
1903 assert!(
1905 prompt.contains("I'm Ralph"),
1906 "Should identify as Ralph with ghuntley style"
1907 );
1908 assert!(
1909 prompt.contains("## WORKFLOW"),
1910 "Should have workflow section"
1911 );
1912 assert!(
1913 prompt.contains("## EVENT WRITING"),
1914 "Should have event writing section"
1915 );
1916 assert!(
1917 prompt.contains("LOOP_COMPLETE"),
1918 "Should reference completion promise"
1919 );
1920 }
1921
1922 #[test]
1929 fn test_always_hatless_ralph_executes_all_iterations() {
1930 let yaml = r#"
1932hats:
1933 planner:
1934 name: "Planner"
1935 triggers: ["task.start", "build.done"]
1936 publishes: ["build.task"]
1937 builder:
1938 name: "Builder"
1939 triggers: ["build.task"]
1940 publishes: ["build.done"]
1941"#;
1942 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1943 let mut event_loop = EventLoop::new(config);
1944
1945 event_loop.initialize("Implement feature X");
1947 assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1948
1949 event_loop.build_prompt(&HatId::new("ralph")); event_loop
1952 .bus
1953 .publish(Event::new("build.task", "Build feature X"));
1954 assert_eq!(
1955 event_loop.next_hat().unwrap().as_str(),
1956 "ralph",
1957 "build.task should route to Ralph"
1958 );
1959
1960 event_loop.build_prompt(&HatId::new("ralph")); event_loop
1963 .bus
1964 .publish(Event::new("build.done", "Feature X complete"));
1965 assert_eq!(
1966 event_loop.next_hat().unwrap().as_str(),
1967 "ralph",
1968 "build.done should route to Ralph"
1969 );
1970 }
1971
1972 #[test]
1973 fn test_always_hatless_solo_mode_unchanged() {
1974 let config = RalphConfig::default();
1976 let mut event_loop = EventLoop::new(config);
1977
1978 assert!(
1979 event_loop.registry().is_empty(),
1980 "Solo mode has no custom hats"
1981 );
1982
1983 event_loop.initialize("Do something");
1984 assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1985
1986 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1988 assert!(
1989 !prompt.contains("## HATS"),
1990 "Solo mode should not have HATS section"
1991 );
1992 }
1993
1994 #[test]
1995 fn test_always_hatless_topology_preserved_in_prompt() {
1996 let yaml = r#"
1998hats:
1999 planner:
2000 name: "Planner"
2001 triggers: ["task.start", "build.done", "build.blocked"]
2002 publishes: ["build.task"]
2003 builder:
2004 name: "Builder"
2005 triggers: ["build.task"]
2006 publishes: ["build.done", "build.blocked"]
2007"#;
2008 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2009 let mut event_loop = EventLoop::new(config);
2010 event_loop.initialize("Test");
2011
2012 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
2013
2014 assert!(prompt.contains("## HATS"), "Should have HATS section");
2016 assert!(
2017 prompt.contains("Delegate via events"),
2018 "Should explain delegation"
2019 );
2020 assert!(
2021 prompt.contains("| Hat | Triggers On | Publishes |"),
2022 "Should have topology table"
2023 );
2024
2025 assert!(prompt.contains("Planner"), "Should include Planner hat");
2027 assert!(prompt.contains("Builder"), "Should include Builder hat");
2028
2029 assert!(
2031 prompt.contains("build.task"),
2032 "Should document build.task event"
2033 );
2034 assert!(
2035 prompt.contains("build.done"),
2036 "Should document build.done event"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_always_hatless_no_backend_delegation() {
2042 let yaml = r#"
2046hats:
2047 builder:
2048 name: "Builder"
2049 triggers: ["build.task"]
2050 backend: "gemini" # This backend should NEVER be used
2051"#;
2052 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2053 let mut event_loop = EventLoop::new(config);
2054
2055 event_loop.bus.publish(Event::new("build.task", "Test"));
2056
2057 let next = event_loop.next_hat();
2059 assert_eq!(
2060 next.unwrap().as_str(),
2061 "ralph",
2062 "Ralph handles all iterations"
2063 );
2064
2065 }
2068
2069 #[test]
2070 fn test_always_hatless_collects_all_pending_events() {
2071 let yaml = r#"
2073hats:
2074 planner:
2075 name: "Planner"
2076 triggers: ["task.start"]
2077 builder:
2078 name: "Builder"
2079 triggers: ["build.task"]
2080"#;
2081 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2082 let mut event_loop = EventLoop::new(config);
2083
2084 event_loop
2086 .bus
2087 .publish(Event::new("task.start", "Start task"));
2088 event_loop
2089 .bus
2090 .publish(Event::new("build.task", "Build something"));
2091
2092 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
2094
2095 assert!(
2097 prompt.contains("task.start"),
2098 "Should include task.start event"
2099 );
2100 assert!(
2101 prompt.contains("build.task"),
2102 "Should include build.task event"
2103 );
2104 }
2105
2106 #[test]
2109 fn test_determine_active_hats() {
2110 let yaml = r#"
2112hats:
2113 security_reviewer:
2114 name: "Security Reviewer"
2115 triggers: ["review.security"]
2116 architecture_reviewer:
2117 name: "Architecture Reviewer"
2118 triggers: ["review.architecture"]
2119 correctness_reviewer:
2120 name: "Correctness Reviewer"
2121 triggers: ["review.correctness"]
2122"#;
2123 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2124 let event_loop = EventLoop::new(config);
2125
2126 let events = vec![
2128 Event::new("review.security", "Check for vulnerabilities"),
2129 Event::new("review.architecture", "Review design patterns"),
2130 ];
2131
2132 let active_hats = event_loop.determine_active_hats(&events);
2134
2135 assert_eq!(active_hats.len(), 2, "Should return exactly 2 active hats");
2137
2138 let hat_ids: Vec<&str> = active_hats.iter().map(|h| h.id.as_str()).collect();
2139 assert!(
2140 hat_ids.contains(&"security_reviewer"),
2141 "Should include security_reviewer"
2142 );
2143 assert!(
2144 hat_ids.contains(&"architecture_reviewer"),
2145 "Should include architecture_reviewer"
2146 );
2147 assert!(
2148 !hat_ids.contains(&"correctness_reviewer"),
2149 "Should NOT include correctness_reviewer"
2150 );
2151 }
2152
2153 #[test]
2154 fn test_get_active_hat_id_with_pending_event() {
2155 let yaml = r#"
2157hats:
2158 security_reviewer:
2159 name: "Security Reviewer"
2160 triggers: ["review.security"]
2161"#;
2162 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2163 let mut event_loop = EventLoop::new(config);
2164
2165 event_loop
2167 .bus
2168 .publish(Event::new("review.security", "Check authentication"));
2169
2170 let active_hat_id = event_loop.get_active_hat_id();
2172
2173 assert_eq!(
2175 active_hat_id.as_str(),
2176 "security_reviewer",
2177 "Should return security_reviewer, not ralph"
2178 );
2179 }
2180
2181 #[test]
2182 fn test_get_active_hat_id_no_pending_returns_ralph() {
2183 let yaml = r#"
2185hats:
2186 security_reviewer:
2187 name: "Security Reviewer"
2188 triggers: ["review.security"]
2189"#;
2190 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2191 let event_loop = EventLoop::new(config);
2192
2193 let active_hat_id = event_loop.get_active_hat_id();
2195
2196 assert_eq!(
2198 active_hat_id.as_str(),
2199 "ralph",
2200 "Should return ralph when no pending events"
2201 );
2202 }
2203}