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").subscribe("*"); // Subscribe to all events
173        bus.register(ralph_hat);
174
175        if registry.is_empty() {
176            debug!("Solo mode: Ralph is the only coordinator");
177        } else {
178            debug!(
179                "Multi-hat mode: {} custom hats + Ralph as fallback",
180                registry.len()
181            );
182        }
183
184        let ralph = HatlessRalph::new(
185            config.event_loop.completion_promise.clone(),
186            config.core.clone(),
187            &registry,
188            config.event_loop.starting_event.clone(),
189        );
190
191        // Read events path from marker file, fall back to default if not present
192        // The marker file is written by run_loop_impl() at run startup
193        let events_path = std::fs::read_to_string(".ralph/current-events")
194            .map(|s| s.trim().to_string())
195            .unwrap_or_else(|_| ".ralph/events.jsonl".to_string());
196        let event_reader = EventReader::new(&events_path);
197
198        Self {
199            config,
200            registry,
201            bus,
202            state: LoopState::new(),
203            instruction_builder,
204            ralph,
205            event_reader,
206        }
207    }
208
209    /// Returns the current loop state.
210    pub fn state(&self) -> &LoopState {
211        &self.state
212    }
213
214    /// Returns the configuration.
215    pub fn config(&self) -> &RalphConfig {
216        &self.config
217    }
218
219    /// Returns the hat registry.
220    pub fn registry(&self) -> &HatRegistry {
221        &self.registry
222    }
223
224    /// Gets the backend configuration for a hat.
225    ///
226    /// If the hat has a backend configured, returns that.
227    /// Otherwise, returns None (caller should use global backend).
228    pub fn get_hat_backend(&self, hat_id: &HatId) -> Option<&HatBackend> {
229        self.registry
230            .get_config(hat_id)
231            .and_then(|config| config.backend.as_ref())
232    }
233
234    /// Adds an observer that receives all published events.
235    ///
236    /// Multiple observers can be added (e.g., session recorder + TUI).
237    /// Each observer is called before events are routed to subscribers.
238    pub fn add_observer<F>(&mut self, observer: F)
239    where
240        F: Fn(&Event) + Send + 'static,
241    {
242        self.bus.add_observer(observer);
243    }
244
245    /// Sets a single observer, clearing any existing observers.
246    ///
247    /// Prefer `add_observer` when multiple observers are needed.
248    #[deprecated(since = "2.0.0", note = "Use add_observer instead")]
249    pub fn set_observer<F>(&mut self, observer: F)
250    where
251        F: Fn(&Event) + Send + 'static,
252    {
253        #[allow(deprecated)]
254        self.bus.set_observer(observer);
255    }
256
257    /// Checks if any termination condition is met.
258    pub fn check_termination(&self) -> Option<TerminationReason> {
259        let cfg = &self.config.event_loop;
260
261        if self.state.iteration >= cfg.max_iterations {
262            return Some(TerminationReason::MaxIterations);
263        }
264
265        if self.state.elapsed().as_secs() >= cfg.max_runtime_seconds {
266            return Some(TerminationReason::MaxRuntime);
267        }
268
269        if let Some(max_cost) = cfg.max_cost_usd
270            && self.state.cumulative_cost >= max_cost
271        {
272            return Some(TerminationReason::MaxCost);
273        }
274
275        if self.state.consecutive_failures >= cfg.max_consecutive_failures {
276            return Some(TerminationReason::ConsecutiveFailures);
277        }
278
279        // Check for loop thrashing: planner keeps dispatching abandoned tasks
280        if self.state.abandoned_task_redispatches >= 3 {
281            return Some(TerminationReason::LoopThrashing);
282        }
283
284        // Check for validation failures: too many consecutive malformed JSONL lines
285        if self.state.consecutive_malformed_events >= 3 {
286            return Some(TerminationReason::ValidationFailure);
287        }
288
289        None
290    }
291
292    /// Initializes the loop by publishing the start event.
293    pub fn initialize(&mut self, prompt_content: &str) {
294        self.initialize_with_topic("task.start", prompt_content);
295    }
296
297    /// Initializes the loop for resume mode by publishing task.resume.
298    ///
299    /// Per spec: "User can run `ralph resume` to restart reading existing scratchpad."
300    /// The planner should read the existing scratchpad rather than doing fresh gap analysis.
301    pub fn initialize_resume(&mut self, prompt_content: &str) {
302        self.initialize_with_topic("task.resume", prompt_content);
303    }
304
305    /// Common initialization logic with configurable topic.
306    fn initialize_with_topic(&mut self, topic: &str, prompt_content: &str) {
307        // Per spec: Log hat list, not "mode" terminology
308        // ✅ "Ralph ready with hats: planner, builder"
309        // ❌ "Starting in multi-hat mode"
310        let start_event = Event::new(topic, prompt_content);
311        self.bus.publish(start_event);
312        debug!(topic = topic, "Published {} event", topic);
313    }
314
315    /// Gets the next hat to execute (if any have pending events).
316    ///
317    /// Per "Hatless Ralph" architecture: When custom hats are defined, Ralph is
318    /// always the executor. Custom hats define topology (pub/sub contracts) that
319    /// Ralph uses for coordination context, but Ralph handles all iterations.
320    ///
321    /// - Solo mode (no custom hats): Returns "ralph" if Ralph has pending events
322    /// - Multi-hat mode (custom hats defined): Always returns "ralph" if ANY hat has pending events
323    pub fn next_hat(&self) -> Option<&HatId> {
324        let next = self.bus.next_hat_with_pending();
325
326        // If no pending events, return None
327        next.as_ref()?;
328
329        // In multi-hat mode, always route to Ralph (custom hats define topology only)
330        // Ralph's prompt includes the ## HATS section for coordination awareness
331        if self.registry.is_empty() {
332            // Solo mode - return the next hat (which is "ralph")
333            next
334        } else {
335            // Return "ralph" - the constant coordinator
336            // Find ralph in the bus's registered hats
337            self.bus.hat_ids().find(|id| id.as_str() == "ralph")
338        }
339    }
340
341    /// Checks if any hats have pending events.
342    ///
343    /// Use this after `process_output` to detect if the LLM failed to publish an event.
344    /// If false after processing, the loop will terminate on the next iteration.
345    pub fn has_pending_events(&self) -> bool {
346        self.bus.next_hat_with_pending().is_some()
347    }
348
349    /// Gets the topics a hat is allowed to publish.
350    ///
351    /// Used to build retry prompts when the LLM forgets to publish an event.
352    pub fn get_hat_publishes(&self, hat_id: &HatId) -> Vec<String> {
353        self.registry
354            .get(hat_id)
355            .map(|hat| hat.publishes.iter().map(|t| t.to_string()).collect())
356            .unwrap_or_default()
357    }
358
359    /// Injects a fallback event to recover from a stalled loop.
360    ///
361    /// When no hats have pending events (agent failed to publish), this method
362    /// injects a `task.resume` event which Ralph will handle to attempt recovery.
363    ///
364    /// Returns true if a fallback event was injected, false if recovery is not possible.
365    pub fn inject_fallback_event(&mut self) -> bool {
366        let fallback_event = Event::new(
367            "task.resume",
368            "RECOVERY: Previous iteration did not publish an event. \
369             Review the scratchpad and either dispatch the next task or complete the loop.",
370        );
371
372        // If a custom hat was last executing, target the fallback back to it
373        // This preserves hat context instead of always falling back to Ralph
374        let fallback_event = match &self.state.last_hat {
375            Some(hat_id) if hat_id.as_str() != "ralph" => {
376                debug!(
377                    hat = %hat_id.as_str(),
378                    "Injecting fallback event to recover - targeting last hat with task.resume"
379                );
380                fallback_event.with_target(hat_id.clone())
381            }
382            _ => {
383                debug!("Injecting fallback event to recover - triggering Ralph with task.resume");
384                fallback_event
385            }
386        };
387
388        self.bus.publish(fallback_event);
389        true
390    }
391
392    /// Builds the prompt for a hat's execution.
393    ///
394    /// Per "Hatless Ralph" architecture:
395    /// - Solo mode: Ralph handles everything with his own prompt
396    /// - Multi-hat mode: Ralph is the sole executor, custom hats define topology only
397    ///
398    /// When in multi-hat mode, this method collects ALL pending events across all hats
399    /// and builds Ralph's prompt with that context. The `## HATS` section in Ralph's
400    /// prompt documents the topology for coordination awareness.
401    pub fn build_prompt(&mut self, hat_id: &HatId) -> Option<String> {
402        // Handle "ralph" hat - the constant coordinator
403        // Per spec: "Hatless Ralph is constant — Cannot be replaced, overwritten, or configured away"
404        if hat_id.as_str() == "ralph" {
405            if self.registry.is_empty() {
406                // Solo mode - just Ralph's events, no hats to filter
407                let events = self.bus.take_pending(&hat_id.clone());
408                let events_context = events
409                    .iter()
410                    .map(|e| format!("Event: {} - {}", e.topic, e.payload))
411                    .collect::<Vec<_>>()
412                    .join("\n");
413
414                debug!("build_prompt: routing to HatlessRalph (solo mode)");
415                return Some(self.ralph.build_prompt(&events_context, &[]));
416            } else {
417                // Multi-hat mode: collect events and determine active hats
418                let all_hat_ids: Vec<HatId> = self.bus.hat_ids().cloned().collect();
419                let mut all_events = Vec::new();
420                for id in all_hat_ids {
421                    all_events.extend(self.bus.take_pending(&id));
422                }
423
424                // Determine which hats are active based on events
425                let active_hats = self.determine_active_hats(&all_events);
426
427                // Format events for context
428                let events_context = all_events
429                    .iter()
430                    .map(|e| format!("Event: {} - {}", e.topic, e.payload))
431                    .collect::<Vec<_>>()
432                    .join("\n");
433
434                // Build prompt with active hats - filters instructions to only active hats
435                debug!(
436                    "build_prompt: routing to HatlessRalph (multi-hat coordinator mode), active_hats: {:?}",
437                    active_hats
438                        .iter()
439                        .map(|h| h.id.as_str())
440                        .collect::<Vec<_>>()
441                );
442                return Some(self.ralph.build_prompt(&events_context, &active_hats));
443            }
444        }
445
446        // Non-ralph hat requested - this shouldn't happen in multi-hat mode since
447        // next_hat() always returns "ralph" when custom hats are defined.
448        // But we keep this code path for backward compatibility and tests.
449        let events = self.bus.take_pending(&hat_id.clone());
450        let events_context = events
451            .iter()
452            .map(|e| format!("Event: {} - {}", e.topic, e.payload))
453            .collect::<Vec<_>>()
454            .join("\n");
455
456        let hat = self.registry.get(hat_id)?;
457
458        // Debug logging to trace hat routing
459        debug!(
460            "build_prompt: hat_id='{}', instructions.is_empty()={}",
461            hat_id.as_str(),
462            hat.instructions.is_empty()
463        );
464
465        // All hats use build_custom_hat with ghuntley-style prompts
466        debug!(
467            "build_prompt: routing to build_custom_hat() for '{}'",
468            hat_id.as_str()
469        );
470        Some(
471            self.instruction_builder
472                .build_custom_hat(hat, &events_context),
473        )
474    }
475
476    /// Builds the Ralph prompt (coordination mode).
477    pub fn build_ralph_prompt(&self, prompt_content: &str) -> String {
478        self.ralph.build_prompt(prompt_content, &[])
479    }
480
481    /// Determines which hats should be active based on pending events.
482    /// Returns list of Hat references that are triggered by any pending event.
483    fn determine_active_hats(&self, events: &[Event]) -> Vec<&Hat> {
484        let mut active_hats = Vec::new();
485        for event in events {
486            if let Some(hat) = self.registry.get_for_topic(event.topic.as_str()) {
487                // Avoid duplicates
488                if !active_hats.iter().any(|h: &&Hat| h.id == hat.id) {
489                    active_hats.push(hat);
490                }
491            }
492        }
493        active_hats
494    }
495
496    /// Returns the primary active hat ID for display purposes.
497    /// Returns the first active hat, or "ralph" if no specific hat is active.
498    pub fn get_active_hat_id(&self) -> HatId {
499        // Peek at pending events (don't consume them)
500        for hat_id in self.bus.hat_ids() {
501            let Some(events) = self.bus.peek_pending(hat_id) else {
502                continue;
503            };
504            let Some(event) = events.first() else {
505                continue;
506            };
507            if let Some(active_hat) = self.registry.get_for_topic(event.topic.as_str()) {
508                return active_hat.id.clone();
509            }
510        }
511        HatId::new("ralph")
512    }
513
514    /// Records the current event count before hat execution.
515    ///
516    /// Call this before executing a hat, then use `check_default_publishes`
517    /// after execution to inject a fallback event if needed.
518    pub fn record_event_count(&mut self) -> usize {
519        self.event_reader
520            .read_new_events()
521            .map(|r| r.events.len())
522            .unwrap_or(0)
523    }
524
525    /// Checks if hat wrote any events, and injects default if configured.
526    ///
527    /// Call this after hat execution with the count from `record_event_count`.
528    /// If no new events were written AND the hat has `default_publishes` configured,
529    /// this will inject the default event automatically.
530    pub fn check_default_publishes(&mut self, hat_id: &HatId, _events_before: usize) {
531        let events_after = self
532            .event_reader
533            .read_new_events()
534            .map(|r| r.events.len())
535            .unwrap_or(0);
536
537        if events_after == 0
538            && let Some(config) = self.registry.get_config(hat_id)
539            && let Some(default_topic) = &config.default_publishes
540        {
541            // No new events written - inject default event
542            let default_event = Event::new(default_topic.as_str(), "").with_source(hat_id.clone());
543
544            debug!(
545                hat = %hat_id.as_str(),
546                topic = %default_topic,
547                "No events written by hat, injecting default_publishes event"
548            );
549
550            self.bus.publish(default_event);
551        }
552    }
553
554    /// Processes output from a hat execution.
555    ///
556    /// Returns the termination reason if the loop should stop.
557    pub fn process_output(
558        &mut self,
559        hat_id: &HatId,
560        output: &str,
561        success: bool,
562    ) -> Option<TerminationReason> {
563        self.state.iteration += 1;
564        self.state.last_hat = Some(hat_id.clone());
565
566        // Track failures
567        if success {
568            self.state.consecutive_failures = 0;
569        } else {
570            self.state.consecutive_failures += 1;
571        }
572
573        // Check for completion promise - only valid from Ralph (the coordinator)
574        // Per spec: Requires dual condition (task state + consecutive confirmation)
575        if hat_id.as_str() == "ralph"
576            && EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
577        {
578            // Verify scratchpad task state
579            match self.verify_scratchpad_complete() {
580                Ok(true) => {
581                    // All tasks complete - increment confirmation counter
582                    self.state.completion_confirmations += 1;
583
584                    if self.state.completion_confirmations >= 2 {
585                        // Second consecutive confirmation - terminate
586                        info!(
587                            confirmations = self.state.completion_confirmations,
588                            "Completion confirmed on consecutive iterations - terminating"
589                        );
590                        return Some(TerminationReason::CompletionPromise);
591                    }
592                    // First confirmation - continue to next iteration
593                    info!(
594                        confirmations = self.state.completion_confirmations,
595                        "Completion detected but requires consecutive confirmation - continuing"
596                    );
597                }
598                Ok(false) => {
599                    // Pending tasks exist - reject completion
600                    debug!(
601                        "Completion promise detected but scratchpad has pending [ ] tasks - rejected"
602                    );
603                    self.state.completion_confirmations = 0;
604                }
605                Err(e) => {
606                    // Scratchpad doesn't exist or can't be read - reject completion
607                    debug!(
608                        error = %e,
609                        "Completion promise detected but scratchpad verification failed - rejected"
610                    );
611                    self.state.completion_confirmations = 0;
612                }
613            }
614        }
615
616        // Parse and publish events from output
617        let parser = EventParser::new().with_source(hat_id.clone());
618        let events = parser.parse(output);
619
620        // Validate build.done events have backpressure evidence
621        let mut validated_events = Vec::new();
622        for event in events {
623            if event.topic.as_str() == "build.done" {
624                if let Some(evidence) = EventParser::parse_backpressure_evidence(&event.payload) {
625                    if evidence.all_passed() {
626                        validated_events.push(event);
627                    } else {
628                        // Evidence present but checks failed - synthesize build.blocked
629                        warn!(
630                            hat = %hat_id.as_str(),
631                            tests = evidence.tests_passed,
632                            lint = evidence.lint_passed,
633                            typecheck = evidence.typecheck_passed,
634                            "build.done rejected: backpressure checks failed"
635                        );
636                        let blocked = Event::new(
637                            "build.blocked",
638                            "Backpressure checks failed. Fix tests/lint/typecheck before emitting build.done."
639                        ).with_source(hat_id.clone());
640                        validated_events.push(blocked);
641                    }
642                } else {
643                    // No evidence found - synthesize build.blocked
644                    warn!(
645                        hat = %hat_id.as_str(),
646                        "build.done rejected: missing backpressure evidence"
647                    );
648                    let blocked = Event::new(
649                        "build.blocked",
650                        "Missing backpressure evidence. Include 'tests: pass', 'lint: pass', 'typecheck: pass' in build.done payload."
651                    ).with_source(hat_id.clone());
652                    validated_events.push(blocked);
653                }
654            } else {
655                validated_events.push(event);
656            }
657        }
658
659        // Track build.blocked events for task-level thrashing detection
660        let blocked_events: Vec<_> = validated_events
661            .iter()
662            .filter(|e| e.topic == "build.blocked".into())
663            .collect();
664
665        for blocked_event in &blocked_events {
666            // Extract task ID from first line of payload
667            let task_id = Self::extract_task_id(&blocked_event.payload);
668
669            // Increment block count for this task
670            let count = self
671                .state
672                .task_block_counts
673                .entry(task_id.clone())
674                .or_insert(0);
675            *count += 1;
676
677            debug!(
678                task_id = %task_id,
679                block_count = *count,
680                "Task blocked"
681            );
682
683            // After 3 blocks on same task, emit build.task.abandoned
684            if *count >= 3 && !self.state.abandoned_tasks.contains(&task_id) {
685                warn!(
686                    task_id = %task_id,
687                    "Task abandoned after 3 consecutive blocks"
688                );
689
690                self.state.abandoned_tasks.push(task_id.clone());
691
692                let abandoned_event = Event::new(
693                    "build.task.abandoned",
694                    format!(
695                        "Task '{}' abandoned after 3 consecutive build.blocked events",
696                        task_id
697                    ),
698                )
699                .with_source(hat_id.clone());
700
701                self.bus.publish(abandoned_event);
702            }
703        }
704
705        // Track build.task events to detect redispatch of abandoned tasks
706        let task_events: Vec<_> = validated_events
707            .iter()
708            .filter(|e| e.topic == "build.task".into())
709            .collect();
710
711        for task_event in task_events {
712            let task_id = Self::extract_task_id(&task_event.payload);
713
714            // Check if this task was already abandoned
715            if self.state.abandoned_tasks.contains(&task_id) {
716                self.state.abandoned_task_redispatches += 1;
717                warn!(
718                    task_id = %task_id,
719                    redispatch_count = self.state.abandoned_task_redispatches,
720                    "Planner redispatched abandoned task"
721                );
722            } else {
723                // Reset redispatch counter on non-abandoned task
724                self.state.abandoned_task_redispatches = 0;
725            }
726        }
727
728        // Track hat-level blocking for legacy thrashing detection
729        let has_blocked_event = !blocked_events.is_empty();
730
731        if has_blocked_event {
732            // Check if same hat as last blocked event
733            if self.state.last_blocked_hat.as_ref() == Some(hat_id) {
734                self.state.consecutive_blocked += 1;
735            } else {
736                self.state.consecutive_blocked = 1;
737                self.state.last_blocked_hat = Some(hat_id.clone());
738            }
739        } else {
740            // Reset counter on any non-blocked event
741            self.state.consecutive_blocked = 0;
742            self.state.last_blocked_hat = None;
743        }
744
745        for event in validated_events {
746            debug!(
747                topic = %event.topic,
748                source = ?event.source,
749                target = ?event.target,
750                "Publishing event from output"
751            );
752            let topic = event.topic.clone();
753            let recipients = self.bus.publish(event);
754
755            // Per spec: "Unknown topic → Log warning, event dropped"
756            if recipients.is_empty() {
757                warn!(
758                    topic = %topic,
759                    source = %hat_id.as_str(),
760                    "Event has no subscribers - will be dropped. Check hat triggers configuration."
761                );
762            }
763        }
764
765        // Check termination conditions
766        self.check_termination()
767    }
768
769    /// Extracts task identifier from build.blocked payload.
770    /// Uses first line of payload as task ID.
771    fn extract_task_id(payload: &str) -> String {
772        payload
773            .lines()
774            .next()
775            .unwrap_or("unknown")
776            .trim()
777            .to_string()
778    }
779
780    /// Adds cost to the cumulative total.
781    pub fn add_cost(&mut self, cost: f64) {
782        self.state.cumulative_cost += cost;
783    }
784
785    /// Verifies all tasks in scratchpad are complete or cancelled.
786    ///
787    /// Returns:
788    /// - `Ok(true)` if all tasks are `[x]` or `[~]`
789    /// - `Ok(false)` if any tasks are `[ ]` (pending)
790    /// - `Err(...)` if scratchpad doesn't exist or can't be read
791    fn verify_scratchpad_complete(&self) -> Result<bool, std::io::Error> {
792        use std::path::Path;
793
794        let scratchpad_path = Path::new(&self.config.core.scratchpad);
795
796        if !scratchpad_path.exists() {
797            return Err(std::io::Error::new(
798                std::io::ErrorKind::NotFound,
799                "Scratchpad does not exist",
800            ));
801        }
802
803        let content = std::fs::read_to_string(scratchpad_path)?;
804
805        let has_pending = content
806            .lines()
807            .any(|line| line.trim_start().starts_with("- [ ]"));
808
809        Ok(!has_pending)
810    }
811
812    /// Processes events from JSONL and routes orphaned events to Ralph.
813    ///
814    /// Also handles backpressure for malformed JSONL lines by:
815    /// 1. Emitting `event.malformed` system events for each parse failure
816    /// 2. Tracking consecutive failures for termination check
817    /// 3. Resetting counter when valid events are parsed
818    ///
819    /// Returns true if Ralph should be invoked to handle orphaned events.
820    pub fn process_events_from_jsonl(&mut self) -> std::io::Result<bool> {
821        let result = self.event_reader.read_new_events()?;
822
823        // Handle malformed lines with backpressure
824        for malformed in &result.malformed {
825            let payload = format!(
826                "Line {}: {}\nContent: {}",
827                malformed.line_number, malformed.error, &malformed.content
828            );
829            let event = Event::new("event.malformed", &payload);
830            self.bus.publish(event);
831            self.state.consecutive_malformed_events += 1;
832            warn!(
833                line = malformed.line_number,
834                consecutive = self.state.consecutive_malformed_events,
835                "Malformed event line detected"
836            );
837        }
838
839        // Reset counter when valid events are parsed
840        if !result.events.is_empty() {
841            self.state.consecutive_malformed_events = 0;
842        }
843
844        if result.events.is_empty() && result.malformed.is_empty() {
845            return Ok(false);
846        }
847
848        let mut has_orphans = false;
849
850        for event in result.events {
851            // Check if any hat subscribes to this event
852            if self.registry.has_subscriber(&event.topic) {
853                // Route to subscriber via EventBus
854                let proto_event = if let Some(payload) = event.payload {
855                    Event::new(event.topic.as_str(), &payload)
856                } else {
857                    Event::new(event.topic.as_str(), "")
858                };
859                self.bus.publish(proto_event);
860            } else {
861                // Orphaned event - Ralph will handle it
862                debug!(
863                    topic = %event.topic,
864                    "Event has no subscriber - will be handled by Ralph"
865                );
866                has_orphans = true;
867            }
868        }
869
870        Ok(has_orphans)
871    }
872
873    /// Checks if output contains LOOP_COMPLETE from Ralph.
874    ///
875    /// Only Ralph can trigger loop completion. Hat outputs are ignored.
876    pub fn check_ralph_completion(&self, output: &str) -> bool {
877        EventParser::contains_promise(output, &self.config.event_loop.completion_promise)
878    }
879
880    /// Publishes the loop.terminate system event to observers.
881    ///
882    /// Per spec: "Published by the orchestrator (not agents) when the loop exits."
883    /// This is an observer-only event—hats cannot trigger on it.
884    ///
885    /// Returns the event for logging purposes.
886    pub fn publish_terminate_event(&mut self, reason: &TerminationReason) -> Event {
887        let elapsed = self.state.elapsed();
888        let duration_str = format_duration(elapsed);
889
890        let payload = format!(
891            "## Reason\n{}\n\n## Status\n{}\n\n## Summary\n- Iterations: {}\n- Duration: {}\n- Exit code: {}",
892            reason.as_str(),
893            termination_status_text(reason),
894            self.state.iteration,
895            duration_str,
896            reason.exit_code()
897        );
898
899        let event = Event::new("loop.terminate", &payload);
900
901        // Publish to bus for observers (but no hat can trigger on this)
902        self.bus.publish(event.clone());
903
904        info!(
905            reason = %reason.as_str(),
906            iterations = self.state.iteration,
907            duration = %duration_str,
908            "Wrapping up: {}. {} iterations in {}.",
909            reason.as_str(),
910            self.state.iteration,
911            duration_str
912        );
913
914        event
915    }
916}
917
918/// Formats a duration as human-readable string.
919fn format_duration(d: Duration) -> String {
920    let total_secs = d.as_secs();
921    let hours = total_secs / 3600;
922    let minutes = (total_secs % 3600) / 60;
923    let seconds = total_secs % 60;
924
925    if hours > 0 {
926        format!("{}h {}m {}s", hours, minutes, seconds)
927    } else if minutes > 0 {
928        format!("{}m {}s", minutes, seconds)
929    } else {
930        format!("{}s", seconds)
931    }
932}
933
934/// Returns a human-readable status based on termination reason.
935fn termination_status_text(reason: &TerminationReason) -> &'static str {
936    match reason {
937        TerminationReason::CompletionPromise => "All tasks completed successfully.",
938        TerminationReason::MaxIterations => "Stopped at iteration limit.",
939        TerminationReason::MaxRuntime => "Stopped at runtime limit.",
940        TerminationReason::MaxCost => "Stopped at cost limit.",
941        TerminationReason::ConsecutiveFailures => "Too many consecutive failures.",
942        TerminationReason::LoopThrashing => {
943            "Loop thrashing detected - same hat repeatedly blocked."
944        }
945        TerminationReason::ValidationFailure => "Too many consecutive malformed JSONL events.",
946        TerminationReason::Stopped => "Manually stopped.",
947        TerminationReason::Interrupted => "Interrupted by signal.",
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954
955    #[test]
956    fn test_initialization_routes_to_ralph_in_multihat_mode() {
957        // Per "Hatless Ralph" architecture: When custom hats are defined,
958        // Ralph is always the executor. Custom hats define topology only.
959        let yaml = r#"
960hats:
961  planner:
962    name: "Planner"
963    triggers: ["task.start", "build.done", "build.blocked"]
964    publishes: ["build.task"]
965"#;
966        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
967        let mut event_loop = EventLoop::new(config);
968
969        event_loop.initialize("Test prompt");
970
971        // Per spec: In multi-hat mode, Ralph handles all iterations
972        let next = event_loop.next_hat();
973        assert!(next.is_some());
974        assert_eq!(
975            next.unwrap().as_str(),
976            "ralph",
977            "Multi-hat mode should route to Ralph"
978        );
979
980        // Verify Ralph's prompt includes the event
981        let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
982        assert!(
983            prompt.contains("task.start"),
984            "Ralph's prompt should include the event"
985        );
986    }
987
988    #[test]
989    fn test_termination_max_iterations() {
990        let yaml = r"
991event_loop:
992  max_iterations: 2
993";
994        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
995        let mut event_loop = EventLoop::new(config);
996        event_loop.state.iteration = 2;
997
998        assert_eq!(
999            event_loop.check_termination(),
1000            Some(TerminationReason::MaxIterations)
1001        );
1002    }
1003
1004    #[test]
1005    fn test_completion_promise_detection() {
1006        use std::fs;
1007        use std::path::Path;
1008
1009        let config = RalphConfig::default();
1010        let mut event_loop = EventLoop::new(config);
1011        event_loop.initialize("Test");
1012
1013        // Create scratchpad with all tasks completed
1014        let scratchpad_path = Path::new(".agent/scratchpad.md");
1015        fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
1016        fs::write(
1017            scratchpad_path,
1018            "## Tasks\n- [x] Task 1 done\n- [x] Task 2 done\n",
1019        )
1020        .unwrap();
1021
1022        // Use Ralph since it's the coordinator that outputs completion promise
1023        let hat_id = HatId::new("ralph");
1024
1025        // First LOOP_COMPLETE - should NOT terminate (needs consecutive confirmation)
1026        let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1027        assert_eq!(reason, None, "First confirmation should not terminate");
1028
1029        // Second consecutive LOOP_COMPLETE - should terminate
1030        let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1031        assert_eq!(
1032            reason,
1033            Some(TerminationReason::CompletionPromise),
1034            "Second consecutive confirmation should terminate"
1035        );
1036
1037        // Cleanup
1038        fs::remove_file(scratchpad_path).ok();
1039    }
1040
1041    #[test]
1042    fn test_builder_cannot_terminate_loop() {
1043        // Per spec: "Builder hat outputs LOOP_COMPLETE → completion promise is ignored (only Ralph can terminate)"
1044        let config = RalphConfig::default();
1045        let mut event_loop = EventLoop::new(config);
1046        event_loop.initialize("Test");
1047
1048        // Builder hat outputs completion promise - should be IGNORED
1049        let hat_id = HatId::new("builder");
1050        let reason = event_loop.process_output(&hat_id, "Done! LOOP_COMPLETE", true);
1051
1052        // Builder cannot terminate, so no termination reason
1053        assert_eq!(reason, None);
1054    }
1055
1056    #[test]
1057    fn test_build_prompt_uses_ghuntley_style_for_all_hats() {
1058        // Per Hatless Ralph spec: All hats use build_custom_hat with ghuntley-style prompts
1059        let yaml = r#"
1060hats:
1061  planner:
1062    name: "Planner"
1063    triggers: ["task.start", "build.done", "build.blocked"]
1064    publishes: ["build.task"]
1065  builder:
1066    name: "Builder"
1067    triggers: ["build.task"]
1068    publishes: ["build.done", "build.blocked"]
1069"#;
1070        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1071        let mut event_loop = EventLoop::new(config);
1072        event_loop.initialize("Test task");
1073
1074        // Planner hat should get ghuntley-style prompt via build_custom_hat
1075        let planner_id = HatId::new("planner");
1076        let planner_prompt = event_loop.build_prompt(&planner_id).unwrap();
1077
1078        // Verify ghuntley-style structure (numbered phases, guardrails)
1079        assert!(
1080            planner_prompt.contains("### 0. ORIENTATION"),
1081            "Planner should use ghuntley-style orientation phase"
1082        );
1083        assert!(
1084            planner_prompt.contains("### GUARDRAILS"),
1085            "Planner prompt should have guardrails section"
1086        );
1087        assert!(
1088            planner_prompt.contains("Fresh context each iteration"),
1089            "Planner prompt should have ghuntley identity"
1090        );
1091
1092        // Now trigger builder hat by publishing build.task event
1093        let hat_id = HatId::new("builder");
1094        event_loop
1095            .bus
1096            .publish(Event::new("build.task", "Build something"));
1097
1098        let builder_prompt = event_loop.build_prompt(&hat_id).unwrap();
1099
1100        // Verify ghuntley-style structure for builder too
1101        assert!(
1102            builder_prompt.contains("### 0. ORIENTATION"),
1103            "Builder should use ghuntley-style orientation phase"
1104        );
1105        assert!(
1106            builder_prompt.contains("Only 1 subagent for build/tests"),
1107            "Builder prompt should have subagent limit"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_build_prompt_uses_custom_hat_for_non_defaults() {
1113        // Per spec: Custom hats use build_custom_hat with their instructions
1114        let yaml = r#"
1115mode: "multi"
1116hats:
1117  reviewer:
1118    name: "Code Reviewer"
1119    triggers: ["review.request"]
1120    instructions: "Review code quality."
1121"#;
1122        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1123        let mut event_loop = EventLoop::new(config);
1124
1125        // Publish event to trigger reviewer
1126        event_loop
1127            .bus
1128            .publish(Event::new("review.request", "Review PR #123"));
1129
1130        let reviewer_id = HatId::new("reviewer");
1131        let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
1132
1133        // Should be custom hat prompt (contains custom instructions)
1134        assert!(
1135            prompt.contains("Code Reviewer"),
1136            "Custom hat should use its name"
1137        );
1138        assert!(
1139            prompt.contains("Review code quality"),
1140            "Custom hat should include its instructions"
1141        );
1142        // Should NOT be planner or builder prompt
1143        assert!(
1144            !prompt.contains("PLANNER MODE"),
1145            "Custom hat should not use planner prompt"
1146        );
1147        assert!(
1148            !prompt.contains("BUILDER MODE"),
1149            "Custom hat should not use builder prompt"
1150        );
1151    }
1152
1153    #[test]
1154    fn test_exit_codes_per_spec() {
1155        // Per spec "Loop Termination" section:
1156        // - 0: Completion promise detected (success)
1157        // - 1: Consecutive failures or unrecoverable error (failure)
1158        // - 2: Max iterations, max runtime, or max cost exceeded (limit)
1159        // - 130: User interrupt (SIGINT = 128 + 2)
1160        assert_eq!(TerminationReason::CompletionPromise.exit_code(), 0);
1161        assert_eq!(TerminationReason::ConsecutiveFailures.exit_code(), 1);
1162        assert_eq!(TerminationReason::LoopThrashing.exit_code(), 1);
1163        assert_eq!(TerminationReason::Stopped.exit_code(), 1);
1164        assert_eq!(TerminationReason::MaxIterations.exit_code(), 2);
1165        assert_eq!(TerminationReason::MaxRuntime.exit_code(), 2);
1166        assert_eq!(TerminationReason::MaxCost.exit_code(), 2);
1167        assert_eq!(TerminationReason::Interrupted.exit_code(), 130);
1168    }
1169
1170    #[test]
1171    fn test_loop_thrashing_detection() {
1172        let config = RalphConfig::default();
1173        let mut event_loop = EventLoop::new(config);
1174        event_loop.initialize("Test");
1175
1176        let planner_id = HatId::new("planner");
1177        let builder_id = HatId::new("builder");
1178
1179        // Planner dispatches task "Fix bug"
1180        event_loop.process_output(
1181            &planner_id,
1182            "<event topic=\"build.task\">Fix bug</event>",
1183            true,
1184        );
1185
1186        // Builder blocks on "Fix bug" three times (should emit build.task.abandoned)
1187        event_loop.process_output(
1188            &builder_id,
1189            "<event topic=\"build.blocked\">Fix bug\nCan't compile</event>",
1190            true,
1191        );
1192        event_loop.process_output(
1193            &builder_id,
1194            "<event topic=\"build.blocked\">Fix bug\nStill can't compile</event>",
1195            true,
1196        );
1197        event_loop.process_output(
1198            &builder_id,
1199            "<event topic=\"build.blocked\">Fix bug\nReally stuck</event>",
1200            true,
1201        );
1202
1203        // Task should be abandoned but loop continues
1204        assert!(
1205            event_loop
1206                .state
1207                .abandoned_tasks
1208                .contains(&"Fix bug".to_string())
1209        );
1210        assert_eq!(event_loop.state.abandoned_task_redispatches, 0);
1211
1212        // Planner redispatches the same abandoned task
1213        event_loop.process_output(
1214            &planner_id,
1215            "<event topic=\"build.task\">Fix bug</event>",
1216            true,
1217        );
1218        assert_eq!(event_loop.state.abandoned_task_redispatches, 1);
1219
1220        // Planner redispatches again
1221        event_loop.process_output(
1222            &planner_id,
1223            "<event topic=\"build.task\">Fix bug</event>",
1224            true,
1225        );
1226        assert_eq!(event_loop.state.abandoned_task_redispatches, 2);
1227
1228        // Third redispatch should trigger LoopThrashing
1229        let reason = event_loop.process_output(
1230            &planner_id,
1231            "<event topic=\"build.task\">Fix bug</event>",
1232            true,
1233        );
1234        assert_eq!(reason, Some(TerminationReason::LoopThrashing));
1235        assert_eq!(event_loop.state.abandoned_task_redispatches, 3);
1236    }
1237
1238    #[test]
1239    fn test_thrashing_counter_resets_on_different_hat() {
1240        let config = RalphConfig::default();
1241        let mut event_loop = EventLoop::new(config);
1242        event_loop.initialize("Test");
1243
1244        let planner_id = HatId::new("planner");
1245        let builder_id = HatId::new("builder");
1246
1247        // Planner blocked twice
1248        event_loop.process_output(
1249            &planner_id,
1250            "<event topic=\"build.blocked\">Stuck</event>",
1251            true,
1252        );
1253        event_loop.process_output(
1254            &planner_id,
1255            "<event topic=\"build.blocked\">Still stuck</event>",
1256            true,
1257        );
1258        assert_eq!(event_loop.state.consecutive_blocked, 2);
1259
1260        // Builder blocked - should reset counter
1261        event_loop.process_output(
1262            &builder_id,
1263            "<event topic=\"build.blocked\">Builder stuck</event>",
1264            true,
1265        );
1266        assert_eq!(event_loop.state.consecutive_blocked, 1);
1267        assert_eq!(event_loop.state.last_blocked_hat, Some(builder_id));
1268    }
1269
1270    #[test]
1271    fn test_thrashing_counter_resets_on_non_blocked_event() {
1272        let config = RalphConfig::default();
1273        let mut event_loop = EventLoop::new(config);
1274        event_loop.initialize("Test");
1275
1276        let planner_id = HatId::new("planner");
1277
1278        // Two blocked events
1279        event_loop.process_output(
1280            &planner_id,
1281            "<event topic=\"build.blocked\">Stuck</event>",
1282            true,
1283        );
1284        event_loop.process_output(
1285            &planner_id,
1286            "<event topic=\"build.blocked\">Still stuck</event>",
1287            true,
1288        );
1289        assert_eq!(event_loop.state.consecutive_blocked, 2);
1290
1291        // Non-blocked event should reset counter
1292        event_loop.process_output(
1293            &planner_id,
1294            "<event topic=\"build.task\">Working now</event>",
1295            true,
1296        );
1297        assert_eq!(event_loop.state.consecutive_blocked, 0);
1298        assert_eq!(event_loop.state.last_blocked_hat, None);
1299    }
1300
1301    #[test]
1302    fn test_custom_hat_with_instructions_uses_build_custom_hat() {
1303        // Per spec: Custom hats with instructions should use build_custom_hat() method
1304        let yaml = r#"
1305hats:
1306  reviewer:
1307    name: "Code Reviewer"
1308    triggers: ["review.request"]
1309    instructions: "Review code for quality and security issues."
1310"#;
1311        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1312        let mut event_loop = EventLoop::new(config);
1313
1314        // Trigger the custom hat
1315        event_loop
1316            .bus
1317            .publish(Event::new("review.request", "Review PR #123"));
1318
1319        let reviewer_id = HatId::new("reviewer");
1320        let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
1321
1322        // Should use build_custom_hat() - verify by checking for ghuntley-style structure
1323        assert!(
1324            prompt.contains("Code Reviewer"),
1325            "Should include custom hat name"
1326        );
1327        assert!(
1328            prompt.contains("Review code for quality and security issues"),
1329            "Should include custom instructions"
1330        );
1331        assert!(
1332            prompt.contains("### 0. ORIENTATION"),
1333            "Should include ghuntley-style orientation"
1334        );
1335        assert!(
1336            prompt.contains("### 1. EXECUTE"),
1337            "Should use ghuntley-style execute phase"
1338        );
1339        assert!(
1340            prompt.contains("### GUARDRAILS"),
1341            "Should include guardrails section"
1342        );
1343
1344        // Should include event context
1345        assert!(
1346            prompt.contains("Review PR #123"),
1347            "Should include event context"
1348        );
1349    }
1350
1351    #[test]
1352    fn test_custom_hat_instructions_included_in_prompt() {
1353        // Test that custom instructions are properly included in the generated prompt
1354        let yaml = r#"
1355hats:
1356  tester:
1357    name: "Test Engineer"
1358    triggers: ["test.request"]
1359    instructions: |
1360      Run comprehensive tests including:
1361      - Unit tests
1362      - Integration tests
1363      - Security scans
1364      Report results with detailed coverage metrics.
1365"#;
1366        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1367        let mut event_loop = EventLoop::new(config);
1368
1369        // Trigger the custom hat
1370        event_loop
1371            .bus
1372            .publish(Event::new("test.request", "Test the auth module"));
1373
1374        let tester_id = HatId::new("tester");
1375        let prompt = event_loop.build_prompt(&tester_id).unwrap();
1376
1377        // Verify all custom instructions are included
1378        assert!(prompt.contains("Run comprehensive tests including"));
1379        assert!(prompt.contains("Unit tests"));
1380        assert!(prompt.contains("Integration tests"));
1381        assert!(prompt.contains("Security scans"));
1382        assert!(prompt.contains("detailed coverage metrics"));
1383
1384        // Verify event context is included
1385        assert!(prompt.contains("Test the auth module"));
1386    }
1387
1388    #[test]
1389    fn test_custom_hat_topology_visible_to_ralph() {
1390        // Per "Hatless Ralph" architecture: Custom hats define topology,
1391        // but Ralph handles all iterations. This test verifies hat topology
1392        // is visible in Ralph's prompt.
1393        let yaml = r#"
1394hats:
1395  deployer:
1396    name: "Deployment Manager"
1397    triggers: ["deploy.request", "deploy.rollback"]
1398    publishes: ["deploy.done", "deploy.failed"]
1399    instructions: "Handle deployment operations safely."
1400"#;
1401        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1402        let mut event_loop = EventLoop::new(config);
1403
1404        // Publish an event that the deployer hat would conceptually handle
1405        event_loop
1406            .bus
1407            .publish(Event::new("deploy.request", "Deploy to staging"));
1408
1409        // In multi-hat mode, next_hat always returns "ralph"
1410        let next_hat = event_loop.next_hat();
1411        assert_eq!(
1412            next_hat.unwrap().as_str(),
1413            "ralph",
1414            "Multi-hat mode routes to Ralph"
1415        );
1416
1417        // Build Ralph's prompt - it should include the event context
1418        let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1419
1420        // Ralph's prompt should include:
1421        // 1. The event topic that was published (payload format: "Event: topic - payload")
1422        assert!(
1423            prompt.contains("deploy.request"),
1424            "Ralph's prompt should include the event topic"
1425        );
1426
1427        // 2. The HATS section documenting the topology
1428        assert!(
1429            prompt.contains("## HATS"),
1430            "Ralph's prompt should include hat topology"
1431        );
1432        assert!(
1433            prompt.contains("Deployment Manager"),
1434            "Hat topology should include hat name"
1435        );
1436        assert!(
1437            prompt.contains("deploy.request"),
1438            "Hat triggers should be in topology"
1439        );
1440    }
1441
1442    #[test]
1443    fn test_default_hat_with_custom_instructions_uses_build_custom_hat() {
1444        // Test that even default hats (planner/builder) use build_custom_hat when they have custom instructions
1445        let yaml = r#"
1446hats:
1447  planner:
1448    name: "Custom Planner"
1449    triggers: ["task.start", "build.done"]
1450    instructions: "Custom planning instructions with special focus on security."
1451"#;
1452        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1453        let mut event_loop = EventLoop::new(config);
1454
1455        event_loop.initialize("Test task");
1456
1457        let planner_id = HatId::new("planner");
1458        let prompt = event_loop.build_prompt(&planner_id).unwrap();
1459
1460        // Should use build_custom_hat with ghuntley-style structure
1461        assert!(prompt.contains("Custom Planner"), "Should use custom name");
1462        assert!(
1463            prompt.contains("Custom planning instructions with special focus on security"),
1464            "Should include custom instructions"
1465        );
1466        assert!(
1467            prompt.contains("### 1. EXECUTE"),
1468            "Should use ghuntley-style execute phase"
1469        );
1470        assert!(
1471            prompt.contains("### GUARDRAILS"),
1472            "Should include guardrails section"
1473        );
1474    }
1475
1476    #[test]
1477    fn test_custom_hat_without_instructions_gets_default_behavior() {
1478        // Test that custom hats without instructions still work with build_custom_hat
1479        let yaml = r#"
1480hats:
1481  monitor:
1482    name: "System Monitor"
1483    triggers: ["monitor.request"]
1484"#;
1485        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1486        let mut event_loop = EventLoop::new(config);
1487
1488        event_loop
1489            .bus
1490            .publish(Event::new("monitor.request", "Check system health"));
1491
1492        let monitor_id = HatId::new("monitor");
1493        let prompt = event_loop.build_prompt(&monitor_id).unwrap();
1494
1495        // Should still use build_custom_hat with ghuntley-style structure
1496        assert!(
1497            prompt.contains("System Monitor"),
1498            "Should include custom hat name"
1499        );
1500        assert!(
1501            prompt.contains("Follow the incoming event instructions"),
1502            "Should have default instructions when none provided"
1503        );
1504        assert!(
1505            prompt.contains("### 0. ORIENTATION"),
1506            "Should include ghuntley-style orientation"
1507        );
1508        assert!(
1509            prompt.contains("### GUARDRAILS"),
1510            "Should include guardrails section"
1511        );
1512        assert!(
1513            prompt.contains("Check system health"),
1514            "Should include event context"
1515        );
1516    }
1517
1518    #[test]
1519    fn test_task_cancellation_with_tilde_marker() {
1520        // Test that tasks marked with [~] are recognized as cancelled
1521        let config = RalphConfig::default();
1522        let mut event_loop = EventLoop::new(config);
1523        event_loop.initialize("Test task");
1524
1525        let ralph_id = HatId::new("ralph");
1526
1527        // Simulate Ralph output with cancelled task
1528        let output = r"
1529## Tasks
1530- [x] Task 1 completed
1531- [~] Task 2 cancelled (too complex for current scope)
1532- [ ] Task 3 pending
1533";
1534
1535        // Process output - should not terminate since there are still pending tasks
1536        let reason = event_loop.process_output(&ralph_id, output, true);
1537        assert_eq!(reason, None, "Should not terminate with pending tasks");
1538    }
1539
1540    #[test]
1541    fn test_partial_completion_with_cancelled_tasks() {
1542        use std::fs;
1543        use std::path::Path;
1544
1545        // Test that cancelled tasks don't block completion when all other tasks are done
1546        let yaml = r#"
1547hats:
1548  builder:
1549    name: "Builder"
1550    triggers: ["build.task"]
1551    publishes: ["build.done"]
1552"#;
1553        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1554        let mut event_loop = EventLoop::new(config);
1555        event_loop.initialize("Test task");
1556
1557        // Ralph handles task.start, not a specific hat
1558        let ralph_id = HatId::new("ralph");
1559
1560        // Create scratchpad with completed and cancelled tasks
1561        let scratchpad_path = Path::new(".agent/scratchpad.md");
1562        fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
1563        let scratchpad_content = r"## Tasks
1564- [x] Core feature implemented
1565- [x] Tests added
1566- [~] Documentation update (cancelled: out of scope)
1567- [~] Performance optimization (cancelled: not needed)
1568";
1569        fs::write(scratchpad_path, scratchpad_content).unwrap();
1570
1571        // Simulate completion with some cancelled tasks
1572        let output = "All done! LOOP_COMPLETE";
1573
1574        // First confirmation - should not terminate yet
1575        let reason = event_loop.process_output(&ralph_id, output, true);
1576        assert_eq!(reason, None, "First confirmation should not terminate");
1577
1578        // Second consecutive confirmation - should complete successfully despite cancelled tasks
1579        let reason = event_loop.process_output(&ralph_id, output, true);
1580        assert_eq!(
1581            reason,
1582            Some(TerminationReason::CompletionPromise),
1583            "Should complete with partial completion"
1584        );
1585
1586        // Cleanup
1587        fs::remove_file(scratchpad_path).ok();
1588    }
1589
1590    #[test]
1591    fn test_planner_auto_cancellation_after_three_blocks() {
1592        // Test that task is abandoned after 3 build.blocked events for same task
1593        let config = RalphConfig::default();
1594        let mut event_loop = EventLoop::new(config);
1595        event_loop.initialize("Test task");
1596
1597        let builder_id = HatId::new("builder");
1598        let planner_id = HatId::new("planner");
1599
1600        // First blocked event for "Task X" - should not abandon
1601        let reason = event_loop.process_output(
1602            &builder_id,
1603            "<event topic=\"build.blocked\">Task X\nmissing dependency</event>",
1604            true,
1605        );
1606        assert_eq!(reason, None);
1607        assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&1));
1608
1609        // Second blocked event for "Task X" - should not abandon
1610        let reason = event_loop.process_output(
1611            &builder_id,
1612            "<event topic=\"build.blocked\">Task X\ndependency issue persists</event>",
1613            true,
1614        );
1615        assert_eq!(reason, None);
1616        assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&2));
1617
1618        // Third blocked event for "Task X" - should emit build.task.abandoned but not terminate
1619        let reason = event_loop.process_output(
1620            &builder_id,
1621            "<event topic=\"build.blocked\">Task X\nsame dependency issue</event>",
1622            true,
1623        );
1624        assert_eq!(reason, None, "Should not terminate, just abandon task");
1625        assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&3));
1626        assert!(
1627            event_loop
1628                .state
1629                .abandoned_tasks
1630                .contains(&"Task X".to_string()),
1631            "Task X should be abandoned"
1632        );
1633
1634        // Planner can now replan around the abandoned task
1635        // Only terminates if planner keeps redispatching the abandoned task
1636        event_loop.process_output(
1637            &planner_id,
1638            "<event topic=\"build.task\">Task X</event>",
1639            true,
1640        );
1641        assert_eq!(event_loop.state.abandoned_task_redispatches, 1);
1642
1643        event_loop.process_output(
1644            &planner_id,
1645            "<event topic=\"build.task\">Task X</event>",
1646            true,
1647        );
1648        assert_eq!(event_loop.state.abandoned_task_redispatches, 2);
1649
1650        let reason = event_loop.process_output(
1651            &planner_id,
1652            "<event topic=\"build.task\">Task X</event>",
1653            true,
1654        );
1655        assert_eq!(
1656            reason,
1657            Some(TerminationReason::LoopThrashing),
1658            "Should terminate after 3 redispatches of abandoned task"
1659        );
1660    }
1661
1662    #[test]
1663    fn test_default_publishes_injects_when_no_events() {
1664        use std::collections::HashMap;
1665        use tempfile::tempdir;
1666
1667        let temp_dir = tempdir().unwrap();
1668        let events_path = temp_dir.path().join("events.jsonl");
1669
1670        let mut config = RalphConfig::default();
1671        let mut hats = HashMap::new();
1672        hats.insert(
1673            "test-hat".to_string(),
1674            crate::config::HatConfig {
1675                name: "test-hat".to_string(),
1676                description: Some("Test hat for default publishes".to_string()),
1677                triggers: vec!["task.start".to_string()],
1678                publishes: vec!["task.done".to_string()],
1679                instructions: "Test hat".to_string(),
1680                backend: None,
1681                default_publishes: Some("task.done".to_string()),
1682            },
1683        );
1684        config.hats = hats;
1685
1686        let mut event_loop = EventLoop::new(config);
1687        event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1688        event_loop.initialize("Test");
1689
1690        let hat_id = HatId::new("test-hat");
1691
1692        // Record event count before execution
1693        let before = event_loop.record_event_count();
1694
1695        // Hat executes but writes no events
1696        // (In real scenario, hat would write to events.jsonl, but we simulate none written)
1697
1698        // Check for default_publishes
1699        event_loop.check_default_publishes(&hat_id, before);
1700
1701        // Verify default event was injected
1702        assert!(
1703            event_loop.has_pending_events(),
1704            "Default event should be injected"
1705        );
1706    }
1707
1708    #[test]
1709    fn test_default_publishes_not_injected_when_events_written() {
1710        use std::collections::HashMap;
1711        use std::io::Write;
1712        use tempfile::tempdir;
1713
1714        let temp_dir = tempdir().unwrap();
1715        let events_path = temp_dir.path().join("events.jsonl");
1716
1717        let mut config = RalphConfig::default();
1718        let mut hats = HashMap::new();
1719        hats.insert(
1720            "test-hat".to_string(),
1721            crate::config::HatConfig {
1722                name: "test-hat".to_string(),
1723                description: Some("Test hat for default publishes".to_string()),
1724                triggers: vec!["task.start".to_string()],
1725                publishes: vec!["task.done".to_string()],
1726                instructions: "Test hat".to_string(),
1727                backend: None,
1728                default_publishes: Some("task.done".to_string()),
1729            },
1730        );
1731        config.hats = hats;
1732
1733        let mut event_loop = EventLoop::new(config);
1734        event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1735        event_loop.initialize("Test");
1736
1737        let hat_id = HatId::new("test-hat");
1738
1739        // Record event count before execution
1740        let before = event_loop.record_event_count();
1741
1742        // Hat writes an event
1743        let mut file = std::fs::File::create(&events_path).unwrap();
1744        writeln!(
1745            file,
1746            r#"{{"topic":"task.done","ts":"2024-01-01T00:00:00Z"}}"#
1747        )
1748        .unwrap();
1749        file.flush().unwrap();
1750
1751        // Check for default_publishes
1752        event_loop.check_default_publishes(&hat_id, before);
1753
1754        // Default should NOT be injected since hat wrote an event
1755        // The event from file should be read by event_reader
1756    }
1757
1758    #[test]
1759    fn test_default_publishes_not_injected_when_not_configured() {
1760        use std::collections::HashMap;
1761        use tempfile::tempdir;
1762
1763        let temp_dir = tempdir().unwrap();
1764        let events_path = temp_dir.path().join("events.jsonl");
1765
1766        let mut config = RalphConfig::default();
1767        let mut hats = HashMap::new();
1768        hats.insert(
1769            "test-hat".to_string(),
1770            crate::config::HatConfig {
1771                name: "test-hat".to_string(),
1772                description: Some("Test hat for default publishes".to_string()),
1773                triggers: vec!["task.start".to_string()],
1774                publishes: vec!["task.done".to_string()],
1775                instructions: "Test hat".to_string(),
1776                backend: None,
1777                default_publishes: None, // No default configured
1778            },
1779        );
1780        config.hats = hats;
1781
1782        let mut event_loop = EventLoop::new(config);
1783        event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
1784        event_loop.initialize("Test");
1785
1786        let hat_id = HatId::new("test-hat");
1787
1788        // Consume the initial event from initialize
1789        let _ = event_loop.build_prompt(&hat_id);
1790
1791        // Record event count before execution
1792        let before = event_loop.record_event_count();
1793
1794        // Hat executes but writes no events
1795
1796        // Check for default_publishes
1797        event_loop.check_default_publishes(&hat_id, before);
1798
1799        // No default should be injected since not configured
1800        assert!(
1801            !event_loop.has_pending_events(),
1802            "No default should be injected"
1803        );
1804    }
1805
1806    #[test]
1807    fn test_get_hat_backend_with_named_backend() {
1808        let yaml = r#"
1809hats:
1810  builder:
1811    name: "Builder"
1812    triggers: ["build.task"]
1813    backend: "claude"
1814"#;
1815        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1816        let event_loop = EventLoop::new(config);
1817
1818        let hat_id = HatId::new("builder");
1819        let backend = event_loop.get_hat_backend(&hat_id);
1820
1821        assert!(backend.is_some());
1822        match backend.unwrap() {
1823            HatBackend::Named(name) => assert_eq!(name, "claude"),
1824            _ => panic!("Expected Named backend"),
1825        }
1826    }
1827
1828    #[test]
1829    fn test_get_hat_backend_with_kiro_agent() {
1830        let yaml = r#"
1831hats:
1832  builder:
1833    name: "Builder"
1834    triggers: ["build.task"]
1835    backend:
1836      type: "kiro"
1837      agent: "my-agent"
1838"#;
1839        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1840        let event_loop = EventLoop::new(config);
1841
1842        let hat_id = HatId::new("builder");
1843        let backend = event_loop.get_hat_backend(&hat_id);
1844
1845        assert!(backend.is_some());
1846        match backend.unwrap() {
1847            HatBackend::KiroAgent { agent, .. } => assert_eq!(agent, "my-agent"),
1848            _ => panic!("Expected KiroAgent backend"),
1849        }
1850    }
1851
1852    #[test]
1853    fn test_get_hat_backend_inherits_global() {
1854        let yaml = r#"
1855cli:
1856  backend: "gemini"
1857hats:
1858  builder:
1859    name: "Builder"
1860    triggers: ["build.task"]
1861"#;
1862        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1863        let event_loop = EventLoop::new(config);
1864
1865        let hat_id = HatId::new("builder");
1866        let backend = event_loop.get_hat_backend(&hat_id);
1867
1868        // Hat has no backend configured, should return None (inherit global)
1869        assert!(backend.is_none());
1870    }
1871
1872    #[test]
1873    fn test_hatless_mode_registers_ralph_catch_all() {
1874        // When no hats are configured, "ralph" should be registered as catch-all
1875        let config = RalphConfig::default();
1876        let mut event_loop = EventLoop::new(config);
1877
1878        // Registry should be empty (no user-defined hats)
1879        assert!(event_loop.registry().is_empty());
1880
1881        // But when we initialize, task.start should route to "ralph"
1882        event_loop.initialize("Test prompt");
1883
1884        // "ralph" should have pending events
1885        let next_hat = event_loop.next_hat();
1886        assert!(next_hat.is_some(), "Should have pending events for ralph");
1887        assert_eq!(next_hat.unwrap().as_str(), "ralph");
1888    }
1889
1890    #[test]
1891    fn test_hatless_mode_builds_ralph_prompt() {
1892        // In hatless mode, build_prompt for "ralph" should return HatlessRalph prompt
1893        let config = RalphConfig::default();
1894        let mut event_loop = EventLoop::new(config);
1895        event_loop.initialize("Test prompt");
1896
1897        let ralph_id = HatId::new("ralph");
1898        let prompt = event_loop.build_prompt(&ralph_id);
1899
1900        assert!(prompt.is_some(), "Should build prompt for ralph");
1901        let prompt = prompt.unwrap();
1902
1903        // Should contain ghuntley-style Ralph identity (uses "I'm Ralph" not "You are Ralph")
1904        assert!(
1905            prompt.contains("I'm Ralph"),
1906            "Should identify as Ralph with ghuntley style"
1907        );
1908        assert!(
1909            prompt.contains("## WORKFLOW"),
1910            "Should have workflow section"
1911        );
1912        assert!(
1913            prompt.contains("## EVENT WRITING"),
1914            "Should have event writing section"
1915        );
1916        assert!(
1917            prompt.contains("LOOP_COMPLETE"),
1918            "Should reference completion promise"
1919        );
1920    }
1921
1922    // === "Always Hatless Iteration" Architecture Tests ===
1923    // These tests verify the core invariants of the Hatless Ralph architecture:
1924    // - Ralph is always the sole executor when custom hats are defined
1925    // - Custom hats define topology (pub/sub contracts) for coordination context
1926    // - Ralph's prompt includes the ## HATS section documenting the topology
1927
1928    #[test]
1929    fn test_always_hatless_ralph_executes_all_iterations() {
1930        // Per acceptance criteria #1: Ralph executes all iterations with custom hats
1931        let yaml = r#"
1932hats:
1933  planner:
1934    name: "Planner"
1935    triggers: ["task.start", "build.done"]
1936    publishes: ["build.task"]
1937  builder:
1938    name: "Builder"
1939    triggers: ["build.task"]
1940    publishes: ["build.done"]
1941"#;
1942        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
1943        let mut event_loop = EventLoop::new(config);
1944
1945        // Simulate the workflow: task.start → planner (conceptually)
1946        event_loop.initialize("Implement feature X");
1947        assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1948
1949        // Simulate build.task → builder (conceptually)
1950        event_loop.build_prompt(&HatId::new("ralph")); // Consume task.start
1951        event_loop
1952            .bus
1953            .publish(Event::new("build.task", "Build feature X"));
1954        assert_eq!(
1955            event_loop.next_hat().unwrap().as_str(),
1956            "ralph",
1957            "build.task should route to Ralph"
1958        );
1959
1960        // Simulate build.done → planner (conceptually)
1961        event_loop.build_prompt(&HatId::new("ralph")); // Consume build.task
1962        event_loop
1963            .bus
1964            .publish(Event::new("build.done", "Feature X complete"));
1965        assert_eq!(
1966            event_loop.next_hat().unwrap().as_str(),
1967            "ralph",
1968            "build.done should route to Ralph"
1969        );
1970    }
1971
1972    #[test]
1973    fn test_always_hatless_solo_mode_unchanged() {
1974        // Per acceptance criteria #3: Solo mode (no hats) operates as before
1975        let config = RalphConfig::default();
1976        let mut event_loop = EventLoop::new(config);
1977
1978        assert!(
1979            event_loop.registry().is_empty(),
1980            "Solo mode has no custom hats"
1981        );
1982
1983        event_loop.initialize("Do something");
1984        assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
1985
1986        // Solo mode prompt should NOT have ## HATS section
1987        let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
1988        assert!(
1989            !prompt.contains("## HATS"),
1990            "Solo mode should not have HATS section"
1991        );
1992    }
1993
1994    #[test]
1995    fn test_always_hatless_topology_preserved_in_prompt() {
1996        // Per acceptance criteria #2 and #4: Hat topology preserved for coordination
1997        let yaml = r#"
1998hats:
1999  planner:
2000    name: "Planner"
2001    triggers: ["task.start", "build.done", "build.blocked"]
2002    publishes: ["build.task"]
2003  builder:
2004    name: "Builder"
2005    triggers: ["build.task"]
2006    publishes: ["build.done", "build.blocked"]
2007"#;
2008        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2009        let mut event_loop = EventLoop::new(config);
2010        event_loop.initialize("Test");
2011
2012        let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
2013
2014        // Verify ## HATS section with topology table
2015        assert!(prompt.contains("## HATS"), "Should have HATS section");
2016        assert!(
2017            prompt.contains("Delegate via events"),
2018            "Should explain delegation"
2019        );
2020        assert!(
2021            prompt.contains("| Hat | Triggers On | Publishes |"),
2022            "Should have topology table"
2023        );
2024
2025        // Verify both hats are documented
2026        assert!(prompt.contains("Planner"), "Should include Planner hat");
2027        assert!(prompt.contains("Builder"), "Should include Builder hat");
2028
2029        // Verify trigger and publish information
2030        assert!(
2031            prompt.contains("build.task"),
2032            "Should document build.task event"
2033        );
2034        assert!(
2035            prompt.contains("build.done"),
2036            "Should document build.done event"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_always_hatless_no_backend_delegation() {
2042        // Per acceptance criteria #5: Custom hat backends are NOT used
2043        // This is architectural - the EventLoop.next_hat() always returns "ralph"
2044        // so per-hat backends (if configured) are never invoked
2045        let yaml = r#"
2046hats:
2047  builder:
2048    name: "Builder"
2049    triggers: ["build.task"]
2050    backend: "gemini"  # This backend should NEVER be used
2051"#;
2052        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2053        let mut event_loop = EventLoop::new(config);
2054
2055        event_loop.bus.publish(Event::new("build.task", "Test"));
2056
2057        // Despite builder having a specific backend, Ralph handles the iteration
2058        let next = event_loop.next_hat();
2059        assert_eq!(
2060            next.unwrap().as_str(),
2061            "ralph",
2062            "Ralph handles all iterations"
2063        );
2064
2065        // The backend delegation would happen in main.rs, but since we always
2066        // return "ralph" from next_hat(), the gemini backend is never selected
2067    }
2068
2069    #[test]
2070    fn test_always_hatless_collects_all_pending_events() {
2071        // Verify Ralph's prompt includes events from ALL hats when in multi-hat mode
2072        let yaml = r#"
2073hats:
2074  planner:
2075    name: "Planner"
2076    triggers: ["task.start"]
2077  builder:
2078    name: "Builder"
2079    triggers: ["build.task"]
2080"#;
2081        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2082        let mut event_loop = EventLoop::new(config);
2083
2084        // Publish events that would go to different hats
2085        event_loop
2086            .bus
2087            .publish(Event::new("task.start", "Start task"));
2088        event_loop
2089            .bus
2090            .publish(Event::new("build.task", "Build something"));
2091
2092        // Ralph should collect ALL pending events
2093        let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
2094
2095        // Both events should be in Ralph's context
2096        assert!(
2097            prompt.contains("task.start"),
2098            "Should include task.start event"
2099        );
2100        assert!(
2101            prompt.contains("build.task"),
2102            "Should include build.task event"
2103        );
2104    }
2105
2106    // === Phase 2: Active Hat Detection Tests ===
2107
2108    #[test]
2109    fn test_determine_active_hats() {
2110        // Create EventLoop with 3 hats (security_reviewer, architecture_reviewer, correctness_reviewer)
2111        let yaml = r#"
2112hats:
2113  security_reviewer:
2114    name: "Security Reviewer"
2115    triggers: ["review.security"]
2116  architecture_reviewer:
2117    name: "Architecture Reviewer"
2118    triggers: ["review.architecture"]
2119  correctness_reviewer:
2120    name: "Correctness Reviewer"
2121    triggers: ["review.correctness"]
2122"#;
2123        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2124        let event_loop = EventLoop::new(config);
2125
2126        // Create events: [Event("review.security", "..."), Event("review.architecture", "...")]
2127        let events = vec![
2128            Event::new("review.security", "Check for vulnerabilities"),
2129            Event::new("review.architecture", "Review design patterns"),
2130        ];
2131
2132        // Call determine_active_hats(&events)
2133        let active_hats = event_loop.determine_active_hats(&events);
2134
2135        // Assert: Returns Vec with exactly security_reviewer and architecture_reviewer Hats
2136        assert_eq!(active_hats.len(), 2, "Should return exactly 2 active hats");
2137
2138        let hat_ids: Vec<&str> = active_hats.iter().map(|h| h.id.as_str()).collect();
2139        assert!(
2140            hat_ids.contains(&"security_reviewer"),
2141            "Should include security_reviewer"
2142        );
2143        assert!(
2144            hat_ids.contains(&"architecture_reviewer"),
2145            "Should include architecture_reviewer"
2146        );
2147        assert!(
2148            !hat_ids.contains(&"correctness_reviewer"),
2149            "Should NOT include correctness_reviewer"
2150        );
2151    }
2152
2153    #[test]
2154    fn test_get_active_hat_id_with_pending_event() {
2155        // Create EventLoop with security_reviewer hat
2156        let yaml = r#"
2157hats:
2158  security_reviewer:
2159    name: "Security Reviewer"
2160    triggers: ["review.security"]
2161"#;
2162        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2163        let mut event_loop = EventLoop::new(config);
2164
2165        // Publish Event("review.security", "...")
2166        event_loop
2167            .bus
2168            .publish(Event::new("review.security", "Check authentication"));
2169
2170        // Call get_active_hat_id()
2171        let active_hat_id = event_loop.get_active_hat_id();
2172
2173        // Assert: Returns HatId("security_reviewer"), NOT "ralph"
2174        assert_eq!(
2175            active_hat_id.as_str(),
2176            "security_reviewer",
2177            "Should return security_reviewer, not ralph"
2178        );
2179    }
2180
2181    #[test]
2182    fn test_get_active_hat_id_no_pending_returns_ralph() {
2183        // Create EventLoop with hats but NO pending events
2184        let yaml = r#"
2185hats:
2186  security_reviewer:
2187    name: "Security Reviewer"
2188    triggers: ["review.security"]
2189"#;
2190        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
2191        let event_loop = EventLoop::new(config);
2192
2193        // Call get_active_hat_id() - no pending events
2194        let active_hat_id = event_loop.get_active_hat_id();
2195
2196        // Assert: Returns HatId("ralph")
2197        assert_eq!(
2198            active_hat_id.as_str(),
2199            "ralph",
2200            "Should return ralph when no pending events"
2201        );
2202    }
2203}