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