Skip to main content

ralph_core/
event_loop.rs

1//! Event loop orchestration.
2//!
3//! The event loop coordinates the execution of hats via pub/sub messaging.
4
5use 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/// Reason the event loop terminated.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum TerminationReason {
19    /// Completion promise was detected in output.
20    CompletionPromise,
21    /// Maximum iterations reached.
22    MaxIterations,
23    /// Maximum runtime exceeded.
24    MaxRuntime,
25    /// Maximum cost exceeded.
26    MaxCost,
27    /// Too many consecutive failures.
28    ConsecutiveFailures,
29    /// Loop thrashing detected (repeated blocked events).
30    LoopThrashing,
31    /// Too many consecutive malformed JSONL lines in events file.
32    ValidationFailure,
33    /// Manually stopped.
34    Stopped,
35    /// Interrupted by signal (SIGINT/SIGTERM).
36    Interrupted,
37}
38
39impl TerminationReason {
40    /// Returns the exit code for this termination reason per spec.
41    ///
42    /// Per spec "Loop Termination" section:
43    /// - 0: Completion promise detected (success)
44    /// - 1: Consecutive failures or unrecoverable error (failure)
45    /// - 2: Max iterations, max runtime, or max cost exceeded (limit)
46    /// - 130: User interrupt (SIGINT = 128 + 2)
47    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    /// Returns the reason string for use in loop.terminate event payload.
62    ///
63    /// Per spec event payload format:
64    /// `completed | max_iterations | max_runtime | consecutive_failures | interrupted | error`
65    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/// Current state of the event loop.
81#[derive(Debug)]
82pub struct LoopState {
83    /// Current iteration number (1-indexed).
84    pub iteration: u32,
85    /// Number of consecutive failures.
86    pub consecutive_failures: u32,
87    /// Cumulative cost in USD (if tracked).
88    pub cumulative_cost: f64,
89    /// When the loop started.
90    pub started_at: Instant,
91    /// The last hat that executed.
92    pub last_hat: Option<HatId>,
93    /// Consecutive blocked events from the same hat.
94    pub consecutive_blocked: u32,
95    /// Hat that emitted the last blocked event.
96    pub last_blocked_hat: Option<HatId>,
97    /// Per-task block counts for task-level thrashing detection.
98    pub task_block_counts: HashMap<String, u32>,
99    /// Tasks that have been abandoned after 3+ blocks.
100    pub abandoned_tasks: Vec<String>,
101    /// Count of times planner dispatched an already-abandoned task.
102    pub abandoned_task_redispatches: u32,
103    /// Number of consecutive completion confirmations (requires 2 for termination).
104    pub completion_confirmations: u32,
105    /// Consecutive malformed JSONL lines encountered (for validation backpressure).
106    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    /// Creates a new loop state.
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Returns the elapsed time since the loop started.
135    pub fn elapsed(&self) -> Duration {
136        self.started_at.elapsed()
137    }
138}
139
140/// The main event loop orchestrator.
141pub 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    /// Creates a new event loop from configuration.
153    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        // Per spec: "Hatless Ralph is constant — Cannot be replaced, overwritten, or configured away"
164        // Ralph is ALWAYS registered as the universal fallback for orphaned events.
165        // Custom hats are registered first (higher priority), Ralph catches everything else.
166        for hat in registry.all() {
167            bus.register(hat.clone());
168        }
169
170        // Always register Ralph as catch-all coordinator
171        // Per spec: "Ralph runs when no hat triggered — Universal fallback for orphaned events"
172        let ralph_hat = ralph_proto::Hat::new("ralph", "Ralph")
173            .subscribe("*"); // Subscribe to all events
174        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            &registry,
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    /// Returns the current loop state.
206    pub fn state(&self) -> &LoopState {
207        &self.state
208    }
209
210    /// Returns the configuration.
211    pub fn config(&self) -> &RalphConfig {
212        &self.config
213    }
214
215    /// Returns the hat registry.
216    pub fn registry(&self) -> &HatRegistry {
217        &self.registry
218    }
219
220    /// Gets the backend configuration for a hat.
221    ///
222    /// If the hat has a backend configured, returns that.
223    /// Otherwise, returns None (caller should use global backend).
224    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    /// Adds an observer that receives all published events.
231    ///
232    /// Multiple observers can be added (e.g., session recorder + TUI).
233    /// Each observer is called before events are routed to subscribers.
234    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    /// Sets a single observer, clearing any existing observers.
242    ///
243    /// Prefer `add_observer` when multiple observers are needed.
244    #[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    /// Checks if any termination condition is met.
254    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        // Check for loop thrashing: planner keeps dispatching abandoned tasks
276        if self.state.abandoned_task_redispatches >= 3 {
277            return Some(TerminationReason::LoopThrashing);
278        }
279
280        // Check for validation failures: too many consecutive malformed JSONL lines
281        if self.state.consecutive_malformed_events >= 3 {
282            return Some(TerminationReason::ValidationFailure);
283        }
284
285        None
286    }
287
288    /// Initializes the loop by publishing the start event.
289    pub fn initialize(&mut self, prompt_content: &str) {
290        self.initialize_with_topic("task.start", prompt_content);
291    }
292
293    /// Initializes the loop for resume mode by publishing task.resume.
294    ///
295    /// Per spec: "User can run `ralph resume` to restart reading existing scratchpad."
296    /// The planner should read the existing scratchpad rather than doing fresh gap analysis.
297    pub fn initialize_resume(&mut self, prompt_content: &str) {
298        self.initialize_with_topic("task.resume", prompt_content);
299    }
300
301    /// Common initialization logic with configurable topic.
302    fn initialize_with_topic(&mut self, topic: &str, prompt_content: &str) {
303        // Per spec: Log hat list, not "mode" terminology
304        // ✅ "Ralph ready with hats: planner, builder"
305        // ❌ "Starting in multi-hat mode"
306        let start_event = Event::new(topic, prompt_content);
307        self.bus.publish(start_event);
308        debug!(topic = topic, "Published {} event", topic);
309    }
310
311    /// Gets the next hat to execute (if any have pending events).
312    ///
313    /// Per "Hatless Ralph" architecture: When custom hats are defined, Ralph is
314    /// always the executor. Custom hats define topology (pub/sub contracts) that
315    /// Ralph uses for coordination context, but Ralph handles all iterations.
316    ///
317    /// - Solo mode (no custom hats): Returns "ralph" if Ralph has pending events
318    /// - Multi-hat mode (custom hats defined): Always returns "ralph" if ANY hat has pending events
319    pub fn next_hat(&self) -> Option<&HatId> {
320        let next = self.bus.next_hat_with_pending();
321
322        // If no pending events, return None
323        next.as_ref()?;
324
325        // In multi-hat mode, always route to Ralph (custom hats define topology only)
326        // Ralph's prompt includes the ## HATS section for coordination awareness
327        if self.registry.is_empty() {
328            // Solo mode - return the next hat (which is "ralph")
329            next
330        } else {
331            // Return "ralph" - the constant coordinator
332            // Find ralph in the bus's registered hats
333            self.bus.hat_ids().find(|id| id.as_str() == "ralph")
334        }
335    }
336
337    /// Checks if any hats have pending events.
338    ///
339    /// Use this after `process_output` to detect if the LLM failed to publish an event.
340    /// If false after processing, the loop will terminate on the next iteration.
341    pub fn has_pending_events(&self) -> bool {
342        self.bus.next_hat_with_pending().is_some()
343    }
344
345    /// Gets the topics a hat is allowed to publish.
346    ///
347    /// Used to build retry prompts when the LLM forgets to publish an event.
348    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    /// Injects a fallback event to recover from a stalled loop.
356    ///
357    /// When no hats have pending events (agent failed to publish), this method
358    /// injects a `task.resume` event which Ralph will handle to attempt recovery.
359    ///
360    /// Returns true if a fallback event was injected, false if recovery is not possible.
361    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        // If a custom hat was last executing, target the fallback back to it
369        // This preserves hat context instead of always falling back to Ralph
370        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    /// Builds the prompt for a hat's execution.
389    ///
390    /// Per "Hatless Ralph" architecture:
391    /// - Solo mode: Ralph handles everything with his own prompt
392    /// - Multi-hat mode: Ralph is the sole executor, custom hats define topology only
393    ///
394    /// When in multi-hat mode, this method collects ALL pending events across all hats
395    /// and builds Ralph's prompt with that context. The `## HATS` section in Ralph's
396    /// prompt documents the topology for coordination awareness.
397    pub fn build_prompt(&mut self, hat_id: &HatId) -> Option<String> {
398        // Handle "ralph" hat - the constant coordinator
399        // Per spec: "Hatless Ralph is constant — Cannot be replaced, overwritten, or configured away"
400        if hat_id.as_str() == "ralph" {
401            if self.registry.is_empty() {
402                // Solo mode - just Ralph's events, no hats to filter
403                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                // Multi-hat mode: collect events and determine active hats
414                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                // Determine which hats are active based on events
421                let active_hats = self.determine_active_hats(&all_events);
422
423                // Format events for context
424                let events_context = all_events
425                    .iter()
426                    .map(|e| format!("Event: {} - {}", e.topic, e.payload))
427                    .collect::<Vec<_>>()
428                    .join("\n");
429
430                // Build prompt with active hats - filters instructions to only active hats
431                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        // Non-ralph hat requested - this shouldn't happen in multi-hat mode since
438        // next_hat() always returns "ralph" when custom hats are defined.
439        // But we keep this code path for backward compatibility and tests.
440        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 logging to trace hat routing
450        debug!("build_prompt: hat_id='{}', instructions.is_empty()={}",
451               hat_id.as_str(), hat.instructions.is_empty());
452
453        // All hats use build_custom_hat with ghuntley-style prompts
454        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    /// Builds the Ralph prompt (coordination mode).
465    pub fn build_ralph_prompt(&self, prompt_content: &str) -> String {
466        self.ralph.build_prompt(prompt_content, &[])
467    }
468
469    /// Determines which hats should be active based on pending events.
470    /// Returns list of Hat references that are triggered by any pending event.
471    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                // Avoid duplicates
476                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    /// Returns the primary active hat ID for display purposes.
485    /// Returns the first active hat, or "ralph" if no specific hat is active.
486    pub fn get_active_hat_id(&self) -> HatId {
487        // Peek at pending events (don't consume them)
488        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                    // Return the hat ID that this event triggers
492                    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    /// Records the current event count before hat execution.
504    ///
505    /// Call this before executing a hat, then use `check_default_publishes`
506    /// after execution to inject a fallback event if needed.
507    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    /// Checks if hat wrote any events, and injects default if configured.
515    ///
516    /// Call this after hat execution with the count from `record_event_count`.
517    /// If no new events were written AND the hat has `default_publishes` configured,
518    /// this will inject the default event automatically.
519    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            // No new events written
528            if let Some(config) = self.registry.get_config(hat_id) {
529                if let Some(default_topic) = &config.default_publishes {
530                    // Inject default event
531                    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    /// Processes output from a hat execution.
547    ///
548    /// Returns the termination reason if the loop should stop.
549    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        // Track failures
559        if success {
560            self.state.consecutive_failures = 0;
561        } else {
562            self.state.consecutive_failures += 1;
563        }
564
565        // Check for completion promise - only valid from Ralph (the coordinator)
566        // Per spec: Requires dual condition (task state + consecutive confirmation)
567        if hat_id.as_str() == "ralph"
568            && EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
569        {
570            // Verify scratchpad task state
571            match self.verify_scratchpad_complete() {
572                Ok(true) => {
573                    // All tasks complete - increment confirmation counter
574                    self.state.completion_confirmations += 1;
575                    
576                    if self.state.completion_confirmations >= 2 {
577                        // Second consecutive confirmation - terminate
578                        info!(
579                            confirmations = self.state.completion_confirmations,
580                            "Completion confirmed on consecutive iterations - terminating"
581                        );
582                        return Some(TerminationReason::CompletionPromise);
583                    }
584                    // First confirmation - continue to next iteration
585                    info!(
586                        confirmations = self.state.completion_confirmations,
587                        "Completion detected but requires consecutive confirmation - continuing"
588                    );
589                }
590                Ok(false) => {
591                    // Pending tasks exist - reject completion
592                    debug!(
593                        "Completion promise detected but scratchpad has pending [ ] tasks - rejected"
594                    );
595                    self.state.completion_confirmations = 0;
596                }
597                Err(e) => {
598                    // Scratchpad doesn't exist or can't be read - reject completion
599                    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        // Parse and publish events from output
609        let parser = EventParser::new().with_source(hat_id.clone());
610        let events = parser.parse(output);
611
612        // Validate build.done events have backpressure evidence
613        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                        // Evidence present but checks failed - synthesize build.blocked
621                        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                    // No evidence found - synthesize build.blocked
636                    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        // Track build.blocked events for task-level thrashing detection
652        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            // Extract task ID from first line of payload
658            let task_id = Self::extract_task_id(&blocked_event.payload);
659            
660            // Increment block count for this task
661            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            // After 3 blocks on same task, emit build.task.abandoned
671            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        // Track build.task events to detect redispatch of abandoned tasks
689        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            // Check if this task was already abandoned
697            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                // Reset redispatch counter on non-abandoned task
706                self.state.abandoned_task_redispatches = 0;
707            }
708        }
709        
710        // Track hat-level blocking for legacy thrashing detection
711        let has_blocked_event = !blocked_events.is_empty();
712        
713        if has_blocked_event {
714            // Check if same hat as last blocked event
715            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            // Reset counter on any non-blocked event
723            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            // Per spec: "Unknown topic → Log warning, event dropped"
738            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        // Check termination conditions
748        self.check_termination()
749    }
750    
751    /// Extracts task identifier from build.blocked payload.
752    /// Uses first line of payload as task ID.
753    fn extract_task_id(payload: &str) -> String {
754        payload.lines()
755            .next()
756            .unwrap_or("unknown")
757            .trim()
758            .to_string()
759    }
760
761    /// Adds cost to the cumulative total.
762    pub fn add_cost(&mut self, cost: f64) {
763        self.state.cumulative_cost += cost;
764    }
765
766    /// Verifies all tasks in scratchpad are complete or cancelled.
767    ///
768    /// Returns:
769    /// - `Ok(true)` if all tasks are `[x]` or `[~]`
770    /// - `Ok(false)` if any tasks are `[ ]` (pending)
771    /// - `Err(...)` if scratchpad doesn't exist or can't be read
772    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    /// Processes events from JSONL and routes orphaned events to Ralph.
794    ///
795    /// Also handles backpressure for malformed JSONL lines by:
796    /// 1. Emitting `event.malformed` system events for each parse failure
797    /// 2. Tracking consecutive failures for termination check
798    /// 3. Resetting counter when valid events are parsed
799    ///
800    /// Returns true if Ralph should be invoked to handle orphaned events.
801    pub fn process_events_from_jsonl(&mut self) -> std::io::Result<bool> {
802        let result = self.event_reader.read_new_events()?;
803
804        // Handle malformed lines with backpressure
805        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        // Reset counter when valid events are parsed
823        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            // Check if any hat subscribes to this event
835            if self.registry.has_subscriber(&event.topic) {
836                // Route to subscriber via EventBus
837                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                // Orphaned event - Ralph will handle it
845                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    /// Checks if output contains LOOP_COMPLETE from Ralph.
857    ///
858    /// Only Ralph can trigger loop completion. Hat outputs are ignored.
859    pub fn check_ralph_completion(&self, output: &str) -> bool {
860        EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
861    }
862
863    /// Publishes the loop.terminate system event to observers.
864    ///
865    /// Per spec: "Published by the orchestrator (not agents) when the loop exits."
866    /// This is an observer-only event—hats cannot trigger on it.
867    ///
868    /// Returns the event for logging purposes.
869    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        // Publish to bus for observers (but no hat can trigger on this)
885        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
901/// Formats a duration as human-readable string.
902fn 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
917/// Returns a human-readable status based on termination reason.
918fn 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        // Per "Hatless Ralph" architecture: When custom hats are defined,
939        // Ralph is always the executor. Custom hats define topology only.
940        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        // Per spec: In multi-hat mode, Ralph handles all iterations
953        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        // Verify Ralph's prompt includes the event
958        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        // Create scratchpad with all tasks completed
988        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        // Use Ralph since it's the coordinator that outputs completion promise
993        let hat_id = HatId::new("ralph");
994        
995        // First LOOP_COMPLETE - should NOT terminate (needs consecutive confirmation)
996        let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
997        assert_eq!(reason, None, "First confirmation should not terminate");
998        
999        // Second consecutive LOOP_COMPLETE - should terminate
1000        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        // Cleanup
1004        fs::remove_file(scratchpad_path).ok();
1005    }
1006
1007    #[test]
1008    fn test_builder_cannot_terminate_loop() {
1009        // Per spec: "Builder hat outputs LOOP_COMPLETE → completion promise is ignored (only Ralph can terminate)"
1010        let config = RalphConfig::default();
1011        let mut event_loop = EventLoop::new(config);
1012        event_loop.initialize("Test");
1013
1014        // Builder hat outputs completion promise - should be IGNORED
1015        let hat_id = HatId::new("builder");
1016        let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1017
1018        // Builder cannot terminate, so no termination reason
1019        assert_eq!(reason, None);
1020    }
1021
1022    #[test]
1023    fn test_build_prompt_uses_ghuntley_style_for_all_hats() {
1024        // Per Hatless Ralph spec: All hats use build_custom_hat with ghuntley-style prompts
1025        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        // Planner hat should get ghuntley-style prompt via build_custom_hat
1041        let planner_id = HatId::new("planner");
1042        let planner_prompt = event_loop.build_prompt(&planner_id).unwrap();
1043
1044        // Verify ghuntley-style structure (numbered phases, guardrails)
1045        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        // Now trigger builder hat by publishing build.task event
1059        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        // Verify ghuntley-style structure for builder too
1065        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        // Per spec: Custom hats use build_custom_hat with their instructions
1078        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        // Publish event to trigger reviewer
1090        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        // Should be custom hat prompt (contains custom instructions)
1096        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        // Should NOT be planner or builder prompt
1105        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        // Per spec "Loop Termination" section:
1118        // - 0: Completion promise detected (success)
1119        // - 1: Consecutive failures or unrecoverable error (failure)
1120        // - 2: Max iterations, max runtime, or max cost exceeded (limit)
1121        // - 130: User interrupt (SIGINT = 128 + 2)
1122        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        // Planner dispatches task "Fix bug"
1142        event_loop.process_output(&planner_id, "<event topic=\"build.task\">Fix bug</event>", true);
1143        
1144        // Builder blocks on "Fix bug" three times (should emit build.task.abandoned)
1145        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        // Task should be abandoned but loop continues
1150        assert!(event_loop.state.abandoned_tasks.contains(&"Fix bug".to_string()));
1151        assert_eq!(event_loop.state.abandoned_task_redispatches, 0);
1152        
1153        // Planner redispatches the same abandoned task
1154        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        // Planner redispatches again
1158        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        // Third redispatch should trigger LoopThrashing
1162        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        // Planner blocked twice
1177        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        // Builder blocked - should reset counter
1182        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        // Two blocked events
1196        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        // Non-blocked event should reset counter
1201        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        // Per spec: Custom hats with instructions should use build_custom_hat() method
1209        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        // Trigger the custom hat
1220        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        // Should use build_custom_hat() - verify by checking for ghuntley-style structure
1226        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        // Should include event context
1233        assert!(prompt.contains("Review PR #123"), "Should include event context");
1234    }
1235
1236    #[test]
1237    fn test_custom_hat_instructions_included_in_prompt() {
1238        // Test that custom instructions are properly included in the generated prompt
1239        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        // Trigger the custom hat
1255        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        // Verify all custom instructions are included
1261        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        // Verify event context is included
1268        assert!(prompt.contains("Test the auth module"));
1269    }
1270
1271    #[test]
1272    fn test_custom_hat_topology_visible_to_ralph() {
1273        // Per "Hatless Ralph" architecture: Custom hats define topology,
1274        // but Ralph handles all iterations. This test verifies hat topology
1275        // is visible in Ralph's prompt.
1276        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        // Publish an event that the deployer hat would conceptually handle
1288        event_loop.bus.publish(Event::new("deploy.request", "Deploy to staging"));
1289
1290        // In multi-hat mode, next_hat always returns "ralph"
1291        let next_hat = event_loop.next_hat();
1292        assert_eq!(next_hat.unwrap().as_str(), "ralph", "Multi-hat mode routes to Ralph");
1293
1294        // Build Ralph's prompt - it should include the event context
1295        let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1296
1297        // Ralph's prompt should include:
1298        // 1. The event topic that was published (payload format: "Event: topic - payload")
1299        assert!(prompt.contains("deploy.request"), "Ralph's prompt should include the event topic");
1300
1301        // 2. The HATS section documenting the topology
1302        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        // Test that even default hats (planner/builder) use build_custom_hat when they have custom instructions
1310        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        // Should use build_custom_hat with ghuntley-style structure
1326        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        // Test that custom hats without instructions still work with build_custom_hat
1335        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        // Should still use build_custom_hat with ghuntley-style structure
1350        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        // Test that tasks marked with [~] are recognized as cancelled
1360        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        // Simulate Ralph output with cancelled task
1367        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        // Process output - should not terminate since there are still pending tasks
1375        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        // Test that cancelled tasks don't block completion when all other tasks are done
1385        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        // Ralph handles task.start, not a specific hat
1397        let ralph_id = HatId::new("ralph");
1398        
1399        // Create scratchpad with completed and cancelled tasks
1400        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        // Simulate completion with some cancelled tasks
1411        let output = "All done! LOOP_COMPLETE";
1412        
1413        // First confirmation - should not terminate yet
1414        let reason = event_loop.process_output(&ralph_id, output, true);
1415        assert_eq!(reason, None, "First confirmation should not terminate");
1416        
1417        // Second consecutive confirmation - should complete successfully despite cancelled tasks
1418        let reason = event_loop.process_output(&ralph_id, output, true);
1419        assert_eq!(reason, Some(TerminationReason::CompletionPromise), "Should complete with partial completion");
1420
1421        // Cleanup
1422        fs::remove_file(scratchpad_path).ok();
1423    }
1424
1425    #[test]
1426    fn test_planner_auto_cancellation_after_three_blocks() {
1427        // Test that task is abandoned after 3 build.blocked events for same task
1428        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        // First blocked event for "Task X" - should not abandon
1436        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        // Second blocked event for "Task X" - should not abandon
1441        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        // Third blocked event for "Task X" - should emit build.task.abandoned but not terminate
1446        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        // Planner can now replan around the abandoned task
1452        // Only terminates if planner keeps redispatching the abandoned task
1453        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        // Record event count before execution
1494        let before = event_loop.record_event_count();
1495
1496        // Hat executes but writes no events
1497        // (In real scenario, hat would write to events.jsonl, but we simulate none written)
1498
1499        // Check for default_publishes
1500        event_loop.check_default_publishes(&hat_id, before);
1501
1502        // Verify default event was injected
1503        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        // Record event count before execution
1538        let before = event_loop.record_event_count();
1539
1540        // Hat writes an event
1541        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        // Check for default_publishes
1546        event_loop.check_default_publishes(&hat_id, before);
1547
1548        // Default should NOT be injected since hat wrote an event
1549        // The event from file should be read by event_reader
1550    }
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, // No default configured
1572            }
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        // Consume the initial event from initialize
1583        let _ = event_loop.build_prompt(&hat_id);
1584        
1585        // Record event count before execution
1586        let before = event_loop.record_event_count();
1587        
1588        // Hat executes but writes no events
1589        
1590        // Check for default_publishes
1591        event_loop.check_default_publishes(&hat_id, before);
1592        
1593        // No default should be injected since not configured
1594        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        // Hat has no backend configured, should return None (inherit global)
1660        assert!(backend.is_none());
1661    }
1662
1663    #[test]
1664    fn test_hatless_mode_registers_ralph_catch_all() {
1665        // When no hats are configured, "ralph" should be registered as catch-all
1666        let config = RalphConfig::default();
1667        let mut event_loop = EventLoop::new(config);
1668
1669        // Registry should be empty (no user-defined hats)
1670        assert!(event_loop.registry().is_empty());
1671
1672        // But when we initialize, task.start should route to "ralph"
1673        event_loop.initialize("Test prompt");
1674
1675        // "ralph" should have pending events
1676        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        // In hatless mode, build_prompt for "ralph" should return HatlessRalph prompt
1684        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        // Should contain ghuntley-style Ralph identity (uses "I'm Ralph" not "You are Ralph")
1695        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    // === "Always Hatless Iteration" Architecture Tests ===
1702    // These tests verify the core invariants of the Hatless Ralph architecture:
1703    // - Ralph is always the sole executor when custom hats are defined
1704    // - Custom hats define topology (pub/sub contracts) for coordination context
1705    // - Ralph's prompt includes the ## HATS section documenting the topology
1706
1707    #[test]
1708    fn test_always_hatless_ralph_executes_all_iterations() {
1709        // Per acceptance criteria #1: Ralph executes all iterations with custom hats
1710        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        // Simulate the workflow: task.start → planner (conceptually)
1725        event_loop.initialize("Implement feature X");
1726        assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1727
1728        // Simulate build.task → builder (conceptually)
1729        event_loop.build_prompt(&HatId::new("ralph")); // Consume task.start
1730        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        // Simulate build.done → planner (conceptually)
1734        event_loop.build_prompt(&HatId::new("ralph")); // Consume build.task
1735        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        // Per acceptance criteria #3: Solo mode (no hats) operates as before
1742        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        // Solo mode prompt should NOT have ## HATS section
1751        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        // Per acceptance criteria #2 and #4: Hat topology preserved for coordination
1758        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        // Verify ## HATS section with topology table
1776        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        // Verify both hats are documented
1781        assert!(prompt.contains("Planner"), "Should include Planner hat");
1782        assert!(prompt.contains("Builder"), "Should include Builder hat");
1783
1784        // Verify trigger and publish information
1785        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        // Per acceptance criteria #5: Custom hat backends are NOT used
1792        // This is architectural - the EventLoop.next_hat() always returns "ralph"
1793        // so per-hat backends (if configured) are never invoked
1794        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        // Despite builder having a specific backend, Ralph handles the iteration
1807        let next = event_loop.next_hat();
1808        assert_eq!(next.unwrap().as_str(), "ralph", "Ralph handles all iterations");
1809
1810        // The backend delegation would happen in main.rs, but since we always
1811        // return "ralph" from next_hat(), the gemini backend is never selected
1812    }
1813
1814    #[test]
1815    fn test_always_hatless_collects_all_pending_events() {
1816        // Verify Ralph's prompt includes events from ALL hats when in multi-hat mode
1817        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        // Publish events that would go to different hats
1830        event_loop.bus.publish(Event::new("task.start", "Start task"));
1831        event_loop.bus.publish(Event::new("build.task", "Build something"));
1832
1833        // Ralph should collect ALL pending events
1834        let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1835
1836        // Both events should be in Ralph's context
1837        assert!(prompt.contains("task.start"), "Should include task.start event");
1838        assert!(prompt.contains("build.task"), "Should include build.task event");
1839    }
1840
1841    // === Phase 2: Active Hat Detection Tests ===
1842
1843    #[test]
1844    fn test_determine_active_hats() {
1845        // Create EventLoop with 3 hats (security_reviewer, architecture_reviewer, correctness_reviewer)
1846        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        // Create events: [Event("review.security", "..."), Event("review.architecture", "...")]
1862        let events = vec![
1863            Event::new("review.security", "Check for vulnerabilities"),
1864            Event::new("review.architecture", "Review design patterns"),
1865        ];
1866
1867        // Call determine_active_hats(&events)
1868        let active_hats = event_loop.determine_active_hats(&events);
1869
1870        // Assert: Returns Vec with exactly security_reviewer and architecture_reviewer Hats
1871        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        // Create EventLoop with security_reviewer hat
1882        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        // Publish Event("review.security", "...")
1892        event_loop.bus.publish(Event::new("review.security", "Check authentication"));
1893
1894        // Call get_active_hat_id()
1895        let active_hat_id = event_loop.get_active_hat_id();
1896
1897        // Assert: Returns HatId("security_reviewer"), NOT "ralph"
1898        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        // Create EventLoop with hats but NO pending events
1905        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        // Call get_active_hat_id() - no pending events
1915        let active_hat_id = event_loop.get_active_hat_id();
1916
1917        // Assert: Returns HatId("ralph")
1918        assert_eq!(active_hat_id.as_str(), "ralph",
1919                   "Should return ralph when no pending events");
1920    }
1921}