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")
173 .subscribe("*"); bus.register(ralph_hat);
175
176 if registry.is_empty() {
177 debug!("Solo mode: Ralph is the only coordinator");
178 } else {
179 debug!(
180 "Multi-hat mode: {} custom hats + Ralph as fallback",
181 registry.len()
182 );
183 }
184
185 let ralph = HatlessRalph::new(
186 config.event_loop.completion_promise.clone(),
187 config.core.clone(),
188 ®istry,
189 config.event_loop.starting_event.clone(),
190 );
191
192 let event_reader = EventReader::new(".agent/events.jsonl");
193
194 Self {
195 config,
196 registry,
197 bus,
198 state: LoopState::new(),
199 instruction_builder,
200 ralph,
201 event_reader,
202 }
203 }
204
205 pub fn state(&self) -> &LoopState {
207 &self.state
208 }
209
210 pub fn config(&self) -> &RalphConfig {
212 &self.config
213 }
214
215 pub fn registry(&self) -> &HatRegistry {
217 &self.registry
218 }
219
220 pub fn get_hat_backend(&self, hat_id: &HatId) -> Option<&HatBackend> {
225 self.registry
226 .get_config(hat_id)
227 .and_then(|config| config.backend.as_ref())
228 }
229
230 pub fn add_observer<F>(&mut self, observer: F)
235 where
236 F: Fn(&Event) + Send + 'static,
237 {
238 self.bus.add_observer(observer);
239 }
240
241 #[deprecated(since = "2.0.0", note = "Use add_observer instead")]
245 pub fn set_observer<F>(&mut self, observer: F)
246 where
247 F: Fn(&Event) + Send + 'static,
248 {
249 #[allow(deprecated)]
250 self.bus.set_observer(observer);
251 }
252
253 pub fn check_termination(&self) -> Option<TerminationReason> {
255 let cfg = &self.config.event_loop;
256
257 if self.state.iteration >= cfg.max_iterations {
258 return Some(TerminationReason::MaxIterations);
259 }
260
261 if self.state.elapsed().as_secs() >= cfg.max_runtime_seconds {
262 return Some(TerminationReason::MaxRuntime);
263 }
264
265 if let Some(max_cost) = cfg.max_cost_usd {
266 if self.state.cumulative_cost >= max_cost {
267 return Some(TerminationReason::MaxCost);
268 }
269 }
270
271 if self.state.consecutive_failures >= cfg.max_consecutive_failures {
272 return Some(TerminationReason::ConsecutiveFailures);
273 }
274
275 if self.state.abandoned_task_redispatches >= 3 {
277 return Some(TerminationReason::LoopThrashing);
278 }
279
280 if self.state.consecutive_malformed_events >= 3 {
282 return Some(TerminationReason::ValidationFailure);
283 }
284
285 None
286 }
287
288 pub fn initialize(&mut self, prompt_content: &str) {
290 self.initialize_with_topic("task.start", prompt_content);
291 }
292
293 pub fn initialize_resume(&mut self, prompt_content: &str) {
298 self.initialize_with_topic("task.resume", prompt_content);
299 }
300
301 fn initialize_with_topic(&mut self, topic: &str, prompt_content: &str) {
303 let start_event = Event::new(topic, prompt_content);
307 self.bus.publish(start_event);
308 debug!(topic = topic, "Published {} event", topic);
309 }
310
311 pub fn next_hat(&self) -> Option<&HatId> {
320 let next = self.bus.next_hat_with_pending();
321
322 next.as_ref()?;
324
325 if self.registry.is_empty() {
328 next
330 } else {
331 self.bus.hat_ids().find(|id| id.as_str() == "ralph")
334 }
335 }
336
337 pub fn has_pending_events(&self) -> bool {
342 self.bus.next_hat_with_pending().is_some()
343 }
344
345 pub fn get_hat_publishes(&self, hat_id: &HatId) -> Vec<String> {
349 self.registry
350 .get(hat_id)
351 .map(|hat| hat.publishes.iter().map(|t| t.to_string()).collect())
352 .unwrap_or_default()
353 }
354
355 pub fn inject_fallback_event(&mut self) -> bool {
362 let fallback_event = Event::new(
363 "task.resume",
364 "RECOVERY: Previous iteration did not publish an event. \
365 Review the scratchpad and either dispatch the next task or complete the loop."
366 );
367
368 let fallback_event = match &self.state.last_hat {
371 Some(hat_id) if hat_id.as_str() != "ralph" => {
372 debug!(
373 hat = %hat_id.as_str(),
374 "Injecting fallback event to recover - targeting last hat with task.resume"
375 );
376 fallback_event.with_target(hat_id.clone())
377 }
378 _ => {
379 debug!("Injecting fallback event to recover - triggering Ralph with task.resume");
380 fallback_event
381 }
382 };
383
384 self.bus.publish(fallback_event);
385 true
386 }
387
388 pub fn build_prompt(&mut self, hat_id: &HatId) -> Option<String> {
398 if hat_id.as_str() == "ralph" {
401 if self.registry.is_empty() {
402 let events = self.bus.take_pending(&hat_id.clone());
404 let events_context = events
405 .iter()
406 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
407 .collect::<Vec<_>>()
408 .join("\n");
409
410 debug!("build_prompt: routing to HatlessRalph (solo mode)");
411 return Some(self.ralph.build_prompt(&events_context, &[]));
412 } else {
413 let all_hat_ids: Vec<HatId> = self.bus.hat_ids().cloned().collect();
415 let mut all_events = Vec::new();
416 for id in all_hat_ids {
417 all_events.extend(self.bus.take_pending(&id));
418 }
419
420 let active_hats = self.determine_active_hats(&all_events);
422
423 let events_context = all_events
425 .iter()
426 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
427 .collect::<Vec<_>>()
428 .join("\n");
429
430 debug!("build_prompt: routing to HatlessRalph (multi-hat coordinator mode), active_hats: {:?}",
432 active_hats.iter().map(|h| h.id.as_str()).collect::<Vec<_>>());
433 return Some(self.ralph.build_prompt(&events_context, &active_hats));
434 }
435 }
436
437 let events = self.bus.take_pending(&hat_id.clone());
441 let events_context = events
442 .iter()
443 .map(|e| format!("Event: {} - {}", e.topic, e.payload))
444 .collect::<Vec<_>>()
445 .join("\n");
446
447 let hat = self.registry.get(hat_id)?;
448
449 debug!("build_prompt: hat_id='{}', instructions.is_empty()={}",
451 hat_id.as_str(), hat.instructions.is_empty());
452
453 debug!(
455 "build_prompt: routing to build_custom_hat() for '{}'",
456 hat_id.as_str()
457 );
458 Some(
459 self.instruction_builder
460 .build_custom_hat(hat, &events_context),
461 )
462 }
463
464 pub fn build_ralph_prompt(&self, prompt_content: &str) -> String {
466 self.ralph.build_prompt(prompt_content, &[])
467 }
468
469 fn determine_active_hats(&self, events: &[Event]) -> Vec<&Hat> {
472 let mut active_hats = Vec::new();
473 for event in events {
474 if let Some(hat) = self.registry.get_for_topic(event.topic.as_str()) {
475 if !active_hats.iter().any(|h: &&Hat| h.id == hat.id) {
477 active_hats.push(hat);
478 }
479 }
480 }
481 active_hats
482 }
483
484 pub fn get_active_hat_id(&self) -> HatId {
487 for hat_id in self.bus.hat_ids() {
489 if let Some(events) = self.bus.peek_pending(hat_id) {
490 if !events.is_empty() {
491 if let Some(event) = events.first() {
493 if let Some(active_hat) = self.registry.get_for_topic(event.topic.as_str()) {
494 return active_hat.id.clone();
495 }
496 }
497 }
498 }
499 }
500 HatId::new("ralph")
501 }
502
503 pub fn record_event_count(&mut self) -> usize {
508 self.event_reader
509 .read_new_events()
510 .map(|r| r.events.len())
511 .unwrap_or(0)
512 }
513
514 pub fn check_default_publishes(&mut self, hat_id: &HatId, _events_before: usize) {
520 let events_after = self
521 .event_reader
522 .read_new_events()
523 .map(|r| r.events.len())
524 .unwrap_or(0);
525
526 if events_after == 0 {
527 if let Some(config) = self.registry.get_config(hat_id) {
529 if let Some(default_topic) = &config.default_publishes {
530 let default_event = Event::new(default_topic.as_str(), "")
532 .with_source(hat_id.clone());
533
534 debug!(
535 hat = %hat_id.as_str(),
536 topic = %default_topic,
537 "No events written by hat, injecting default_publishes event"
538 );
539
540 self.bus.publish(default_event);
541 }
542 }
543 }
544 }
545
546 pub fn process_output(
550 &mut self,
551 hat_id: &HatId,
552 output: &str,
553 success: bool,
554 ) -> Option<TerminationReason> {
555 self.state.iteration += 1;
556 self.state.last_hat = Some(hat_id.clone());
557
558 if success {
560 self.state.consecutive_failures = 0;
561 } else {
562 self.state.consecutive_failures += 1;
563 }
564
565 if hat_id.as_str() == "ralph"
568 && EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
569 {
570 match self.verify_scratchpad_complete() {
572 Ok(true) => {
573 self.state.completion_confirmations += 1;
575
576 if self.state.completion_confirmations >= 2 {
577 info!(
579 confirmations = self.state.completion_confirmations,
580 "Completion confirmed on consecutive iterations - terminating"
581 );
582 return Some(TerminationReason::CompletionPromise);
583 }
584 info!(
586 confirmations = self.state.completion_confirmations,
587 "Completion detected but requires consecutive confirmation - continuing"
588 );
589 }
590 Ok(false) => {
591 debug!(
593 "Completion promise detected but scratchpad has pending [ ] tasks - rejected"
594 );
595 self.state.completion_confirmations = 0;
596 }
597 Err(e) => {
598 debug!(
600 error = %e,
601 "Completion promise detected but scratchpad verification failed - rejected"
602 );
603 self.state.completion_confirmations = 0;
604 }
605 }
606 }
607
608 let parser = EventParser::new().with_source(hat_id.clone());
610 let events = parser.parse(output);
611
612 let mut validated_events = Vec::new();
614 for event in events {
615 if event.topic.as_str() == "build.done" {
616 if let Some(evidence) = EventParser::parse_backpressure_evidence(&event.payload) {
617 if evidence.all_passed() {
618 validated_events.push(event);
619 } else {
620 warn!(
622 hat = %hat_id.as_str(),
623 tests = evidence.tests_passed,
624 lint = evidence.lint_passed,
625 typecheck = evidence.typecheck_passed,
626 "build.done rejected: backpressure checks failed"
627 );
628 let blocked = Event::new(
629 "build.blocked",
630 "Backpressure checks failed. Fix tests/lint/typecheck before emitting build.done."
631 ).with_source(hat_id.clone());
632 validated_events.push(blocked);
633 }
634 } else {
635 warn!(
637 hat = %hat_id.as_str(),
638 "build.done rejected: missing backpressure evidence"
639 );
640 let blocked = Event::new(
641 "build.blocked",
642 "Missing backpressure evidence. Include 'tests: pass', 'lint: pass', 'typecheck: pass' in build.done payload."
643 ).with_source(hat_id.clone());
644 validated_events.push(blocked);
645 }
646 } else {
647 validated_events.push(event);
648 }
649 }
650
651 let blocked_events: Vec<_> = validated_events.iter()
653 .filter(|e| e.topic == "build.blocked".into())
654 .collect();
655
656 for blocked_event in &blocked_events {
657 let task_id = Self::extract_task_id(&blocked_event.payload);
659
660 let count = self.state.task_block_counts.entry(task_id.clone()).or_insert(0);
662 *count += 1;
663
664 debug!(
665 task_id = %task_id,
666 block_count = *count,
667 "Task blocked"
668 );
669
670 if *count >= 3 && !self.state.abandoned_tasks.contains(&task_id) {
672 warn!(
673 task_id = %task_id,
674 "Task abandoned after 3 consecutive blocks"
675 );
676
677 self.state.abandoned_tasks.push(task_id.clone());
678
679 let abandoned_event = Event::new(
680 "build.task.abandoned",
681 format!("Task '{}' abandoned after 3 consecutive build.blocked events", task_id)
682 ).with_source(hat_id.clone());
683
684 self.bus.publish(abandoned_event);
685 }
686 }
687
688 let task_events: Vec<_> = validated_events.iter()
690 .filter(|e| e.topic == "build.task".into())
691 .collect();
692
693 for task_event in task_events {
694 let task_id = Self::extract_task_id(&task_event.payload);
695
696 if self.state.abandoned_tasks.contains(&task_id) {
698 self.state.abandoned_task_redispatches += 1;
699 warn!(
700 task_id = %task_id,
701 redispatch_count = self.state.abandoned_task_redispatches,
702 "Planner redispatched abandoned task"
703 );
704 } else {
705 self.state.abandoned_task_redispatches = 0;
707 }
708 }
709
710 let has_blocked_event = !blocked_events.is_empty();
712
713 if has_blocked_event {
714 if self.state.last_blocked_hat.as_ref() == Some(hat_id) {
716 self.state.consecutive_blocked += 1;
717 } else {
718 self.state.consecutive_blocked = 1;
719 self.state.last_blocked_hat = Some(hat_id.clone());
720 }
721 } else {
722 self.state.consecutive_blocked = 0;
724 self.state.last_blocked_hat = None;
725 }
726
727 for event in validated_events {
728 debug!(
729 topic = %event.topic,
730 source = ?event.source,
731 target = ?event.target,
732 "Publishing event from output"
733 );
734 let topic = event.topic.clone();
735 let recipients = self.bus.publish(event);
736
737 if recipients.is_empty() {
739 warn!(
740 topic = %topic,
741 source = %hat_id.as_str(),
742 "Event has no subscribers - will be dropped. Check hat triggers configuration."
743 );
744 }
745 }
746
747 self.check_termination()
749 }
750
751 fn extract_task_id(payload: &str) -> String {
754 payload.lines()
755 .next()
756 .unwrap_or("unknown")
757 .trim()
758 .to_string()
759 }
760
761 pub fn add_cost(&mut self, cost: f64) {
763 self.state.cumulative_cost += cost;
764 }
765
766 fn verify_scratchpad_complete(&self) -> Result<bool, std::io::Error> {
773 use std::path::Path;
774
775 let scratchpad_path = Path::new(&self.config.core.scratchpad);
776
777 if !scratchpad_path.exists() {
778 return Err(std::io::Error::new(
779 std::io::ErrorKind::NotFound,
780 "Scratchpad does not exist",
781 ));
782 }
783
784 let content = std::fs::read_to_string(scratchpad_path)?;
785
786 let has_pending = content
787 .lines()
788 .any(|line| line.trim_start().starts_with("- [ ]"));
789
790 Ok(!has_pending)
791 }
792
793 pub fn process_events_from_jsonl(&mut self) -> std::io::Result<bool> {
802 let result = self.event_reader.read_new_events()?;
803
804 for malformed in &result.malformed {
806 let payload = format!(
807 "Line {}: {}\nContent: {}",
808 malformed.line_number,
809 malformed.error,
810 &malformed.content
811 );
812 let event = Event::new("event.malformed", &payload);
813 self.bus.publish(event);
814 self.state.consecutive_malformed_events += 1;
815 warn!(
816 line = malformed.line_number,
817 consecutive = self.state.consecutive_malformed_events,
818 "Malformed event line detected"
819 );
820 }
821
822 if !result.events.is_empty() {
824 self.state.consecutive_malformed_events = 0;
825 }
826
827 if result.events.is_empty() && result.malformed.is_empty() {
828 return Ok(false);
829 }
830
831 let mut has_orphans = false;
832
833 for event in result.events {
834 if self.registry.has_subscriber(&event.topic) {
836 let proto_event = if let Some(payload) = event.payload {
838 Event::new(event.topic.as_str(), &payload)
839 } else {
840 Event::new(event.topic.as_str(), "")
841 };
842 self.bus.publish(proto_event);
843 } else {
844 debug!(
846 topic = %event.topic,
847 "Event has no subscriber - will be handled by Ralph"
848 );
849 has_orphans = true;
850 }
851 }
852
853 Ok(has_orphans)
854 }
855
856 pub fn check_ralph_completion(&self, output: &str) -> bool {
860 EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
861 }
862
863 pub fn publish_terminate_event(&mut self, reason: &TerminationReason) -> Event {
870 let elapsed = self.state.elapsed();
871 let duration_str = format_duration(elapsed);
872
873 let payload = format!(
874 "## Reason\n{}\n\n## Status\n{}\n\n## Summary\n- Iterations: {}\n- Duration: {}\n- Exit code: {}",
875 reason.as_str(),
876 termination_status_text(reason),
877 self.state.iteration,
878 duration_str,
879 reason.exit_code()
880 );
881
882 let event = Event::new("loop.terminate", &payload);
883
884 self.bus.publish(event.clone());
886
887 info!(
888 reason = %reason.as_str(),
889 iterations = self.state.iteration,
890 duration = %duration_str,
891 "Wrapping up: {}. {} iterations in {}.",
892 reason.as_str(),
893 self.state.iteration,
894 duration_str
895 );
896
897 event
898 }
899}
900
901fn format_duration(d: Duration) -> String {
903 let total_secs = d.as_secs();
904 let hours = total_secs / 3600;
905 let minutes = (total_secs % 3600) / 60;
906 let seconds = total_secs % 60;
907
908 if hours > 0 {
909 format!("{}h {}m {}s", hours, minutes, seconds)
910 } else if minutes > 0 {
911 format!("{}m {}s", minutes, seconds)
912 } else {
913 format!("{}s", seconds)
914 }
915}
916
917fn termination_status_text(reason: &TerminationReason) -> &'static str {
919 match reason {
920 TerminationReason::CompletionPromise => "All tasks completed successfully.",
921 TerminationReason::MaxIterations => "Stopped at iteration limit.",
922 TerminationReason::MaxRuntime => "Stopped at runtime limit.",
923 TerminationReason::MaxCost => "Stopped at cost limit.",
924 TerminationReason::ConsecutiveFailures => "Too many consecutive failures.",
925 TerminationReason::LoopThrashing => "Loop thrashing detected - same hat repeatedly blocked.",
926 TerminationReason::ValidationFailure => "Too many consecutive malformed JSONL events.",
927 TerminationReason::Stopped => "Manually stopped.",
928 TerminationReason::Interrupted => "Interrupted by signal.",
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935
936 #[test]
937 fn test_initialization_routes_to_ralph_in_multihat_mode() {
938 let yaml = r#"
941hats:
942 planner:
943 name: "Planner"
944 triggers: ["task.start", "build.done", "build.blocked"]
945 publishes: ["build.task"]
946"#;
947 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
948 let mut event_loop = EventLoop::new(config);
949
950 event_loop.initialize("Test prompt");
951
952 let next = event_loop.next_hat();
954 assert!(next.is_some());
955 assert_eq!(next.unwrap().as_str(), "ralph", "Multi-hat mode should route to Ralph");
956
957 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
959 assert!(prompt.contains("task.start"), "Ralph's prompt should include the event");
960 }
961
962 #[test]
963 fn test_termination_max_iterations() {
964 let yaml = r"
965event_loop:
966 max_iterations: 2
967";
968 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
969 let mut event_loop = EventLoop::new(config);
970 event_loop.state.iteration = 2;
971
972 assert_eq!(
973 event_loop.check_termination(),
974 Some(TerminationReason::MaxIterations)
975 );
976 }
977
978 #[test]
979 fn test_completion_promise_detection() {
980 use std::fs;
981 use std::path::Path;
982
983 let config = RalphConfig::default();
984 let mut event_loop = EventLoop::new(config);
985 event_loop.initialize("Test");
986
987 let scratchpad_path = Path::new(".agent/scratchpad.md");
989 fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
990 fs::write(scratchpad_path, "## Tasks\n- [x] Task 1 done\n- [x] Task 2 done\n").unwrap();
991
992 let hat_id = HatId::new("ralph");
994
995 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
997 assert_eq!(reason, None, "First confirmation should not terminate");
998
999 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1001 assert_eq!(reason, Some(TerminationReason::CompletionPromise), "Second consecutive confirmation should terminate");
1002
1003 fs::remove_file(scratchpad_path).ok();
1005 }
1006
1007 #[test]
1008 fn test_builder_cannot_terminate_loop() {
1009 let config = RalphConfig::default();
1011 let mut event_loop = EventLoop::new(config);
1012 event_loop.initialize("Test");
1013
1014 let hat_id = HatId::new("builder");
1016 let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1017
1018 assert_eq!(reason, None);
1020 }
1021
1022 #[test]
1023 fn test_build_prompt_uses_ghuntley_style_for_all_hats() {
1024 let yaml = r#"
1026hats:
1027 planner:
1028 name: "Planner"
1029 triggers: ["task.start", "build.done", "build.blocked"]
1030 publishes: ["build.task"]
1031 builder:
1032 name: "Builder"
1033 triggers: ["build.task"]
1034 publishes: ["build.done", "build.blocked"]
1035"#;
1036 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1037 let mut event_loop = EventLoop::new(config);
1038 event_loop.initialize("Test task");
1039
1040 let planner_id = HatId::new("planner");
1042 let planner_prompt = event_loop.build_prompt(&planner_id).unwrap();
1043
1044 assert!(
1046 planner_prompt.contains("### 0. ORIENTATION"),
1047 "Planner should use ghuntley-style orientation phase"
1048 );
1049 assert!(
1050 planner_prompt.contains("### GUARDRAILS"),
1051 "Planner prompt should have guardrails section"
1052 );
1053 assert!(
1054 planner_prompt.contains("Fresh context each iteration"),
1055 "Planner prompt should have ghuntley identity"
1056 );
1057
1058 let hat_id = HatId::new("builder");
1060 event_loop.bus.publish(Event::new("build.task", "Build something"));
1061
1062 let builder_prompt = event_loop.build_prompt(&hat_id).unwrap();
1063
1064 assert!(
1066 builder_prompt.contains("### 0. ORIENTATION"),
1067 "Builder should use ghuntley-style orientation phase"
1068 );
1069 assert!(
1070 builder_prompt.contains("Only 1 subagent for build/tests"),
1071 "Builder prompt should have subagent limit"
1072 );
1073 }
1074
1075 #[test]
1076 fn test_build_prompt_uses_custom_hat_for_non_defaults() {
1077 let yaml = r#"
1079mode: "multi"
1080hats:
1081 reviewer:
1082 name: "Code Reviewer"
1083 triggers: ["review.request"]
1084 instructions: "Review code quality."
1085"#;
1086 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1087 let mut event_loop = EventLoop::new(config);
1088
1089 event_loop.bus.publish(Event::new("review.request", "Review PR #123"));
1091
1092 let reviewer_id = HatId::new("reviewer");
1093 let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
1094
1095 assert!(
1097 prompt.contains("Code Reviewer"),
1098 "Custom hat should use its name"
1099 );
1100 assert!(
1101 prompt.contains("Review code quality"),
1102 "Custom hat should include its instructions"
1103 );
1104 assert!(
1106 !prompt.contains("PLANNER MODE"),
1107 "Custom hat should not use planner prompt"
1108 );
1109 assert!(
1110 !prompt.contains("BUILDER MODE"),
1111 "Custom hat should not use builder prompt"
1112 );
1113 }
1114
1115 #[test]
1116 fn test_exit_codes_per_spec() {
1117 assert_eq!(TerminationReason::CompletionPromise.exit_code(), 0);
1123 assert_eq!(TerminationReason::ConsecutiveFailures.exit_code(), 1);
1124 assert_eq!(TerminationReason::LoopThrashing.exit_code(), 1);
1125 assert_eq!(TerminationReason::Stopped.exit_code(), 1);
1126 assert_eq!(TerminationReason::MaxIterations.exit_code(), 2);
1127 assert_eq!(TerminationReason::MaxRuntime.exit_code(), 2);
1128 assert_eq!(TerminationReason::MaxCost.exit_code(), 2);
1129 assert_eq!(TerminationReason::Interrupted.exit_code(), 130);
1130 }
1131
1132 #[test]
1133 fn test_loop_thrashing_detection() {
1134 let config = RalphConfig::default();
1135 let mut event_loop = EventLoop::new(config);
1136 event_loop.initialize("Test");
1137
1138 let planner_id = HatId::new("planner");
1139 let builder_id = HatId::new("builder");
1140
1141 event_loop.process_output(&planner_id, "<event topic=\"build.task\">Fix bug</event>", true);
1143
1144 event_loop.process_output(&builder_id, "<event topic=\"build.blocked\">Fix bug\nCan't compile</event>", true);
1146 event_loop.process_output(&builder_id, "<event topic=\"build.blocked\">Fix bug\nStill can't compile</event>", true);
1147 event_loop.process_output(&builder_id, "<event topic=\"build.blocked\">Fix bug\nReally stuck</event>", true);
1148
1149 assert!(event_loop.state.abandoned_tasks.contains(&"Fix bug".to_string()));
1151 assert_eq!(event_loop.state.abandoned_task_redispatches, 0);
1152
1153 event_loop.process_output(&planner_id, "<event topic=\"build.task\">Fix bug</event>", true);
1155 assert_eq!(event_loop.state.abandoned_task_redispatches, 1);
1156
1157 event_loop.process_output(&planner_id, "<event topic=\"build.task\">Fix bug</event>", true);
1159 assert_eq!(event_loop.state.abandoned_task_redispatches, 2);
1160
1161 let reason = event_loop.process_output(&planner_id, "<event topic=\"build.task\">Fix bug</event>", true);
1163 assert_eq!(reason, Some(TerminationReason::LoopThrashing));
1164 assert_eq!(event_loop.state.abandoned_task_redispatches, 3);
1165 }
1166
1167 #[test]
1168 fn test_thrashing_counter_resets_on_different_hat() {
1169 let config = RalphConfig::default();
1170 let mut event_loop = EventLoop::new(config);
1171 event_loop.initialize("Test");
1172
1173 let planner_id = HatId::new("planner");
1174 let builder_id = HatId::new("builder");
1175
1176 event_loop.process_output(&planner_id, "<event topic=\"build.blocked\">Stuck</event>", true);
1178 event_loop.process_output(&planner_id, "<event topic=\"build.blocked\">Still stuck</event>", true);
1179 assert_eq!(event_loop.state.consecutive_blocked, 2);
1180
1181 event_loop.process_output(&builder_id, "<event topic=\"build.blocked\">Builder stuck</event>", true);
1183 assert_eq!(event_loop.state.consecutive_blocked, 1);
1184 assert_eq!(event_loop.state.last_blocked_hat, Some(builder_id));
1185 }
1186
1187 #[test]
1188 fn test_thrashing_counter_resets_on_non_blocked_event() {
1189 let config = RalphConfig::default();
1190 let mut event_loop = EventLoop::new(config);
1191 event_loop.initialize("Test");
1192
1193 let planner_id = HatId::new("planner");
1194
1195 event_loop.process_output(&planner_id, "<event topic=\"build.blocked\">Stuck</event>", true);
1197 event_loop.process_output(&planner_id, "<event topic=\"build.blocked\">Still stuck</event>", true);
1198 assert_eq!(event_loop.state.consecutive_blocked, 2);
1199
1200 event_loop.process_output(&planner_id, "<event topic=\"build.task\">Working now</event>", true);
1202 assert_eq!(event_loop.state.consecutive_blocked, 0);
1203 assert_eq!(event_loop.state.last_blocked_hat, None);
1204 }
1205
1206 #[test]
1207 fn test_custom_hat_with_instructions_uses_build_custom_hat() {
1208 let yaml = r#"
1210hats:
1211 reviewer:
1212 name: "Code Reviewer"
1213 triggers: ["review.request"]
1214 instructions: "Review code for quality and security issues."
1215"#;
1216 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1217 let mut event_loop = EventLoop::new(config);
1218
1219 event_loop.bus.publish(Event::new("review.request", "Review PR #123"));
1221
1222 let reviewer_id = HatId::new("reviewer");
1223 let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
1224
1225 assert!(prompt.contains("Code Reviewer"), "Should include custom hat name");
1227 assert!(prompt.contains("Review code for quality and security issues"), "Should include custom instructions");
1228 assert!(prompt.contains("### 0. ORIENTATION"), "Should include ghuntley-style orientation");
1229 assert!(prompt.contains("### 1. EXECUTE"), "Should use ghuntley-style execute phase");
1230 assert!(prompt.contains("### GUARDRAILS"), "Should include guardrails section");
1231
1232 assert!(prompt.contains("Review PR #123"), "Should include event context");
1234 }
1235
1236 #[test]
1237 fn test_custom_hat_instructions_included_in_prompt() {
1238 let yaml = r#"
1240hats:
1241 tester:
1242 name: "Test Engineer"
1243 triggers: ["test.request"]
1244 instructions: |
1245 Run comprehensive tests including:
1246 - Unit tests
1247 - Integration tests
1248 - Security scans
1249 Report results with detailed coverage metrics.
1250"#;
1251 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1252 let mut event_loop = EventLoop::new(config);
1253
1254 event_loop.bus.publish(Event::new("test.request", "Test the auth module"));
1256
1257 let tester_id = HatId::new("tester");
1258 let prompt = event_loop.build_prompt(&tester_id).unwrap();
1259
1260 assert!(prompt.contains("Run comprehensive tests including"));
1262 assert!(prompt.contains("Unit tests"));
1263 assert!(prompt.contains("Integration tests"));
1264 assert!(prompt.contains("Security scans"));
1265 assert!(prompt.contains("detailed coverage metrics"));
1266
1267 assert!(prompt.contains("Test the auth module"));
1269 }
1270
1271 #[test]
1272 fn test_custom_hat_topology_visible_to_ralph() {
1273 let yaml = r#"
1277hats:
1278 deployer:
1279 name: "Deployment Manager"
1280 triggers: ["deploy.request", "deploy.rollback"]
1281 publishes: ["deploy.done", "deploy.failed"]
1282 instructions: "Handle deployment operations safely."
1283"#;
1284 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1285 let mut event_loop = EventLoop::new(config);
1286
1287 event_loop.bus.publish(Event::new("deploy.request", "Deploy to staging"));
1289
1290 let next_hat = event_loop.next_hat();
1292 assert_eq!(next_hat.unwrap().as_str(), "ralph", "Multi-hat mode routes to Ralph");
1293
1294 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1296
1297 assert!(prompt.contains("deploy.request"), "Ralph's prompt should include the event topic");
1300
1301 assert!(prompt.contains("## HATS"), "Ralph's prompt should include hat topology");
1303 assert!(prompt.contains("Deployment Manager"), "Hat topology should include hat name");
1304 assert!(prompt.contains("deploy.request"), "Hat triggers should be in topology");
1305 }
1306
1307 #[test]
1308 fn test_default_hat_with_custom_instructions_uses_build_custom_hat() {
1309 let yaml = r#"
1311hats:
1312 planner:
1313 name: "Custom Planner"
1314 triggers: ["task.start", "build.done"]
1315 instructions: "Custom planning instructions with special focus on security."
1316"#;
1317 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1318 let mut event_loop = EventLoop::new(config);
1319
1320 event_loop.initialize("Test task");
1321
1322 let planner_id = HatId::new("planner");
1323 let prompt = event_loop.build_prompt(&planner_id).unwrap();
1324
1325 assert!(prompt.contains("Custom Planner"), "Should use custom name");
1327 assert!(prompt.contains("Custom planning instructions with special focus on security"), "Should include custom instructions");
1328 assert!(prompt.contains("### 1. EXECUTE"), "Should use ghuntley-style execute phase");
1329 assert!(prompt.contains("### GUARDRAILS"), "Should include guardrails section");
1330 }
1331
1332 #[test]
1333 fn test_custom_hat_without_instructions_gets_default_behavior() {
1334 let yaml = r#"
1336hats:
1337 monitor:
1338 name: "System Monitor"
1339 triggers: ["monitor.request"]
1340"#;
1341 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1342 let mut event_loop = EventLoop::new(config);
1343
1344 event_loop.bus.publish(Event::new("monitor.request", "Check system health"));
1345
1346 let monitor_id = HatId::new("monitor");
1347 let prompt = event_loop.build_prompt(&monitor_id).unwrap();
1348
1349 assert!(prompt.contains("System Monitor"), "Should include custom hat name");
1351 assert!(prompt.contains("Follow the incoming event instructions"), "Should have default instructions when none provided");
1352 assert!(prompt.contains("### 0. ORIENTATION"), "Should include ghuntley-style orientation");
1353 assert!(prompt.contains("### GUARDRAILS"), "Should include guardrails section");
1354 assert!(prompt.contains("Check system health"), "Should include event context");
1355 }
1356
1357 #[test]
1358 fn test_task_cancellation_with_tilde_marker() {
1359 let config = RalphConfig::default();
1361 let mut event_loop = EventLoop::new(config);
1362 event_loop.initialize("Test task");
1363
1364 let ralph_id = HatId::new("ralph");
1365
1366 let output = r"
1368## Tasks
1369- [x] Task 1 completed
1370- [~] Task 2 cancelled (too complex for current scope)
1371- [ ] Task 3 pending
1372";
1373
1374 let reason = event_loop.process_output(&ralph_id, output, true);
1376 assert_eq!(reason, None, "Should not terminate with pending tasks");
1377 }
1378
1379 #[test]
1380 fn test_partial_completion_with_cancelled_tasks() {
1381 use std::fs;
1382 use std::path::Path;
1383
1384 let yaml = r#"
1386hats:
1387 builder:
1388 name: "Builder"
1389 triggers: ["build.task"]
1390 publishes: ["build.done"]
1391"#;
1392 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1393 let mut event_loop = EventLoop::new(config);
1394 event_loop.initialize("Test task");
1395
1396 let ralph_id = HatId::new("ralph");
1398
1399 let scratchpad_path = Path::new(".agent/scratchpad.md");
1401 fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
1402 let scratchpad_content = r"## Tasks
1403- [x] Core feature implemented
1404- [x] Tests added
1405- [~] Documentation update (cancelled: out of scope)
1406- [~] Performance optimization (cancelled: not needed)
1407";
1408 fs::write(scratchpad_path, scratchpad_content).unwrap();
1409
1410 let output = "All done! LOOP_COMPLETE";
1412
1413 let reason = event_loop.process_output(&ralph_id, output, true);
1415 assert_eq!(reason, None, "First confirmation should not terminate");
1416
1417 let reason = event_loop.process_output(&ralph_id, output, true);
1419 assert_eq!(reason, Some(TerminationReason::CompletionPromise), "Should complete with partial completion");
1420
1421 fs::remove_file(scratchpad_path).ok();
1423 }
1424
1425 #[test]
1426 fn test_planner_auto_cancellation_after_three_blocks() {
1427 let config = RalphConfig::default();
1429 let mut event_loop = EventLoop::new(config);
1430 event_loop.initialize("Test task");
1431
1432 let builder_id = HatId::new("builder");
1433 let planner_id = HatId::new("planner");
1434
1435 let reason = event_loop.process_output(&builder_id, "<event topic=\"build.blocked\">Task X\nmissing dependency</event>", true);
1437 assert_eq!(reason, None);
1438 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&1));
1439
1440 let reason = event_loop.process_output(&builder_id, "<event topic=\"build.blocked\">Task X\ndependency issue persists</event>", true);
1442 assert_eq!(reason, None);
1443 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&2));
1444
1445 let reason = event_loop.process_output(&builder_id, "<event topic=\"build.blocked\">Task X\nsame dependency issue</event>", true);
1447 assert_eq!(reason, None, "Should not terminate, just abandon task");
1448 assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&3));
1449 assert!(event_loop.state.abandoned_tasks.contains(&"Task X".to_string()), "Task X should be abandoned");
1450
1451 event_loop.process_output(&planner_id, "<event topic=\"build.task\">Task X</event>", true);
1454 assert_eq!(event_loop.state.abandoned_task_redispatches, 1);
1455
1456 event_loop.process_output(&planner_id, "<event topic=\"build.task\">Task X</event>", true);
1457 assert_eq!(event_loop.state.abandoned_task_redispatches, 2);
1458
1459 let reason = event_loop.process_output(&planner_id, "<event topic=\"build.task\">Task X</event>", true);
1460 assert_eq!(reason, Some(TerminationReason::LoopThrashing), "Should terminate after 3 redispatches of abandoned task");
1461 }
1462
1463 #[test]
1464 fn test_default_publishes_injects_when_no_events() {
1465 use tempfile::tempdir;
1466 use std::collections::HashMap;
1467
1468 let temp_dir = tempdir().unwrap();
1469 let events_path = temp_dir.path().join("events.jsonl");
1470
1471 let mut config = RalphConfig::default();
1472 let mut hats = HashMap::new();
1473 hats.insert(
1474 "test-hat".to_string(),
1475 crate::config::HatConfig {
1476 name: "test-hat".to_string(),
1477 description: Some("Test hat for default publishes".to_string()),
1478 triggers: vec!["task.start".to_string()],
1479 publishes: vec!["task.done".to_string()],
1480 instructions: "Test hat".to_string(),
1481 backend: None,
1482 default_publishes: Some("task.done".to_string()),
1483 }
1484 );
1485 config.hats = hats;
1486
1487 let mut event_loop = EventLoop::new(config);
1488 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1489 event_loop.initialize("Test");
1490
1491 let hat_id = HatId::new("test-hat");
1492
1493 let before = event_loop.record_event_count();
1495
1496 event_loop.check_default_publishes(&hat_id, before);
1501
1502 assert!(event_loop.has_pending_events(), "Default event should be injected");
1504 }
1505
1506 #[test]
1507 fn test_default_publishes_not_injected_when_events_written() {
1508 use tempfile::tempdir;
1509 use std::io::Write;
1510 use std::collections::HashMap;
1511
1512 let temp_dir = tempdir().unwrap();
1513 let events_path = temp_dir.path().join("events.jsonl");
1514
1515 let mut config = RalphConfig::default();
1516 let mut hats = HashMap::new();
1517 hats.insert(
1518 "test-hat".to_string(),
1519 crate::config::HatConfig {
1520 name: "test-hat".to_string(),
1521 description: Some("Test hat for default publishes".to_string()),
1522 triggers: vec!["task.start".to_string()],
1523 publishes: vec!["task.done".to_string()],
1524 instructions: "Test hat".to_string(),
1525 backend: None,
1526 default_publishes: Some("task.done".to_string()),
1527 }
1528 );
1529 config.hats = hats;
1530
1531 let mut event_loop = EventLoop::new(config);
1532 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1533 event_loop.initialize("Test");
1534
1535 let hat_id = HatId::new("test-hat");
1536
1537 let before = event_loop.record_event_count();
1539
1540 let mut file = std::fs::File::create(&events_path).unwrap();
1542 writeln!(file, r#"{{"topic":"task.done","ts":"2024-01-01T00:00:00Z"}}"#).unwrap();
1543 file.flush().unwrap();
1544
1545 event_loop.check_default_publishes(&hat_id, before);
1547
1548 }
1551
1552 #[test]
1553 fn test_default_publishes_not_injected_when_not_configured() {
1554 use tempfile::tempdir;
1555 use std::collections::HashMap;
1556
1557 let temp_dir = tempdir().unwrap();
1558 let events_path = temp_dir.path().join("events.jsonl");
1559
1560 let mut config = RalphConfig::default();
1561 let mut hats = HashMap::new();
1562 hats.insert(
1563 "test-hat".to_string(),
1564 crate::config::HatConfig {
1565 name: "test-hat".to_string(),
1566 description: Some("Test hat for default publishes".to_string()),
1567 triggers: vec!["task.start".to_string()],
1568 publishes: vec!["task.done".to_string()],
1569 instructions: "Test hat".to_string(),
1570 backend: None,
1571 default_publishes: None, }
1573 );
1574 config.hats = hats;
1575
1576 let mut event_loop = EventLoop::new(config);
1577 event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1578 event_loop.initialize("Test");
1579
1580 let hat_id = HatId::new("test-hat");
1581
1582 let _ = event_loop.build_prompt(&hat_id);
1584
1585 let before = event_loop.record_event_count();
1587
1588 event_loop.check_default_publishes(&hat_id, before);
1592
1593 assert!(!event_loop.has_pending_events(), "No default should be injected");
1595 }
1596
1597 #[test]
1598 fn test_get_hat_backend_with_named_backend() {
1599 let yaml = r#"
1600hats:
1601 builder:
1602 name: "Builder"
1603 triggers: ["build.task"]
1604 backend: "claude"
1605"#;
1606 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1607 let event_loop = EventLoop::new(config);
1608
1609 let hat_id = HatId::new("builder");
1610 let backend = event_loop.get_hat_backend(&hat_id);
1611
1612 assert!(backend.is_some());
1613 match backend.unwrap() {
1614 HatBackend::Named(name) => assert_eq!(name, "claude"),
1615 _ => panic!("Expected Named backend"),
1616 }
1617 }
1618
1619 #[test]
1620 fn test_get_hat_backend_with_kiro_agent() {
1621 let yaml = r#"
1622hats:
1623 builder:
1624 name: "Builder"
1625 triggers: ["build.task"]
1626 backend:
1627 type: "kiro"
1628 agent: "my-agent"
1629"#;
1630 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1631 let event_loop = EventLoop::new(config);
1632
1633 let hat_id = HatId::new("builder");
1634 let backend = event_loop.get_hat_backend(&hat_id);
1635
1636 assert!(backend.is_some());
1637 match backend.unwrap() {
1638 HatBackend::KiroAgent { agent, .. } => assert_eq!(agent, "my-agent"),
1639 _ => panic!("Expected KiroAgent backend"),
1640 }
1641 }
1642
1643 #[test]
1644 fn test_get_hat_backend_inherits_global() {
1645 let yaml = r#"
1646cli:
1647 backend: "gemini"
1648hats:
1649 builder:
1650 name: "Builder"
1651 triggers: ["build.task"]
1652"#;
1653 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1654 let event_loop = EventLoop::new(config);
1655
1656 let hat_id = HatId::new("builder");
1657 let backend = event_loop.get_hat_backend(&hat_id);
1658
1659 assert!(backend.is_none());
1661 }
1662
1663 #[test]
1664 fn test_hatless_mode_registers_ralph_catch_all() {
1665 let config = RalphConfig::default();
1667 let mut event_loop = EventLoop::new(config);
1668
1669 assert!(event_loop.registry().is_empty());
1671
1672 event_loop.initialize("Test prompt");
1674
1675 let next_hat = event_loop.next_hat();
1677 assert!(next_hat.is_some(), "Should have pending events for ralph");
1678 assert_eq!(next_hat.unwrap().as_str(), "ralph");
1679 }
1680
1681 #[test]
1682 fn test_hatless_mode_builds_ralph_prompt() {
1683 let config = RalphConfig::default();
1685 let mut event_loop = EventLoop::new(config);
1686 event_loop.initialize("Test prompt");
1687
1688 let ralph_id = HatId::new("ralph");
1689 let prompt = event_loop.build_prompt(&ralph_id);
1690
1691 assert!(prompt.is_some(), "Should build prompt for ralph");
1692 let prompt = prompt.unwrap();
1693
1694 assert!(prompt.contains("I'm Ralph"), "Should identify as Ralph with ghuntley style");
1696 assert!(prompt.contains("## WORKFLOW"), "Should have workflow section");
1697 assert!(prompt.contains("## EVENT WRITING"), "Should have event writing section");
1698 assert!(prompt.contains("LOOP_COMPLETE"), "Should reference completion promise");
1699 }
1700
1701 #[test]
1708 fn test_always_hatless_ralph_executes_all_iterations() {
1709 let yaml = r#"
1711hats:
1712 planner:
1713 name: "Planner"
1714 triggers: ["task.start", "build.done"]
1715 publishes: ["build.task"]
1716 builder:
1717 name: "Builder"
1718 triggers: ["build.task"]
1719 publishes: ["build.done"]
1720"#;
1721 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1722 let mut event_loop = EventLoop::new(config);
1723
1724 event_loop.initialize("Implement feature X");
1726 assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1727
1728 event_loop.build_prompt(&HatId::new("ralph")); event_loop.bus.publish(Event::new("build.task", "Build feature X"));
1731 assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph", "build.task should route to Ralph");
1732
1733 event_loop.build_prompt(&HatId::new("ralph")); event_loop.bus.publish(Event::new("build.done", "Feature X complete"));
1736 assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph", "build.done should route to Ralph");
1737 }
1738
1739 #[test]
1740 fn test_always_hatless_solo_mode_unchanged() {
1741 let config = RalphConfig::default();
1743 let mut event_loop = EventLoop::new(config);
1744
1745 assert!(event_loop.registry().is_empty(), "Solo mode has no custom hats");
1746
1747 event_loop.initialize("Do something");
1748 assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1749
1750 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1752 assert!(!prompt.contains("## HATS"), "Solo mode should not have HATS section");
1753 }
1754
1755 #[test]
1756 fn test_always_hatless_topology_preserved_in_prompt() {
1757 let yaml = r#"
1759hats:
1760 planner:
1761 name: "Planner"
1762 triggers: ["task.start", "build.done", "build.blocked"]
1763 publishes: ["build.task"]
1764 builder:
1765 name: "Builder"
1766 triggers: ["build.task"]
1767 publishes: ["build.done", "build.blocked"]
1768"#;
1769 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1770 let mut event_loop = EventLoop::new(config);
1771 event_loop.initialize("Test");
1772
1773 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1774
1775 assert!(prompt.contains("## HATS"), "Should have HATS section");
1777 assert!(prompt.contains("Delegate via events"), "Should explain delegation");
1778 assert!(prompt.contains("| Hat | Triggers On | Publishes |"), "Should have topology table");
1779
1780 assert!(prompt.contains("Planner"), "Should include Planner hat");
1782 assert!(prompt.contains("Builder"), "Should include Builder hat");
1783
1784 assert!(prompt.contains("build.task"), "Should document build.task event");
1786 assert!(prompt.contains("build.done"), "Should document build.done event");
1787 }
1788
1789 #[test]
1790 fn test_always_hatless_no_backend_delegation() {
1791 let yaml = r#"
1795hats:
1796 builder:
1797 name: "Builder"
1798 triggers: ["build.task"]
1799 backend: "gemini" # This backend should NEVER be used
1800"#;
1801 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1802 let mut event_loop = EventLoop::new(config);
1803
1804 event_loop.bus.publish(Event::new("build.task", "Test"));
1805
1806 let next = event_loop.next_hat();
1808 assert_eq!(next.unwrap().as_str(), "ralph", "Ralph handles all iterations");
1809
1810 }
1813
1814 #[test]
1815 fn test_always_hatless_collects_all_pending_events() {
1816 let yaml = r#"
1818hats:
1819 planner:
1820 name: "Planner"
1821 triggers: ["task.start"]
1822 builder:
1823 name: "Builder"
1824 triggers: ["build.task"]
1825"#;
1826 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1827 let mut event_loop = EventLoop::new(config);
1828
1829 event_loop.bus.publish(Event::new("task.start", "Start task"));
1831 event_loop.bus.publish(Event::new("build.task", "Build something"));
1832
1833 let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1835
1836 assert!(prompt.contains("task.start"), "Should include task.start event");
1838 assert!(prompt.contains("build.task"), "Should include build.task event");
1839 }
1840
1841 #[test]
1844 fn test_determine_active_hats() {
1845 let yaml = r#"
1847hats:
1848 security_reviewer:
1849 name: "Security Reviewer"
1850 triggers: ["review.security"]
1851 architecture_reviewer:
1852 name: "Architecture Reviewer"
1853 triggers: ["review.architecture"]
1854 correctness_reviewer:
1855 name: "Correctness Reviewer"
1856 triggers: ["review.correctness"]
1857"#;
1858 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1859 let event_loop = EventLoop::new(config);
1860
1861 let events = vec![
1863 Event::new("review.security", "Check for vulnerabilities"),
1864 Event::new("review.architecture", "Review design patterns"),
1865 ];
1866
1867 let active_hats = event_loop.determine_active_hats(&events);
1869
1870 assert_eq!(active_hats.len(), 2, "Should return exactly 2 active hats");
1872
1873 let hat_ids: Vec<&str> = active_hats.iter().map(|h| h.id.as_str()).collect();
1874 assert!(hat_ids.contains(&"security_reviewer"), "Should include security_reviewer");
1875 assert!(hat_ids.contains(&"architecture_reviewer"), "Should include architecture_reviewer");
1876 assert!(!hat_ids.contains(&"correctness_reviewer"), "Should NOT include correctness_reviewer");
1877 }
1878
1879 #[test]
1880 fn test_get_active_hat_id_with_pending_event() {
1881 let yaml = r#"
1883hats:
1884 security_reviewer:
1885 name: "Security Reviewer"
1886 triggers: ["review.security"]
1887"#;
1888 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1889 let mut event_loop = EventLoop::new(config);
1890
1891 event_loop.bus.publish(Event::new("review.security", "Check authentication"));
1893
1894 let active_hat_id = event_loop.get_active_hat_id();
1896
1897 assert_eq!(active_hat_id.as_str(), "security_reviewer",
1899 "Should return security_reviewer, not ralph");
1900 }
1901
1902 #[test]
1903 fn test_get_active_hat_id_no_pending_returns_ralph() {
1904 let yaml = r#"
1906hats:
1907 security_reviewer:
1908 name: "Security Reviewer"
1909 triggers: ["review.security"]
1910"#;
1911 let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1912 let event_loop = EventLoop::new(config);
1913
1914 let active_hat_id = event_loop.get_active_hat_id();
1916
1917 assert_eq!(active_hat_id.as_str(), "ralph",
1919 "Should return ralph when no pending events");
1920 }
1921}