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 event_reader = EventReader::new(".agent/events.jsonl");
192
193 Self {
194 config,
195 registry,
196 bus,
197 state: LoopState::new(),
198 instruction_builder,
199 ralph,
200 event_reader,
201 }
202 }
203
204 pub fn state(&self) -> &LoopState {
206 &self.state
207 }
208
209 pub fn config(&self) -> &RalphConfig {
211 &self.config
212 }
213
214 pub fn registry(&self) -> &HatRegistry {
216 &self.registry
217 }
218
219 pub fn get_hat_backend(&self, hat_id: &HatId) -> Option<&HatBackend> {
224 self.registry
225 .get_config(hat_id)
226 .and_then(|config| config.backend.as_ref())
227 }
228
229 pub fn add_observer<F>(&mut self, observer: F)
234 where
235 F: Fn(&Event) + Send + 'static,
236 {
237 self.bus.add_observer(observer);
238 }
239
240 #[deprecated(since = "2.0.0", note = "Use add_observer instead")]
244 pub fn set_observer<F>(&mut self, observer: F)
245 where
246 F: Fn(&Event) + Send + 'static,
247 {
248 #[allow(deprecated)]
249 self.bus.set_observer(observer);
250 }
251
252 pub fn check_termination(&self) -> Option<TerminationReason> {
254 let cfg = &self.config.event_loop;
255
256 if self.state.iteration >= cfg.max_iterations {
257 return Some(TerminationReason::MaxIterations);
258 }
259
260 if self.state.elapsed().as_secs() >= cfg.max_runtime_seconds {
261 return Some(TerminationReason::MaxRuntime);
262 }
263
264 if let Some(max_cost) = cfg.max_cost_usd
265 && self.state.cumulative_cost >= max_cost
266 {
267 return Some(TerminationReason::MaxCost);
268 }
269
270 if self.state.consecutive_failures >= cfg.max_consecutive_failures {
271 return Some(TerminationReason::ConsecutiveFailures);
272 }
273
274 if self.state.abandoned_task_redispatches >= 3 {
276 return Some(TerminationReason::LoopThrashing);
277 }
278
279 if self.state.consecutive_malformed_events >= 3 {
281 return Some(TerminationReason::ValidationFailure);
282 }
283
284 None
285 }
286
287 pub fn initialize(&mut self, prompt_content: &str) {
289 self.initialize_with_topic("task.start", prompt_content);
290 }
291
292 pub fn initialize_resume(&mut self, prompt_content: &str) {
297 self.initialize_with_topic("task.resume", prompt_content);
298 }
299
300 fn initialize_with_topic(&mut self, topic: &str, prompt_content: &str) {
302 let start_event = Event::new(topic, prompt_content);
306 self.bus.publish(start_event);
307 debug!(topic = topic, "Published {} event", topic);
308 }
309
310 pub fn next_hat(&self) -> Option<&HatId> {
319 let next = self.bus.next_hat_with_pending();
320
321 next.as_ref()?;
323
324 if self.registry.is_empty() {
327 next
329 } else {
330 self.bus.hat_ids().find(|id| id.as_str() == "ralph")
333 }
334 }
335
336 pub fn has_pending_events(&self) -> bool {
341 self.bus.next_hat_with_pending().is_some()
342 }
343
344 pub fn get_hat_publishes(&self, hat_id: &HatId) -> Vec<String> {
348 self.registry
349 .get(hat_id)
350 .map(|hat| hat.publishes.iter().map(|t| t.to_string()).collect())
351 .unwrap_or_default()
352 }
353
354 pub fn inject_fallback_event(&mut self) -> bool {
361 let fallback_event = Event::new(
362 "task.resume",
363 "RECOVERY: Previous iteration did not publish an event. \
364 Review the scratchpad and either dispatch the next task or complete the loop.",
365 );
366
367 let fallback_event = match &self.state.last_hat {
370 Some(hat_id) if hat_id.as_str() != "ralph" => {
371 debug!(
372 hat = %hat_id.as_str(),
373 "Injecting fallback event to recover - targeting last hat with task.resume"
374 );
375 fallback_event.with_target(hat_id.clone())
376 }
377 _ => {
378 debug!("Injecting fallback event to recover - triggering Ralph with task.resume");
379 fallback_event
380 }
381 };
382
383 self.bus.publish(fallback_event);
384 true
385 }
386
387 pub fn build_prompt(&mut self, hat_id: &HatId) -> Option<String> {
397 if hat_id.as_str() == "ralph" {
400 if self.registry.is_empty() {
401 let events = self.bus.take_pending(&hat_id.clone());
403 let events_context = events
404 .iter()
405 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
406 .collect::<Vec<_>>()
407 .join("\n");
408
409 debug!("build_prompt: routing to HatlessRalph (solo mode)");
410 return Some(self.ralph.build_prompt(&events_context, &[]));
411 } else {
412 let all_hat_ids: Vec<HatId> = self.bus.hat_ids().cloned().collect();
414 let mut all_events = Vec::new();
415 for id in all_hat_ids {
416 all_events.extend(self.bus.take_pending(&id));
417 }
418
419 let active_hats = self.determine_active_hats(&all_events);
421
422 let events_context = all_events
424 .iter()
425 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
426 .collect::<Vec<_>>()
427 .join("\n");
428
429 debug!(
431 "build_prompt: routing to HatlessRalph (multi-hat coordinator mode), active_hats: {:?}",
432 active_hats
433 .iter()
434 .map(|h| h.id.as_str())
435 .collect::<Vec<_>>()
436 );
437 return Some(self.ralph.build_prompt(&events_context, &active_hats));
438 }
439 }
440
441 let events = self.bus.take_pending(&hat_id.clone());
445 let events_context = events
446 .iter()
447 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
448 .collect::<Vec<_>>()
449 .join("\n");
450
451 let hat = self.registry.get(hat_id)?;
452
453 debug!(
455 "build_prompt: hat_id='{}', instructions.is_empty()={}",
456 hat_id.as_str(),
457 hat.instructions.is_empty()
458 );
459
460 debug!(
462 "build_prompt: routing to build_custom_hat() for '{}'",
463 hat_id.as_str()
464 );
465 Some(
466 self.instruction_builder
467 .build_custom_hat(hat, &events_context),
468 )
469 }
470
471 pub fn build_ralph_prompt(&self, prompt_content: &str) -> String {
473 self.ralph.build_prompt(prompt_content, &[])
474 }
475
476 fn determine_active_hats(&self, events: &[Event]) -> Vec<&Hat> {
479 let mut active_hats = Vec::new();
480 for event in events {
481 if let Some(hat) = self.registry.get_for_topic(event.topic.as_str()) {
482 if !active_hats.iter().any(|h: &&Hat| h.id == hat.id) {
484 active_hats.push(hat);
485 }
486 }
487 }
488 active_hats
489 }
490
491 pub fn get_active_hat_id(&self) -> HatId {
494 for hat_id in self.bus.hat_ids() {
496 let Some(events) = self.bus.peek_pending(hat_id) else {
497 continue;
498 };
499 let Some(event) = events.first() else {
500 continue;
501 };
502 if let Some(active_hat) = self.registry.get_for_topic(event.topic.as_str()) {
503 return active_hat.id.clone();
504 }
505 }
506 HatId::new("ralph")
507 }
508
509 pub fn record_event_count(&mut self) -> usize {
514 self.event_reader
515 .read_new_events()
516 .map(|r| r.events.len())
517 .unwrap_or(0)
518 }
519
520 pub fn check_default_publishes(&mut self, hat_id: &HatId, _events_before: usize) {
526 let events_after = self
527 .event_reader
528 .read_new_events()
529 .map(|r| r.events.len())
530 .unwrap_or(0);
531
532 if events_after == 0
533 && let Some(config) = self.registry.get_config(hat_id)
534 && let Some(default_topic) = &config.default_publishes
535 {
536 let default_event = Event::new(default_topic.as_str(), "").with_source(hat_id.clone());
538
539 debug!(
540 hat = %hat_id.as_str(),
541 topic = %default_topic,
542 "No events written by hat, injecting default_publishes event"
543 );
544
545 self.bus.publish(default_event);
546 }
547 }
548
549 pub fn process_output(
553 &mut self,
554 hat_id: &HatId,
555 output: &str,
556 success: bool,
557 ) -> Option<TerminationReason> {
558 self.state.iteration += 1;
559 self.state.last_hat = Some(hat_id.clone());
560
561 if success {
563 self.state.consecutive_failures = 0;
564 } else {
565 self.state.consecutive_failures += 1;
566 }
567
568 if hat_id.as_str() == "ralph"
571 && EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
572 {
573 match self.verify_scratchpad_complete() {
575 Ok(true) => {
576 self.state.completion_confirmations += 1;
578
579 if self.state.completion_confirmations >= 2 {
580 info!(
582 confirmations = self.state.completion_confirmations,
583 "Completion confirmed on consecutive iterations - terminating"
584 );
585 return Some(TerminationReason::CompletionPromise);
586 }
587 info!(
589 confirmations = self.state.completion_confirmations,
590 "Completion detected but requires consecutive confirmation - continuing"
591 );
592 }
593 Ok(false) => {
594 debug!(
596 "Completion promise detected but scratchpad has pending [ ] tasks - rejected"
597 );
598 self.state.completion_confirmations = 0;
599 }
600 Err(e) => {
601 debug!(
603 error = %e,
604 "Completion promise detected but scratchpad verification failed - rejected"
605 );
606 self.state.completion_confirmations = 0;
607 }
608 }
609 }
610
611 let parser = EventParser::new().with_source(hat_id.clone());
613 let events = parser.parse(output);
614
615 let mut validated_events = Vec::new();
617 for event in events {
618 if event.topic.as_str() == "build.done" {
619 if let Some(evidence) = EventParser::parse_backpressure_evidence(&event.payload) {
620 if evidence.all_passed() {
621 validated_events.push(event);
622 } else {
623 warn!(
625 hat = %hat_id.as_str(),
626 tests = evidence.tests_passed,
627 lint = evidence.lint_passed,
628 typecheck = evidence.typecheck_passed,
629 "build.done rejected: backpressure checks failed"
630 );
631 let blocked = Event::new(
632 "build.blocked",
633 "Backpressure checks failed. Fix tests/lint/typecheck before emitting build.done."
634 ).with_source(hat_id.clone());
635 validated_events.push(blocked);
636 }
637 } else {
638 warn!(
640 hat = %hat_id.as_str(),
641 "build.done rejected: missing backpressure evidence"
642 );
643 let blocked = Event::new(
644 "build.blocked",
645 "Missing backpressure evidence. Include 'tests: pass', 'lint: pass', 'typecheck: pass' in build.done payload."
646 ).with_source(hat_id.clone());
647 validated_events.push(blocked);
648 }
649 } else {
650 validated_events.push(event);
651 }
652 }
653
654 let blocked_events: Vec<_> = validated_events
656 .iter()
657 .filter(|e| e.topic == "build.blocked".into())
658 .collect();
659
660 for blocked_event in &blocked_events {
661 let task_id = Self::extract_task_id(&blocked_event.payload);
663
664 let count = self
666 .state
667 .task_block_counts
668 .entry(task_id.clone())
669 .or_insert(0);
670 *count += 1;
671
672 debug!(
673 task_id = %task_id,
674 block_count = *count,
675 "Task blocked"
676 );
677
678 if *count >= 3 && !self.state.abandoned_tasks.contains(&task_id) {
680 warn!(
681 task_id = %task_id,
682 "Task abandoned after 3 consecutive blocks"
683 );
684
685 self.state.abandoned_tasks.push(task_id.clone());
686
687 let abandoned_event = Event::new(
688 "build.task.abandoned",
689 format!(
690 "Task '{}' abandoned after 3 consecutive build.blocked events",
691 task_id
692 ),
693 )
694 .with_source(hat_id.clone());
695
696 self.bus.publish(abandoned_event);
697 }
698 }
699
700 let task_events: Vec<_> = validated_events
702 .iter()
703 .filter(|e| e.topic == "build.task".into())
704 .collect();
705
706 for task_event in task_events {
707 let task_id = Self::extract_task_id(&task_event.payload);
708
709 if self.state.abandoned_tasks.contains(&task_id) {
711 self.state.abandoned_task_redispatches += 1;
712 warn!(
713 task_id = %task_id,
714 redispatch_count = self.state.abandoned_task_redispatches,
715 "Planner redispatched abandoned task"
716 );
717 } else {
718 self.state.abandoned_task_redispatches = 0;
720 }
721 }
722
723 let has_blocked_event = !blocked_events.is_empty();
725
726 if has_blocked_event {
727 if self.state.last_blocked_hat.as_ref() == Some(hat_id) {
729 self.state.consecutive_blocked += 1;
730 } else {
731 self.state.consecutive_blocked = 1;
732 self.state.last_blocked_hat = Some(hat_id.clone());
733 }
734 } else {
735 self.state.consecutive_blocked = 0;
737 self.state.last_blocked_hat = None;
738 }
739
740 for event in validated_events {
741 debug!(
742 topic = %event.topic,
743 source = ?event.source,
744 target = ?event.target,
745 "Publishing event from output"
746 );
747 let topic = event.topic.clone();
748 let recipients = self.bus.publish(event);
749
750 if recipients.is_empty() {
752 warn!(
753 topic = %topic,
754 source = %hat_id.as_str(),
755 "Event has no subscribers - will be dropped. Check hat triggers configuration."
756 );
757 }
758 }
759
760 self.check_termination()
762 }
763
764 fn extract_task_id(payload: &str) -> String {
767 payload
768 .lines()
769 .next()
770 .unwrap_or("unknown")
771 .trim()
772 .to_string()
773 }
774
775 pub fn add_cost(&mut self, cost: f64) {
777 self.state.cumulative_cost += cost;
778 }
779
780 fn verify_scratchpad_complete(&self) -> Result<bool, std::io::Error> {
787 use std::path::Path;
788
789 let scratchpad_path = Path::new(&self.config.core.scratchpad);
790
791 if !scratchpad_path.exists() {
792 return Err(std::io::Error::new(
793 std::io::ErrorKind::NotFound,
794 "Scratchpad does not exist",
795 ));
796 }
797
798 let content = std::fs::read_to_string(scratchpad_path)?;
799
800 let has_pending = content
801 .lines()
802 .any(|line| line.trim_start().starts_with("- [ ]"));
803
804 Ok(!has_pending)
805 }
806
807 pub fn process_events_from_jsonl(&mut self) -> std::io::Result<bool> {
816 let result = self.event_reader.read_new_events()?;
817
818 for malformed in &result.malformed {
820 let payload = format!(
821 "Line {}: {}\nContent: {}",
822 malformed.line_number, malformed.error, &malformed.content
823 );
824 let event = Event::new("event.malformed", &payload);
825 self.bus.publish(event);
826 self.state.consecutive_malformed_events += 1;
827 warn!(
828 line = malformed.line_number,
829 consecutive = self.state.consecutive_malformed_events,
830 "Malformed event line detected"
831 );
832 }
833
834 if !result.events.is_empty() {
836 self.state.consecutive_malformed_events = 0;
837 }
838
839 if result.events.is_empty() && result.malformed.is_empty() {
840 return Ok(false);
841 }
842
843 let mut has_orphans = false;
844
845 for event in result.events {
846 if self.registry.has_subscriber(&event.topic) {
848 let proto_event = if let Some(payload) = event.payload {
850 Event::new(event.topic.as_str(), &payload)
851 } else {
852 Event::new(event.topic.as_str(), "")
853 };
854 self.bus.publish(proto_event);
855 } else {
856 debug!(
858 topic = %event.topic,
859 "Event has no subscriber - will be handled by Ralph"
860 );
861 has_orphans = true;
862 }
863 }
864
865 Ok(has_orphans)
866 }
867
868 pub fn check_ralph_completion(&self, output: &str) -> bool {
872 EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
873 }
874
875 pub fn publish_terminate_event(&mut self, reason: &TerminationReason) -> Event {
882 let elapsed = self.state.elapsed();
883 let duration_str = format_duration(elapsed);
884
885 let payload = format!(
886 "## Reason\n{}\n\n## Status\n{}\n\n## Summary\n- Iterations: {}\n- Duration: {}\n- Exit code: {}",
887 reason.as_str(),
888 termination_status_text(reason),
889 self.state.iteration,
890 duration_str,
891 reason.exit_code()
892 );
893
894 let event = Event::new("loop.terminate", &payload);
895
896 self.bus.publish(event.clone());
898
899 info!(
900 reason = %reason.as_str(),
901 iterations = self.state.iteration,
902 duration = %duration_str,
903 "Wrapping up: {}. {} iterations in {}.",
904 reason.as_str(),
905 self.state.iteration,
906 duration_str
907 );
908
909 event
910 }
911}
912
913fn format_duration(d: Duration) -> String {
915 let total_secs = d.as_secs();
916 let hours = total_secs / 3600;
917 let minutes = (total_secs % 3600) / 60;
918 let seconds = total_secs % 60;
919
920 if hours > 0 {
921 format!("{}h {}m {}s", hours, minutes, seconds)
922 } else if minutes > 0 {
923 format!("{}m {}s", minutes, seconds)
924 } else {
925 format!("{}s", seconds)
926 }
927}
928
929fn termination_status_text(reason: &TerminationReason) -> &'static str {
931 match reason {
932 TerminationReason::CompletionPromise => "All tasks completed successfully.",
933 TerminationReason::MaxIterations => "Stopped at iteration limit.",
934 TerminationReason::MaxRuntime => "Stopped at runtime limit.",
935 TerminationReason::MaxCost => "Stopped at cost limit.",
936 TerminationReason::ConsecutiveFailures => "Too many consecutive failures.",
937 TerminationReason::LoopThrashing => {
938 "Loop thrashing detected - same hat repeatedly blocked."
939 }
940 TerminationReason::ValidationFailure => "Too many consecutive malformed JSONL events.",
941 TerminationReason::Stopped => "Manually stopped.",
942 TerminationReason::Interrupted => "Interrupted by signal.",
943 }
944}
945
946#[cfg(test)]
947mod tests {
948 use super::*;
949
950 #[test]
951 fn test_initialization_routes_to_ralph_in_multihat_mode() {
952 let yaml = r#"
955hats:
956 planner:
957 name: "Planner"
958 triggers: ["task.start", "build.done", "build.blocked"]
959 publishes: ["build.task"]
960"#;
961 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
962 let mut event_loop = EventLoop::new(config);
963
964 event_loop.initialize("Test prompt");
965
966 let next = event_loop.next_hat();
968 assert!(next.is_some());
969 assert_eq!(
970 next.unwrap().as_str(),
971 "ralph",
972 "Multi-hat mode should route to Ralph"
973 );
974
975 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
977 assert!(
978 prompt.contains("task.start"),
979 "Ralph's prompt should include the event"
980 );
981 }
982
983 #[test]
984 fn test_termination_max_iterations() {
985 let yaml = r"
986event_loop:
987 max_iterations: 2
988";
989 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
990 let mut event_loop = EventLoop::new(config);
991 event_loop.state.iteration = 2;
992
993 assert_eq!(
994 event_loop.check_termination(),
995 Some(TerminationReason::MaxIterations)
996 );
997 }
998
999 #[test]
1000 fn test_completion_promise_detection() {
1001 use std::fs;
1002 use std::path::Path;
1003
1004 let config = RalphConfig::default();
1005 let mut event_loop = EventLoop::new(config);
1006 event_loop.initialize("Test");
1007
1008 let scratchpad_path = Path::new(".agent/scratchpad.md");
1010 fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
1011 fs::write(
1012 scratchpad_path,
1013 "## Tasks\n- [x] Task 1 done\n- [x] Task 2 done\n",
1014 )
1015 .unwrap();
1016
1017 let hat_id = HatId::new("ralph");
1019
1020 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1022 assert_eq!(reason, None, "First confirmation should not terminate");
1023
1024 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1026 assert_eq!(
1027 reason,
1028 Some(TerminationReason::CompletionPromise),
1029 "Second consecutive confirmation should terminate"
1030 );
1031
1032 fs::remove_file(scratchpad_path).ok();
1034 }
1035
1036 #[test]
1037 fn test_builder_cannot_terminate_loop() {
1038 let config = RalphConfig::default();
1040 let mut event_loop = EventLoop::new(config);
1041 event_loop.initialize("Test");
1042
1043 let hat_id = HatId::new("builder");
1045 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1046
1047 assert_eq!(reason, None);
1049 }
1050
1051 #[test]
1052 fn test_build_prompt_uses_ghuntley_style_for_all_hats() {
1053 let yaml = r#"
1055hats:
1056 planner:
1057 name: "Planner"
1058 triggers: ["task.start", "build.done", "build.blocked"]
1059 publishes: ["build.task"]
1060 builder:
1061 name: "Builder"
1062 triggers: ["build.task"]
1063 publishes: ["build.done", "build.blocked"]
1064"#;
1065 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1066 let mut event_loop = EventLoop::new(config);
1067 event_loop.initialize("Test task");
1068
1069 let planner_id = HatId::new("planner");
1071 let planner_prompt = event_loop.build_prompt(&planner_id).unwrap();
1072
1073 assert!(
1075 planner_prompt.contains("### 0. ORIENTATION"),
1076 "Planner should use ghuntley-style orientation phase"
1077 );
1078 assert!(
1079 planner_prompt.contains("### GUARDRAILS"),
1080 "Planner prompt should have guardrails section"
1081 );
1082 assert!(
1083 planner_prompt.contains("Fresh context each iteration"),
1084 "Planner prompt should have ghuntley identity"
1085 );
1086
1087 let hat_id = HatId::new("builder");
1089 event_loop
1090 .bus
1091 .publish(Event::new("build.task", "Build something"));
1092
1093 let builder_prompt = event_loop.build_prompt(&hat_id).unwrap();
1094
1095 assert!(
1097 builder_prompt.contains("### 0. ORIENTATION"),
1098 "Builder should use ghuntley-style orientation phase"
1099 );
1100 assert!(
1101 builder_prompt.contains("Only 1 subagent for build/tests"),
1102 "Builder prompt should have subagent limit"
1103 );
1104 }
1105
1106 #[test]
1107 fn test_build_prompt_uses_custom_hat_for_non_defaults() {
1108 let yaml = r#"
1110mode: "multi"
1111hats:
1112 reviewer:
1113 name: "Code Reviewer"
1114 triggers: ["review.request"]
1115 instructions: "Review code quality."
1116"#;
1117 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1118 let mut event_loop = EventLoop::new(config);
1119
1120 event_loop
1122 .bus
1123 .publish(Event::new("review.request", "Review PR #123"));
1124
1125 let reviewer_id = HatId::new("reviewer");
1126 let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
1127
1128 assert!(
1130 prompt.contains("Code Reviewer"),
1131 "Custom hat should use its name"
1132 );
1133 assert!(
1134 prompt.contains("Review code quality"),
1135 "Custom hat should include its instructions"
1136 );
1137 assert!(
1139 !prompt.contains("PLANNER MODE"),
1140 "Custom hat should not use planner prompt"
1141 );
1142 assert!(
1143 !prompt.contains("BUILDER MODE"),
1144 "Custom hat should not use builder prompt"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_exit_codes_per_spec() {
1150 assert_eq!(TerminationReason::CompletionPromise.exit_code(), 0);
1156 assert_eq!(TerminationReason::ConsecutiveFailures.exit_code(), 1);
1157 assert_eq!(TerminationReason::LoopThrashing.exit_code(), 1);
1158 assert_eq!(TerminationReason::Stopped.exit_code(), 1);
1159 assert_eq!(TerminationReason::MaxIterations.exit_code(), 2);
1160 assert_eq!(TerminationReason::MaxRuntime.exit_code(), 2);
1161 assert_eq!(TerminationReason::MaxCost.exit_code(), 2);
1162 assert_eq!(TerminationReason::Interrupted.exit_code(), 130);
1163 }
1164
1165 #[test]
1166 fn test_loop_thrashing_detection() {
1167 let config = RalphConfig::default();
1168 let mut event_loop = EventLoop::new(config);
1169 event_loop.initialize("Test");
1170
1171 let planner_id = HatId::new("planner");
1172 let builder_id = HatId::new("builder");
1173
1174 event_loop.process_output(
1176 &planner_id,
1177 "<event topic=\"build.task\">Fix bug</event>",
1178 true,
1179 );
1180
1181 event_loop.process_output(
1183 &builder_id,
1184 "<event topic=\"build.blocked\">Fix bug\nCan't compile</event>",
1185 true,
1186 );
1187 event_loop.process_output(
1188 &builder_id,
1189 "<event topic=\"build.blocked\">Fix bug\nStill can't compile</event>",
1190 true,
1191 );
1192 event_loop.process_output(
1193 &builder_id,
1194 "<event topic=\"build.blocked\">Fix bug\nReally stuck</event>",
1195 true,
1196 );
1197
1198 assert!(
1200 event_loop
1201 .state
1202 .abandoned_tasks
1203 .contains(&"Fix bug".to_string())
1204 );
1205 assert_eq!(event_loop.state.abandoned_task_redispatches, 0);
1206
1207 event_loop.process_output(
1209 &planner_id,
1210 "<event topic=\"build.task\">Fix bug</event>",
1211 true,
1212 );
1213 assert_eq!(event_loop.state.abandoned_task_redispatches, 1);
1214
1215 event_loop.process_output(
1217 &planner_id,
1218 "<event topic=\"build.task\">Fix bug</event>",
1219 true,
1220 );
1221 assert_eq!(event_loop.state.abandoned_task_redispatches, 2);
1222
1223 let reason = event_loop.process_output(
1225 &planner_id,
1226 "<event topic=\"build.task\">Fix bug</event>",
1227 true,
1228 );
1229 assert_eq!(reason, Some(TerminationReason::LoopThrashing));
1230 assert_eq!(event_loop.state.abandoned_task_redispatches, 3);
1231 }
1232
1233 #[test]
1234 fn test_thrashing_counter_resets_on_different_hat() {
1235 let config = RalphConfig::default();
1236 let mut event_loop = EventLoop::new(config);
1237 event_loop.initialize("Test");
1238
1239 let planner_id = HatId::new("planner");
1240 let builder_id = HatId::new("builder");
1241
1242 event_loop.process_output(
1244 &planner_id,
1245 "<event topic=\"build.blocked\">Stuck</event>",
1246 true,
1247 );
1248 event_loop.process_output(
1249 &planner_id,
1250 "<event topic=\"build.blocked\">Still stuck</event>",
1251 true,
1252 );
1253 assert_eq!(event_loop.state.consecutive_blocked, 2);
1254
1255 event_loop.process_output(
1257 &builder_id,
1258 "<event topic=\"build.blocked\">Builder stuck</event>",
1259 true,
1260 );
1261 assert_eq!(event_loop.state.consecutive_blocked, 1);
1262 assert_eq!(event_loop.state.last_blocked_hat, Some(builder_id));
1263 }
1264
1265 #[test]
1266 fn test_thrashing_counter_resets_on_non_blocked_event() {
1267 let config = RalphConfig::default();
1268 let mut event_loop = EventLoop::new(config);
1269 event_loop.initialize("Test");
1270
1271 let planner_id = HatId::new("planner");
1272
1273 event_loop.process_output(
1275 &planner_id,
1276 "<event topic=\"build.blocked\">Stuck</event>",
1277 true,
1278 );
1279 event_loop.process_output(
1280 &planner_id,
1281 "<event topic=\"build.blocked\">Still stuck</event>",
1282 true,
1283 );
1284 assert_eq!(event_loop.state.consecutive_blocked, 2);
1285
1286 event_loop.process_output(
1288 &planner_id,
1289 "<event topic=\"build.task\">Working now</event>",
1290 true,
1291 );
1292 assert_eq!(event_loop.state.consecutive_blocked, 0);
1293 assert_eq!(event_loop.state.last_blocked_hat, None);
1294 }
1295
1296 #[test]
1297 fn test_custom_hat_with_instructions_uses_build_custom_hat() {
1298 let yaml = r#"
1300hats:
1301 reviewer:
1302 name: "Code Reviewer"
1303 triggers: ["review.request"]
1304 instructions: "Review code for quality and security issues."
1305"#;
1306 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1307 let mut event_loop = EventLoop::new(config);
1308
1309 event_loop
1311 .bus
1312 .publish(Event::new("review.request", "Review PR #123"));
1313
1314 let reviewer_id = HatId::new("reviewer");
1315 let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
1316
1317 assert!(
1319 prompt.contains("Code Reviewer"),
1320 "Should include custom hat name"
1321 );
1322 assert!(
1323 prompt.contains("Review code for quality and security issues"),
1324 "Should include custom instructions"
1325 );
1326 assert!(
1327 prompt.contains("### 0. ORIENTATION"),
1328 "Should include ghuntley-style orientation"
1329 );
1330 assert!(
1331 prompt.contains("### 1. EXECUTE"),
1332 "Should use ghuntley-style execute phase"
1333 );
1334 assert!(
1335 prompt.contains("### GUARDRAILS"),
1336 "Should include guardrails section"
1337 );
1338
1339 assert!(
1341 prompt.contains("Review PR #123"),
1342 "Should include event context"
1343 );
1344 }
1345
1346 #[test]
1347 fn test_custom_hat_instructions_included_in_prompt() {
1348 let yaml = r#"
1350hats:
1351 tester:
1352 name: "Test Engineer"
1353 triggers: ["test.request"]
1354 instructions: |
1355 Run comprehensive tests including:
1356 - Unit tests
1357 - Integration tests
1358 - Security scans
1359 Report results with detailed coverage metrics.
1360"#;
1361 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1362 let mut event_loop = EventLoop::new(config);
1363
1364 event_loop
1366 .bus
1367 .publish(Event::new("test.request", "Test the auth module"));
1368
1369 let tester_id = HatId::new("tester");
1370 let prompt = event_loop.build_prompt(&tester_id).unwrap();
1371
1372 assert!(prompt.contains("Run comprehensive tests including"));
1374 assert!(prompt.contains("Unit tests"));
1375 assert!(prompt.contains("Integration tests"));
1376 assert!(prompt.contains("Security scans"));
1377 assert!(prompt.contains("detailed coverage metrics"));
1378
1379 assert!(prompt.contains("Test the auth module"));
1381 }
1382
1383 #[test]
1384 fn test_custom_hat_topology_visible_to_ralph() {
1385 let yaml = r#"
1389hats:
1390 deployer:
1391 name: "Deployment Manager"
1392 triggers: ["deploy.request", "deploy.rollback"]
1393 publishes: ["deploy.done", "deploy.failed"]
1394 instructions: "Handle deployment operations safely."
1395"#;
1396 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1397 let mut event_loop = EventLoop::new(config);
1398
1399 event_loop
1401 .bus
1402 .publish(Event::new("deploy.request", "Deploy to staging"));
1403
1404 let next_hat = event_loop.next_hat();
1406 assert_eq!(
1407 next_hat.unwrap().as_str(),
1408 "ralph",
1409 "Multi-hat mode routes to Ralph"
1410 );
1411
1412 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1414
1415 assert!(
1418 prompt.contains("deploy.request"),
1419 "Ralph's prompt should include the event topic"
1420 );
1421
1422 assert!(
1424 prompt.contains("## HATS"),
1425 "Ralph's prompt should include hat topology"
1426 );
1427 assert!(
1428 prompt.contains("Deployment Manager"),
1429 "Hat topology should include hat name"
1430 );
1431 assert!(
1432 prompt.contains("deploy.request"),
1433 "Hat triggers should be in topology"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_default_hat_with_custom_instructions_uses_build_custom_hat() {
1439 let yaml = r#"
1441hats:
1442 planner:
1443 name: "Custom Planner"
1444 triggers: ["task.start", "build.done"]
1445 instructions: "Custom planning instructions with special focus on security."
1446"#;
1447 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1448 let mut event_loop = EventLoop::new(config);
1449
1450 event_loop.initialize("Test task");
1451
1452 let planner_id = HatId::new("planner");
1453 let prompt = event_loop.build_prompt(&planner_id).unwrap();
1454
1455 assert!(prompt.contains("Custom Planner"), "Should use custom name");
1457 assert!(
1458 prompt.contains("Custom planning instructions with special focus on security"),
1459 "Should include custom instructions"
1460 );
1461 assert!(
1462 prompt.contains("### 1. EXECUTE"),
1463 "Should use ghuntley-style execute phase"
1464 );
1465 assert!(
1466 prompt.contains("### GUARDRAILS"),
1467 "Should include guardrails section"
1468 );
1469 }
1470
1471 #[test]
1472 fn test_custom_hat_without_instructions_gets_default_behavior() {
1473 let yaml = r#"
1475hats:
1476 monitor:
1477 name: "System Monitor"
1478 triggers: ["monitor.request"]
1479"#;
1480 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1481 let mut event_loop = EventLoop::new(config);
1482
1483 event_loop
1484 .bus
1485 .publish(Event::new("monitor.request", "Check system health"));
1486
1487 let monitor_id = HatId::new("monitor");
1488 let prompt = event_loop.build_prompt(&monitor_id).unwrap();
1489
1490 assert!(
1492 prompt.contains("System Monitor"),
1493 "Should include custom hat name"
1494 );
1495 assert!(
1496 prompt.contains("Follow the incoming event instructions"),
1497 "Should have default instructions when none provided"
1498 );
1499 assert!(
1500 prompt.contains("### 0. ORIENTATION"),
1501 "Should include ghuntley-style orientation"
1502 );
1503 assert!(
1504 prompt.contains("### GUARDRAILS"),
1505 "Should include guardrails section"
1506 );
1507 assert!(
1508 prompt.contains("Check system health"),
1509 "Should include event context"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_task_cancellation_with_tilde_marker() {
1515 let config = RalphConfig::default();
1517 let mut event_loop = EventLoop::new(config);
1518 event_loop.initialize("Test task");
1519
1520 let ralph_id = HatId::new("ralph");
1521
1522 let output = r"
1524## Tasks
1525- [x] Task 1 completed
1526- [~] Task 2 cancelled (too complex for current scope)
1527- [ ] Task 3 pending
1528";
1529
1530 let reason = event_loop.process_output(&ralph_id, output, true);
1532 assert_eq!(reason, None, "Should not terminate with pending tasks");
1533 }
1534
1535 #[test]
1536 fn test_partial_completion_with_cancelled_tasks() {
1537 use std::fs;
1538 use std::path::Path;
1539
1540 let yaml = r#"
1542hats:
1543 builder:
1544 name: "Builder"
1545 triggers: ["build.task"]
1546 publishes: ["build.done"]
1547"#;
1548 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1549 let mut event_loop = EventLoop::new(config);
1550 event_loop.initialize("Test task");
1551
1552 let ralph_id = HatId::new("ralph");
1554
1555 let scratchpad_path = Path::new(".agent/scratchpad.md");
1557 fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
1558 let scratchpad_content = r"## Tasks
1559- [x] Core feature implemented
1560- [x] Tests added
1561- [~] Documentation update (cancelled: out of scope)
1562- [~] Performance optimization (cancelled: not needed)
1563";
1564 fs::write(scratchpad_path, scratchpad_content).unwrap();
1565
1566 let output = "All done! LOOP_COMPLETE";
1568
1569 let reason = event_loop.process_output(&ralph_id, output, true);
1571 assert_eq!(reason, None, "First confirmation should not terminate");
1572
1573 let reason = event_loop.process_output(&ralph_id, output, true);
1575 assert_eq!(
1576 reason,
1577 Some(TerminationReason::CompletionPromise),
1578 "Should complete with partial completion"
1579 );
1580
1581 fs::remove_file(scratchpad_path).ok();
1583 }
1584
1585 #[test]
1586 fn test_planner_auto_cancellation_after_three_blocks() {
1587 let config = RalphConfig::default();
1589 let mut event_loop = EventLoop::new(config);
1590 event_loop.initialize("Test task");
1591
1592 let builder_id = HatId::new("builder");
1593 let planner_id = HatId::new("planner");
1594
1595 let reason = event_loop.process_output(
1597 &builder_id,
1598 "<event topic=\"build.blocked\">Task X\nmissing dependency</event>",
1599 true,
1600 );
1601 assert_eq!(reason, None);
1602 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&1));
1603
1604 let reason = event_loop.process_output(
1606 &builder_id,
1607 "<event topic=\"build.blocked\">Task X\ndependency issue persists</event>",
1608 true,
1609 );
1610 assert_eq!(reason, None);
1611 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&2));
1612
1613 let reason = event_loop.process_output(
1615 &builder_id,
1616 "<event topic=\"build.blocked\">Task X\nsame dependency issue</event>",
1617 true,
1618 );
1619 assert_eq!(reason, None, "Should not terminate, just abandon task");
1620 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&3));
1621 assert!(
1622 event_loop
1623 .state
1624 .abandoned_tasks
1625 .contains(&"Task X".to_string()),
1626 "Task X should be abandoned"
1627 );
1628
1629 event_loop.process_output(
1632 &planner_id,
1633 "<event topic=\"build.task\">Task X</event>",
1634 true,
1635 );
1636 assert_eq!(event_loop.state.abandoned_task_redispatches, 1);
1637
1638 event_loop.process_output(
1639 &planner_id,
1640 "<event topic=\"build.task\">Task X</event>",
1641 true,
1642 );
1643 assert_eq!(event_loop.state.abandoned_task_redispatches, 2);
1644
1645 let reason = event_loop.process_output(
1646 &planner_id,
1647 "<event topic=\"build.task\">Task X</event>",
1648 true,
1649 );
1650 assert_eq!(
1651 reason,
1652 Some(TerminationReason::LoopThrashing),
1653 "Should terminate after 3 redispatches of abandoned task"
1654 );
1655 }
1656
1657 #[test]
1658 fn test_default_publishes_injects_when_no_events() {
1659 use std::collections::HashMap;
1660 use tempfile::tempdir;
1661
1662 let temp_dir = tempdir().unwrap();
1663 let events_path = temp_dir.path().join("events.jsonl");
1664
1665 let mut config = RalphConfig::default();
1666 let mut hats = HashMap::new();
1667 hats.insert(
1668 "test-hat".to_string(),
1669 crate::config::HatConfig {
1670 name: "test-hat".to_string(),
1671 description: Some("Test hat for default publishes".to_string()),
1672 triggers: vec!["task.start".to_string()],
1673 publishes: vec!["task.done".to_string()],
1674 instructions: "Test hat".to_string(),
1675 backend: None,
1676 default_publishes: Some("task.done".to_string()),
1677 },
1678 );
1679 config.hats = hats;
1680
1681 let mut event_loop = EventLoop::new(config);
1682 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1683 event_loop.initialize("Test");
1684
1685 let hat_id = HatId::new("test-hat");
1686
1687 let before = event_loop.record_event_count();
1689
1690 event_loop.check_default_publishes(&hat_id, before);
1695
1696 assert!(
1698 event_loop.has_pending_events(),
1699 "Default event should be injected"
1700 );
1701 }
1702
1703 #[test]
1704 fn test_default_publishes_not_injected_when_events_written() {
1705 use std::collections::HashMap;
1706 use std::io::Write;
1707 use tempfile::tempdir;
1708
1709 let temp_dir = tempdir().unwrap();
1710 let events_path = temp_dir.path().join("events.jsonl");
1711
1712 let mut config = RalphConfig::default();
1713 let mut hats = HashMap::new();
1714 hats.insert(
1715 "test-hat".to_string(),
1716 crate::config::HatConfig {
1717 name: "test-hat".to_string(),
1718 description: Some("Test hat for default publishes".to_string()),
1719 triggers: vec!["task.start".to_string()],
1720 publishes: vec!["task.done".to_string()],
1721 instructions: "Test hat".to_string(),
1722 backend: None,
1723 default_publishes: Some("task.done".to_string()),
1724 },
1725 );
1726 config.hats = hats;
1727
1728 let mut event_loop = EventLoop::new(config);
1729 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1730 event_loop.initialize("Test");
1731
1732 let hat_id = HatId::new("test-hat");
1733
1734 let before = event_loop.record_event_count();
1736
1737 let mut file = std::fs::File::create(&events_path).unwrap();
1739 writeln!(
1740 file,
1741 r#"{{"topic":"task.done","ts":"2024-01-01T00:00:00Z"}}"#
1742 )
1743 .unwrap();
1744 file.flush().unwrap();
1745
1746 event_loop.check_default_publishes(&hat_id, before);
1748
1749 }
1752
1753 #[test]
1754 fn test_default_publishes_not_injected_when_not_configured() {
1755 use std::collections::HashMap;
1756 use tempfile::tempdir;
1757
1758 let temp_dir = tempdir().unwrap();
1759 let events_path = temp_dir.path().join("events.jsonl");
1760
1761 let mut config = RalphConfig::default();
1762 let mut hats = HashMap::new();
1763 hats.insert(
1764 "test-hat".to_string(),
1765 crate::config::HatConfig {
1766 name: "test-hat".to_string(),
1767 description: Some("Test hat for default publishes".to_string()),
1768 triggers: vec!["task.start".to_string()],
1769 publishes: vec!["task.done".to_string()],
1770 instructions: "Test hat".to_string(),
1771 backend: None,
1772 default_publishes: None, },
1774 );
1775 config.hats = hats;
1776
1777 let mut event_loop = EventLoop::new(config);
1778 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1779 event_loop.initialize("Test");
1780
1781 let hat_id = HatId::new("test-hat");
1782
1783 let _ = event_loop.build_prompt(&hat_id);
1785
1786 let before = event_loop.record_event_count();
1788
1789 event_loop.check_default_publishes(&hat_id, before);
1793
1794 assert!(
1796 !event_loop.has_pending_events(),
1797 "No default should be injected"
1798 );
1799 }
1800
1801 #[test]
1802 fn test_get_hat_backend_with_named_backend() {
1803 let yaml = r#"
1804hats:
1805 builder:
1806 name: "Builder"
1807 triggers: ["build.task"]
1808 backend: "claude"
1809"#;
1810 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1811 let event_loop = EventLoop::new(config);
1812
1813 let hat_id = HatId::new("builder");
1814 let backend = event_loop.get_hat_backend(&hat_id);
1815
1816 assert!(backend.is_some());
1817 match backend.unwrap() {
1818 HatBackend::Named(name) => assert_eq!(name, "claude"),
1819 _ => panic!("Expected Named backend"),
1820 }
1821 }
1822
1823 #[test]
1824 fn test_get_hat_backend_with_kiro_agent() {
1825 let yaml = r#"
1826hats:
1827 builder:
1828 name: "Builder"
1829 triggers: ["build.task"]
1830 backend:
1831 type: "kiro"
1832 agent: "my-agent"
1833"#;
1834 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1835 let event_loop = EventLoop::new(config);
1836
1837 let hat_id = HatId::new("builder");
1838 let backend = event_loop.get_hat_backend(&hat_id);
1839
1840 assert!(backend.is_some());
1841 match backend.unwrap() {
1842 HatBackend::KiroAgent { agent, .. } => assert_eq!(agent, "my-agent"),
1843 _ => panic!("Expected KiroAgent backend"),
1844 }
1845 }
1846
1847 #[test]
1848 fn test_get_hat_backend_inherits_global() {
1849 let yaml = r#"
1850cli:
1851 backend: "gemini"
1852hats:
1853 builder:
1854 name: "Builder"
1855 triggers: ["build.task"]
1856"#;
1857 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1858 let event_loop = EventLoop::new(config);
1859
1860 let hat_id = HatId::new("builder");
1861 let backend = event_loop.get_hat_backend(&hat_id);
1862
1863 assert!(backend.is_none());
1865 }
1866
1867 #[test]
1868 fn test_hatless_mode_registers_ralph_catch_all() {
1869 let config = RalphConfig::default();
1871 let mut event_loop = EventLoop::new(config);
1872
1873 assert!(event_loop.registry().is_empty());
1875
1876 event_loop.initialize("Test prompt");
1878
1879 let next_hat = event_loop.next_hat();
1881 assert!(next_hat.is_some(), "Should have pending events for ralph");
1882 assert_eq!(next_hat.unwrap().as_str(), "ralph");
1883 }
1884
1885 #[test]
1886 fn test_hatless_mode_builds_ralph_prompt() {
1887 let config = RalphConfig::default();
1889 let mut event_loop = EventLoop::new(config);
1890 event_loop.initialize("Test prompt");
1891
1892 let ralph_id = HatId::new("ralph");
1893 let prompt = event_loop.build_prompt(&ralph_id);
1894
1895 assert!(prompt.is_some(), "Should build prompt for ralph");
1896 let prompt = prompt.unwrap();
1897
1898 assert!(
1900 prompt.contains("I'm Ralph"),
1901 "Should identify as Ralph with ghuntley style"
1902 );
1903 assert!(
1904 prompt.contains("## WORKFLOW"),
1905 "Should have workflow section"
1906 );
1907 assert!(
1908 prompt.contains("## EVENT WRITING"),
1909 "Should have event writing section"
1910 );
1911 assert!(
1912 prompt.contains("LOOP_COMPLETE"),
1913 "Should reference completion promise"
1914 );
1915 }
1916
1917 #[test]
1924 fn test_always_hatless_ralph_executes_all_iterations() {
1925 let yaml = r#"
1927hats:
1928 planner:
1929 name: "Planner"
1930 triggers: ["task.start", "build.done"]
1931 publishes: ["build.task"]
1932 builder:
1933 name: "Builder"
1934 triggers: ["build.task"]
1935 publishes: ["build.done"]
1936"#;
1937 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1938 let mut event_loop = EventLoop::new(config);
1939
1940 event_loop.initialize("Implement feature X");
1942 assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1943
1944 event_loop.build_prompt(&HatId::new("ralph")); event_loop
1947 .bus
1948 .publish(Event::new("build.task", "Build feature X"));
1949 assert_eq!(
1950 event_loop.next_hat().unwrap().as_str(),
1951 "ralph",
1952 "build.task should route to Ralph"
1953 );
1954
1955 event_loop.build_prompt(&HatId::new("ralph")); event_loop
1958 .bus
1959 .publish(Event::new("build.done", "Feature X complete"));
1960 assert_eq!(
1961 event_loop.next_hat().unwrap().as_str(),
1962 "ralph",
1963 "build.done should route to Ralph"
1964 );
1965 }
1966
1967 #[test]
1968 fn test_always_hatless_solo_mode_unchanged() {
1969 let config = RalphConfig::default();
1971 let mut event_loop = EventLoop::new(config);
1972
1973 assert!(
1974 event_loop.registry().is_empty(),
1975 "Solo mode has no custom hats"
1976 );
1977
1978 event_loop.initialize("Do something");
1979 assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1980
1981 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1983 assert!(
1984 !prompt.contains("## HATS"),
1985 "Solo mode should not have HATS section"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_always_hatless_topology_preserved_in_prompt() {
1991 let yaml = r#"
1993hats:
1994 planner:
1995 name: "Planner"
1996 triggers: ["task.start", "build.done", "build.blocked"]
1997 publishes: ["build.task"]
1998 builder:
1999 name: "Builder"
2000 triggers: ["build.task"]
2001 publishes: ["build.done", "build.blocked"]
2002"#;
2003 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2004 let mut event_loop = EventLoop::new(config);
2005 event_loop.initialize("Test");
2006
2007 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
2008
2009 assert!(prompt.contains("## HATS"), "Should have HATS section");
2011 assert!(
2012 prompt.contains("Delegate via events"),
2013 "Should explain delegation"
2014 );
2015 assert!(
2016 prompt.contains("| Hat | Triggers On | Publishes |"),
2017 "Should have topology table"
2018 );
2019
2020 assert!(prompt.contains("Planner"), "Should include Planner hat");
2022 assert!(prompt.contains("Builder"), "Should include Builder hat");
2023
2024 assert!(
2026 prompt.contains("build.task"),
2027 "Should document build.task event"
2028 );
2029 assert!(
2030 prompt.contains("build.done"),
2031 "Should document build.done event"
2032 );
2033 }
2034
2035 #[test]
2036 fn test_always_hatless_no_backend_delegation() {
2037 let yaml = r#"
2041hats:
2042 builder:
2043 name: "Builder"
2044 triggers: ["build.task"]
2045 backend: "gemini" # This backend should NEVER be used
2046"#;
2047 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2048 let mut event_loop = EventLoop::new(config);
2049
2050 event_loop.bus.publish(Event::new("build.task", "Test"));
2051
2052 let next = event_loop.next_hat();
2054 assert_eq!(
2055 next.unwrap().as_str(),
2056 "ralph",
2057 "Ralph handles all iterations"
2058 );
2059
2060 }
2063
2064 #[test]
2065 fn test_always_hatless_collects_all_pending_events() {
2066 let yaml = r#"
2068hats:
2069 planner:
2070 name: "Planner"
2071 triggers: ["task.start"]
2072 builder:
2073 name: "Builder"
2074 triggers: ["build.task"]
2075"#;
2076 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2077 let mut event_loop = EventLoop::new(config);
2078
2079 event_loop
2081 .bus
2082 .publish(Event::new("task.start", "Start task"));
2083 event_loop
2084 .bus
2085 .publish(Event::new("build.task", "Build something"));
2086
2087 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
2089
2090 assert!(
2092 prompt.contains("task.start"),
2093 "Should include task.start event"
2094 );
2095 assert!(
2096 prompt.contains("build.task"),
2097 "Should include build.task event"
2098 );
2099 }
2100
2101 #[test]
2104 fn test_determine_active_hats() {
2105 let yaml = r#"
2107hats:
2108 security_reviewer:
2109 name: "Security Reviewer"
2110 triggers: ["review.security"]
2111 architecture_reviewer:
2112 name: "Architecture Reviewer"
2113 triggers: ["review.architecture"]
2114 correctness_reviewer:
2115 name: "Correctness Reviewer"
2116 triggers: ["review.correctness"]
2117"#;
2118 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2119 let event_loop = EventLoop::new(config);
2120
2121 let events = vec![
2123 Event::new("review.security", "Check for vulnerabilities"),
2124 Event::new("review.architecture", "Review design patterns"),
2125 ];
2126
2127 let active_hats = event_loop.determine_active_hats(&events);
2129
2130 assert_eq!(active_hats.len(), 2, "Should return exactly 2 active hats");
2132
2133 let hat_ids: Vec<&str> = active_hats.iter().map(|h| h.id.as_str()).collect();
2134 assert!(
2135 hat_ids.contains(&"security_reviewer"),
2136 "Should include security_reviewer"
2137 );
2138 assert!(
2139 hat_ids.contains(&"architecture_reviewer"),
2140 "Should include architecture_reviewer"
2141 );
2142 assert!(
2143 !hat_ids.contains(&"correctness_reviewer"),
2144 "Should NOT include correctness_reviewer"
2145 );
2146 }
2147
2148 #[test]
2149 fn test_get_active_hat_id_with_pending_event() {
2150 let yaml = r#"
2152hats:
2153 security_reviewer:
2154 name: "Security Reviewer"
2155 triggers: ["review.security"]
2156"#;
2157 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2158 let mut event_loop = EventLoop::new(config);
2159
2160 event_loop
2162 .bus
2163 .publish(Event::new("review.security", "Check authentication"));
2164
2165 let active_hat_id = event_loop.get_active_hat_id();
2167
2168 assert_eq!(
2170 active_hat_id.as_str(),
2171 "security_reviewer",
2172 "Should return security_reviewer, not ralph"
2173 );
2174 }
2175
2176 #[test]
2177 fn test_get_active_hat_id_no_pending_returns_ralph() {
2178 let yaml = r#"
2180hats:
2181 security_reviewer:
2182 name: "Security Reviewer"
2183 triggers: ["review.security"]
2184"#;
2185 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2186 let event_loop = EventLoop::new(config);
2187
2188 let active_hat_id = event_loop.get_active_hat_id();
2190
2191 assert_eq!(
2193 active_hat_id.as_str(),
2194 "ralph",
2195 "Should return ralph when no pending events"
2196 );
2197 }
2198}