use super::*;
#[test]
fn test_initialization_routes_to_ralph_in_multihat_mode() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.start", "build.done", "build.blocked"]
publishes: ["build.task"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let next = event_loop.next_hat();
assert!(next.is_some());
assert_eq!(
next.unwrap().as_str(),
"ralph",
"Multi-hat mode should route to Ralph"
);
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
prompt.contains("task.start"),
"Ralph's prompt should include the event"
);
}
#[test]
fn test_guidance_persists_across_iterations_solo_mode() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
let ralph_id = HatId::new("ralph");
event_loop
.bus
.publish(Event::new("human.guidance", "Keep this in mind"));
let prompt = event_loop.build_prompt(&ralph_id).unwrap();
assert!(
prompt.contains("## ROBOT GUIDANCE"),
"Prompt should include guidance section"
);
assert!(
prompt.contains("Keep this in mind"),
"Prompt should include guidance payload"
);
assert!(
!event_loop.has_pending_events(),
"Guidance should not remain pending after prompt build"
);
let prompt_again = event_loop.build_prompt(&ralph_id).unwrap();
assert!(
prompt_again.contains("Keep this in mind"),
"Guidance should persist across iterations"
);
}
#[test]
fn test_guidance_persists_across_iterations_multi_hat_mode() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.start"]
publishes: ["task.plan"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
let ralph_id = HatId::new("ralph");
event_loop
.bus
.publish(Event::new("human.guidance", "Focus on error handling"));
let prompt = event_loop.build_prompt(&ralph_id).unwrap();
assert!(
prompt.contains("Focus on error handling"),
"Prompt should include guidance payload"
);
let prompt_again = event_loop.build_prompt(&ralph_id).unwrap();
assert!(
prompt_again.contains("Focus on error handling"),
"Guidance should persist across iterations in multi-hat mode"
);
}
#[test]
fn test_guidance_persisted_to_scratchpad() {
let dir = tempfile::tempdir().unwrap();
let scratchpad_path = dir.path().join("scratchpad.md");
let yaml = format!(
r#"
core:
workspace_root: "{}"
scratchpad: "{}"
"#,
dir.path().display(),
scratchpad_path.display()
);
let config: RalphConfig = serde_yaml::from_str(&yaml).unwrap();
let mut event_loop = EventLoop::new(config);
let ralph_id = HatId::new("ralph");
event_loop
.bus
.publish(Event::new("human.guidance", "Use the new API for auth"));
let prompt = event_loop.build_prompt(&ralph_id).unwrap();
assert!(
prompt.contains("Use the new API for auth"),
"Prompt should include guidance"
);
let scratchpad_content = std::fs::read_to_string(&scratchpad_path)
.expect("Scratchpad file should exist after guidance persistence");
assert!(
scratchpad_content.contains("HUMAN GUIDANCE"),
"Scratchpad should contain HUMAN GUIDANCE header"
);
assert!(
scratchpad_content.contains("Use the new API for auth"),
"Scratchpad should contain guidance text"
);
}
#[test]
fn test_guidance_appends_to_existing_scratchpad() {
let dir = tempfile::tempdir().unwrap();
let scratchpad_path = dir.path().join("scratchpad.md");
std::fs::write(&scratchpad_path, "## Existing Notes\n\nSome prior work.\n").unwrap();
let yaml = format!(
r#"
core:
workspace_root: "{}"
scratchpad: "{}"
"#,
dir.path().display(),
scratchpad_path.display()
);
let config: RalphConfig = serde_yaml::from_str(&yaml).unwrap();
let mut event_loop = EventLoop::new(config);
let ralph_id = HatId::new("ralph");
event_loop
.bus
.publish(Event::new("human.guidance", "Focus on error handling"));
let _ = event_loop.build_prompt(&ralph_id).unwrap();
let content = std::fs::read_to_string(&scratchpad_path).unwrap();
assert!(
content.starts_with("## Existing Notes"),
"Existing scratchpad content should be preserved"
);
assert!(
content.contains("Focus on error handling"),
"New guidance should be appended"
);
}
#[test]
fn test_hat_max_activations_emits_exhausted_event() {
let yaml = r#"
hats:
executor:
name: "Executor"
description: "Implements requested changes"
triggers: ["work.start", "review.changes_requested"]
publishes: ["implementation.done"]
code_reviewer:
name: "Code Reviewer"
description: "Reviews changes and requests fixes"
triggers: ["implementation.done"]
publishes: ["review.changes_requested"]
max_activations: 3
escalator:
name: "Escalator"
description: "Handles exhausted hats"
triggers: ["code_reviewer.exhausted"]
publishes: []
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
let ralph = HatId::new("ralph");
event_loop
.bus
.publish(Event::new("work.start", "begin").with_source(ralph.clone()));
for _ in 0..3 {
let _ = event_loop.build_prompt(&ralph).unwrap();
event_loop
.bus
.publish(Event::new("implementation.done", "done"));
let prompt = event_loop.build_prompt(&ralph).unwrap();
assert!(
!prompt.contains("Event: code_reviewer.exhausted"),
"Reviewer should not be exhausted yet"
);
event_loop
.bus
.publish(Event::new("review.changes_requested", "fix"));
}
let _ = event_loop.build_prompt(&ralph).unwrap();
event_loop
.bus
.publish(Event::new("implementation.done", "done"));
let prompt = event_loop.build_prompt(&ralph).unwrap();
assert!(
prompt.contains("Event: code_reviewer.exhausted"),
"Expected code_reviewer.exhausted to be emitted when max_activations is exceeded"
);
let escalator_id = HatId::new("escalator");
assert!(
event_loop
.bus
.peek_pending(&escalator_id)
.is_some_and(|events| {
events
.iter()
.any(|e| e.topic.as_str() == "code_reviewer.exhausted")
}),
"Expected code_reviewer.exhausted to be published for escalator"
);
let reviewer_id = HatId::new("code_reviewer");
assert_eq!(
*event_loop
.state
.hat_activation_counts
.get(&reviewer_id)
.unwrap_or(&0),
3,
"Reviewer should have exactly max activations recorded"
);
event_loop
.bus
.publish(Event::new("implementation.done", "done again").with_source(ralph.clone()));
let prompt = event_loop.build_prompt(&ralph).unwrap();
assert!(
!prompt.contains("Event: implementation.done"),
"Pending events for an exhausted hat should be dropped"
);
assert_eq!(
*event_loop
.state
.hat_activation_counts
.get(&reviewer_id)
.unwrap_or(&0),
3,
"Reviewer should not be activated after exhaustion"
);
}
#[test]
fn test_termination_max_iterations() {
let yaml = r"
event_loop:
max_iterations: 2
";
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.state.iteration = 2;
assert_eq!(
event_loop.check_termination(),
Some(TerminationReason::MaxIterations)
);
}
#[test]
fn test_completion_promise_detection() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let agent_dir = temp_dir.path().join(".agent");
fs::create_dir_all(&agent_dir).unwrap();
let scratchpad_path = agent_dir.join("scratchpad.md");
fs::write(
&scratchpad_path,
"## Tasks\n- [x] Task 1 done\n- [x] Task 2 done\n",
)
.unwrap();
let mut config = RalphConfig::default();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
let events_path = temp_dir.path().join("events.jsonl");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"Should terminate immediately when LOOP_COMPLETE + tasks verified"
);
}
#[test]
fn test_completion_promise_with_open_tasks_in_scratchpad_still_terminates() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let agent_dir = temp_dir.path().join(".agent");
fs::create_dir_all(&agent_dir).unwrap();
let scratchpad_path = agent_dir.join("scratchpad.md");
fs::write(
&scratchpad_path,
"## Tasks\n- [x] Task 1 done\n- [ ] Task 2 still pending\n",
)
.unwrap();
let mut config = RalphConfig::default();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
let events_path = temp_dir.path().join("events.jsonl");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"Scratchpad mode should still trust the agent's decision"
);
}
#[test]
fn test_completion_promise_with_pending_tasks_in_task_store_is_rejected() {
use crate::loop_context::LoopContext;
use crate::task::{Task, TaskStatus};
use crate::task_store::TaskStore;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let tasks_path = temp_dir.path().join(".ralph/agent/tasks.jsonl");
let mut store = TaskStore::load(&tasks_path).unwrap();
let mut task1 = Task::new("Completed task".to_string(), 1);
task1.status = TaskStatus::Closed;
store.add(task1);
let task2 = Task::new("Still open task".to_string(), 2);
store.add(task2);
store.save().unwrap();
let mut config = RalphConfig::default();
config.memories.enabled = true;
config.core.workspace_root = temp_dir.path().to_path_buf();
let loop_context = LoopContext::primary(temp_dir.path().to_path_buf());
let mut event_loop = EventLoop::with_context(config, loop_context);
event_loop.initialize("Test");
let events_path = temp_dir.path().join("events.jsonl");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason, None,
"Should reject completion while runtime tasks remain pending"
);
assert!(
event_loop.has_pending_events(),
"Rejecting completion should inject task.resume so the loop continues"
);
}
#[test]
fn test_completion_promise_requires_last_event() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
write_event_to_jsonl(&events_path, "task.resume", "Continue");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason, None,
"Completion should be ignored when it is not the last event"
);
}
#[test]
fn test_builder_cannot_terminate_loop() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
let hat_id = HatId::new("builder");
let reason = event_loop.process_output(&hat_id, "Done!\nLOOP_COMPLETE", true);
assert_eq!(reason, None);
let temp_dir = tempfile::tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let completion = event_loop.check_completion_event();
assert_eq!(completion, Some(TerminationReason::CompletionPromise));
}
#[test]
fn test_build_prompt_uses_ghuntley_style_for_all_hats() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.start", "build.done", "build.blocked"]
publishes: ["build.task"]
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done", "build.blocked"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test task");
let planner_id = HatId::new("planner");
let planner_prompt = event_loop.build_prompt(&planner_id).unwrap();
assert!(
planner_prompt.contains("### 0. ORIENTATION"),
"Planner should use ghuntley-style orientation phase"
);
assert!(
planner_prompt.contains("### GUARDRAILS"),
"Planner prompt should have guardrails section"
);
assert!(
planner_prompt.contains("You have fresh context each iteration"),
"Planner prompt should have RFC2119 identity"
);
let hat_id = HatId::new("builder");
event_loop
.bus
.publish(Event::new("build.task", "Build something"));
let builder_prompt = event_loop.build_prompt(&hat_id).unwrap();
assert!(
builder_prompt.contains("### 0. ORIENTATION"),
"Builder should use RFC2119-style orientation phase"
);
assert!(
builder_prompt.contains("You MUST NOT use more than 1 subagent for build/tests"),
"Builder prompt should have subagent limit with MUST NOT"
);
}
#[test]
fn test_build_prompt_uses_custom_hat_for_non_defaults() {
let yaml = r#"
mode: "multi"
hats:
reviewer:
name: "Code Reviewer"
triggers: ["review.request"]
instructions: "Review code quality."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("review.request", "Review PR #123"));
let reviewer_id = HatId::new("reviewer");
let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
assert!(
prompt.contains("Code Reviewer"),
"Custom hat should use its name"
);
assert!(
prompt.contains("Review code quality"),
"Custom hat should include its instructions"
);
assert!(
!prompt.contains("PLANNER MODE"),
"Custom hat should not use planner prompt"
);
assert!(
!prompt.contains("BUILDER MODE"),
"Custom hat should not use builder prompt"
);
}
#[test]
fn test_exit_codes_per_spec() {
assert_eq!(TerminationReason::CompletionPromise.exit_code(), 0);
assert_eq!(TerminationReason::ConsecutiveFailures.exit_code(), 1);
assert_eq!(TerminationReason::LoopThrashing.exit_code(), 1);
assert_eq!(TerminationReason::Stopped.exit_code(), 1);
assert_eq!(TerminationReason::MaxIterations.exit_code(), 2);
assert_eq!(TerminationReason::MaxRuntime.exit_code(), 2);
assert_eq!(TerminationReason::MaxCost.exit_code(), 2);
assert_eq!(TerminationReason::Interrupted.exit_code(), 130);
}
fn write_event_to_jsonl(path: &std::path::Path, topic: &str, payload: &str) {
use std::io::Write;
let ts = chrono::Utc::now().to_rfc3339();
let event_json = serde_json::json!({
"topic": topic,
"payload": payload,
"ts": ts
});
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.unwrap();
writeln!(file, "{}", event_json).unwrap();
}
#[test]
fn test_loop_thrashing_detection() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
write_event_to_jsonl(&events_path, "build.blocked", "Fix bug\nCan't compile");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(
&events_path,
"build.blocked",
"Fix bug\nStill can't compile",
);
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "build.blocked", "Fix bug\nReally stuck");
let _ = event_loop.process_events_from_jsonl();
assert!(
event_loop
.state
.abandoned_tasks
.contains(&"Fix bug".to_string()),
"Task should be abandoned after 3 blocks"
);
}
#[test]
fn test_thrashing_counter_increments_on_blocked_events() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
write_event_to_jsonl(&events_path, "build.blocked", "Stuck");
let _ = event_loop.process_events_from_jsonl();
assert_eq!(event_loop.state.consecutive_blocked, 1);
write_event_to_jsonl(&events_path, "build.blocked", "Still stuck");
let _ = event_loop.process_events_from_jsonl();
assert_eq!(event_loop.state.consecutive_blocked, 2);
}
#[test]
fn test_thrashing_counter_resets_on_non_blocked_event() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
write_event_to_jsonl(&events_path, "build.blocked", "Stuck");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "build.blocked", "Still stuck");
let _ = event_loop.process_events_from_jsonl();
assert_eq!(event_loop.state.consecutive_blocked, 2);
write_event_to_jsonl(&events_path, "build.task", "Working now");
let _ = event_loop.process_events_from_jsonl();
assert_eq!(event_loop.state.consecutive_blocked, 0);
}
#[test]
fn test_custom_hat_with_instructions_uses_build_custom_hat() {
let yaml = r#"
hats:
reviewer:
name: "Code Reviewer"
triggers: ["review.request"]
instructions: "Review code for quality and security issues."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("review.request", "Review PR #123"));
let reviewer_id = HatId::new("reviewer");
let prompt = event_loop.build_prompt(&reviewer_id).unwrap();
assert!(
prompt.contains("Code Reviewer"),
"Should include custom hat name"
);
assert!(
prompt.contains("Review code for quality and security issues"),
"Should include custom instructions"
);
assert!(
prompt.contains("### 0. ORIENTATION"),
"Should include ghuntley-style orientation"
);
assert!(
prompt.contains("### 1. EXECUTE"),
"Should use ghuntley-style execute phase"
);
assert!(
prompt.contains("### GUARDRAILS"),
"Should include guardrails section"
);
assert!(
prompt.contains("Review PR #123"),
"Should include event context"
);
}
#[test]
fn test_custom_hat_instructions_included_in_prompt() {
let yaml = r#"
hats:
tester:
name: "Test Engineer"
triggers: ["test.request"]
instructions: |
Run comprehensive tests including:
- Unit tests
- Integration tests
- Security scans
Report results with detailed coverage metrics.
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("test.request", "Test the auth module"));
let tester_id = HatId::new("tester");
let prompt = event_loop.build_prompt(&tester_id).unwrap();
assert!(prompt.contains("Run comprehensive tests including"));
assert!(prompt.contains("Unit tests"));
assert!(prompt.contains("Integration tests"));
assert!(prompt.contains("Security scans"));
assert!(prompt.contains("detailed coverage metrics"));
assert!(prompt.contains("Test the auth module"));
}
#[test]
fn test_active_hat_with_instructions_and_publishing_guide() {
let yaml = r#"
hats:
deployer:
name: "Deployment Manager"
triggers: ["deploy.request", "deploy.rollback"]
publishes: ["deploy.done", "deploy.failed"]
instructions: "Handle deployment operations safely."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("deploy.request", "Deploy to staging"));
let next_hat = event_loop.next_hat();
assert_eq!(
next_hat.unwrap().as_str(),
"ralph",
"Multi-hat mode routes to Ralph"
);
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
prompt.contains("deploy.request"),
"Should include the event topic in pending events"
);
assert!(
prompt.contains("## ACTIVE HAT"),
"Should have ACTIVE HAT section when hat is triggered"
);
assert!(
!prompt.contains("| Hat | Triggers On | Publishes |"),
"Should NOT have topology table when hat is active"
);
assert!(
prompt.contains("Handle deployment operations safely"),
"Should include active hat's instructions"
);
assert!(
prompt.contains("### Event Publishing Guide"),
"Should have Event Publishing Guide"
);
assert!(
prompt.contains("`deploy.done`"),
"Guide should list deploy.done"
);
assert!(
prompt.contains("`deploy.failed`"),
"Guide should list deploy.failed"
);
}
#[test]
fn test_default_hat_with_custom_instructions_uses_build_custom_hat() {
let yaml = r#"
hats:
planner:
name: "Custom Planner"
triggers: ["task.start", "build.done"]
instructions: "Custom planning instructions with special focus on security."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test task");
let planner_id = HatId::new("planner");
let prompt = event_loop.build_prompt(&planner_id).unwrap();
assert!(prompt.contains("Custom Planner"), "Should use custom name");
assert!(
prompt.contains("Custom planning instructions with special focus on security"),
"Should include custom instructions"
);
assert!(
prompt.contains("### 1. EXECUTE"),
"Should use ghuntley-style execute phase"
);
assert!(
prompt.contains("### GUARDRAILS"),
"Should include guardrails section"
);
}
#[test]
fn test_custom_hat_without_instructions_gets_default_behavior() {
let yaml = r#"
hats:
monitor:
name: "System Monitor"
triggers: ["monitor.request"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("monitor.request", "Check system health"));
let monitor_id = HatId::new("monitor");
let prompt = event_loop.build_prompt(&monitor_id).unwrap();
assert!(
prompt.contains("System Monitor"),
"Should include custom hat name"
);
assert!(
prompt.contains("Follow the incoming event instructions"),
"Should have default instructions when none provided"
);
assert!(
prompt.contains("### 0. ORIENTATION"),
"Should include ghuntley-style orientation"
);
assert!(
prompt.contains("### GUARDRAILS"),
"Should include guardrails section"
);
assert!(
prompt.contains("Check system health"),
"Should include event context"
);
}
#[test]
fn test_task_cancellation_with_tilde_marker() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test task");
let ralph_id = HatId::new("ralph");
let output = r"
## Tasks
- [x] Task 1 completed
- [~] Task 2 cancelled (too complex for current scope)
- [ ] Task 3 pending
";
let reason = event_loop.process_output(&ralph_id, output, true);
assert_eq!(reason, None, "Should not terminate with pending tasks");
}
#[test]
fn test_partial_completion_with_cancelled_tasks() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let agent_dir = temp_dir.path().join(".agent");
fs::create_dir_all(&agent_dir).unwrap();
let scratchpad_path = agent_dir.join("scratchpad.md");
let scratchpad_content = r"## Tasks
- [x] Core feature implemented
- [x] Tests added
- [~] Documentation update (cancelled: out of scope)
- [~] Performance optimization (cancelled: not needed)
";
fs::write(&scratchpad_path, scratchpad_content).unwrap();
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done"]
"#;
let mut config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test task");
let events_path = temp_dir.path().join("events.jsonl");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"Should complete immediately with partial completion (cancelled tasks ok)"
);
}
#[test]
fn test_planner_auto_cancellation_after_three_blocks() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test task");
write_event_to_jsonl(&events_path, "build.blocked", "Task X\nmissing dependency");
let _ = event_loop.process_events_from_jsonl();
assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&1));
write_event_to_jsonl(
&events_path,
"build.blocked",
"Task X\ndependency issue persists",
);
let _ = event_loop.process_events_from_jsonl();
assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&2));
write_event_to_jsonl(
&events_path,
"build.blocked",
"Task X\nsame dependency issue",
);
let _ = event_loop.process_events_from_jsonl();
assert_eq!(event_loop.state.task_block_counts.get("Task X"), Some(&3));
assert!(
event_loop
.state
.abandoned_tasks
.contains(&"Task X".to_string()),
"Task X should be abandoned"
);
}
#[test]
fn test_default_publishes_injects_when_no_events() {
use std::collections::HashMap;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
let mut hats = HashMap::new();
hats.insert(
"test-hat".to_string(),
crate::config::HatConfig {
name: "test-hat".to_string(),
description: Some("Test hat for default publishes".to_string()),
triggers: vec!["task.start".to_string()],
publishes: vec!["task.done".to_string()],
instructions: "Test hat".to_string(),
extra_instructions: vec![],
backend_args: None,
backend: None,
default_publishes: Some("task.done".to_string()),
max_activations: None,
scratchpad: None,
disallowed_tools: vec![],
timeout: None,
concurrency: 1,
aggregate: None,
},
);
config.hats = hats;
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
let hat_id = HatId::new("test-hat");
let result = event_loop.process_events_from_jsonl().unwrap();
assert!(!result.had_events, "No events should be found");
event_loop.check_default_publishes(&hat_id);
assert!(
event_loop.has_pending_events(),
"Default event should be injected"
);
assert!(
event_loop.state.seen_topics.contains("task.done"),
"default_publishes should record topic in seen_topics for chain validation"
);
}
#[test]
fn test_default_publishes_not_injected_when_events_written() {
use std::collections::HashMap;
use std::io::Write;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
let mut hats = HashMap::new();
hats.insert(
"test-hat".to_string(),
crate::config::HatConfig {
name: "test-hat".to_string(),
description: Some("Test hat for default publishes".to_string()),
triggers: vec!["task.start".to_string()],
publishes: vec!["task.done".to_string()],
instructions: "Test hat".to_string(),
extra_instructions: vec![],
backend_args: None,
backend: None,
default_publishes: Some("task.done".to_string()),
max_activations: None,
scratchpad: None,
disallowed_tools: vec![],
timeout: None,
concurrency: 1,
aggregate: None,
},
);
config.hats = hats;
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
let _hat_id = HatId::new("test-hat");
let mut file = std::fs::File::create(&events_path).unwrap();
writeln!(
file,
r#"{{"topic":"task.done","ts":"2024-01-01T00:00:00Z"}}"#
)
.unwrap();
file.flush().unwrap();
let result = event_loop.process_events_from_jsonl().unwrap();
assert!(result.had_events, "Events should be found from JSONL");
assert!(
result.had_events,
"Caller should skip check_default_publishes when agent wrote events"
);
}
#[test]
fn test_has_pending_plan_events_in_jsonl_peeks_without_consuming() {
use std::io::Write;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut event_loop = EventLoop::new(RalphConfig::default());
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let mut file = std::fs::File::create(&events_path).unwrap();
writeln!(
file,
r#"{{"topic":"plan.created","payload":"ready","ts":"2024-01-01T00:00:00Z"}}"#
)
.unwrap();
file.flush().unwrap();
assert!(
event_loop
.has_pending_plan_events_in_jsonl()
.expect("peek should succeed"),
"peek should report unread plan.* topics"
);
let processed = event_loop.process_events_from_jsonl().unwrap();
assert!(processed.had_events);
assert!(
processed.had_plan_events,
"processed metadata should preserve semantic plan.* detection"
);
assert!(
processed.human_interact_context.is_none(),
"plan-only batches should not synthesize human.interact metadata"
);
assert!(
!event_loop
.has_pending_plan_events_in_jsonl()
.expect("peek after consume should succeed"),
"peek should return false after unread events are consumed"
);
}
#[test]
fn test_pending_human_interact_context_in_jsonl_peeks_without_consuming() {
use std::io::Write;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut event_loop = EventLoop::new(RalphConfig::default());
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let mut file = std::fs::File::create(&events_path).unwrap();
writeln!(
file,
r#"{{"topic":"human.interact","payload":"Need approval?","ts":"2024-01-01T00:00:00Z"}}"#
)
.unwrap();
file.flush().unwrap();
let pending_context = event_loop
.pending_human_interact_context_in_jsonl()
.expect("peek should succeed")
.expect("peek should include pending human.interact context");
assert_eq!(
pending_context["question"],
serde_json::json!("Need approval?")
);
assert!(
pending_context.get("outcome").is_none(),
"pre-dispatch context should not include outcome metadata"
);
let processed = event_loop.process_events_from_jsonl().unwrap();
assert!(processed.had_events);
let processed_context = processed
.human_interact_context
.expect("processed metadata should include human.interact context");
assert_eq!(
processed_context["question"],
serde_json::json!("Need approval?")
);
assert_eq!(
processed_context["outcome"],
serde_json::json!("no_robot_service")
);
assert!(
event_loop
.pending_human_interact_context_in_jsonl()
.expect("peek after consume should succeed")
.is_none(),
"peek should return no pending human.interact events after consume"
);
}
#[test]
fn test_process_events_from_jsonl_reports_when_plan_topics_absent() {
use std::io::Write;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut event_loop = EventLoop::new(RalphConfig::default());
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let mut file = std::fs::File::create(&events_path).unwrap();
writeln!(
file,
r#"{{"topic":"task.start","payload":"start","ts":"2024-01-01T00:00:00Z"}}"#
)
.unwrap();
file.flush().unwrap();
let processed = event_loop.process_events_from_jsonl().unwrap();
assert!(processed.had_events);
assert!(
!processed.had_plan_events,
"semantic plan.* flag should remain false when no plan topics were published"
);
assert!(
processed.human_interact_context.is_none(),
"non-human batches should not expose human.interact metadata"
);
}
#[test]
fn test_default_publishes_skipped_when_non_orphan_event_written() {
use std::collections::HashMap;
use std::io::Write;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
let mut hats = HashMap::new();
hats.insert(
"hat-a".to_string(),
crate::config::HatConfig {
name: "hat-a".to_string(),
description: Some("Hat triggered by task.start".to_string()),
triggers: vec!["task.start".to_string()],
publishes: vec!["task.done".to_string()],
instructions: "Do the task".to_string(),
extra_instructions: vec![],
backend_args: None,
backend: None,
default_publishes: Some("task.done".to_string()),
max_activations: None,
scratchpad: None,
disallowed_tools: vec![],
timeout: None,
concurrency: 1,
aggregate: None,
},
);
config.hats = hats;
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
let hat_id = HatId::new("hat-a");
let _ = event_loop.build_prompt(&hat_id);
let mut file = std::fs::File::create(&events_path).unwrap();
writeln!(
file,
r#"{{"topic":"task.start","ts":"2024-01-01T00:00:00Z","payload":"starting work"}}"#
)
.unwrap();
file.flush().unwrap();
let result = event_loop.process_events_from_jsonl().unwrap();
assert!(
result.had_events,
"had_events must be true when agent wrote events (even non-orphan ones)"
);
assert!(
!result.has_orphans,
"has_orphans should be false for non-orphan events"
);
}
#[test]
fn test_default_publishes_not_injected_when_not_configured() {
use std::collections::HashMap;
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
let mut hats = HashMap::new();
hats.insert(
"test-hat".to_string(),
crate::config::HatConfig {
name: "test-hat".to_string(),
description: Some("Test hat for default publishes".to_string()),
triggers: vec!["task.start".to_string()],
publishes: vec!["task.done".to_string()],
instructions: "Test hat".to_string(),
extra_instructions: vec![],
backend_args: None,
backend: None,
default_publishes: None, max_activations: None,
scratchpad: None,
disallowed_tools: vec![],
timeout: None,
concurrency: 1,
aggregate: None,
},
);
config.hats = hats;
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
let hat_id = HatId::new("test-hat");
let _ = event_loop.build_prompt(&hat_id);
let result = event_loop.process_events_from_jsonl().unwrap();
assert!(!result.had_events);
event_loop.check_default_publishes(&hat_id);
assert!(
!event_loop.has_pending_events(),
"No default should be injected"
);
}
#[test]
fn test_get_hat_backend_with_named_backend() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
backend: "claude"
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let event_loop = EventLoop::new(config);
let hat_id = HatId::new("builder");
let backend = event_loop.get_hat_backend(&hat_id);
assert!(backend.is_some());
match backend.unwrap() {
HatBackend::Named(name) => assert_eq!(name, "claude"),
_ => panic!("Expected Named backend"),
}
}
#[test]
fn test_get_hat_backend_with_kiro_agent() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
backend:
type: "kiro"
agent: "my-agent"
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let event_loop = EventLoop::new(config);
let hat_id = HatId::new("builder");
let backend = event_loop.get_hat_backend(&hat_id);
assert!(backend.is_some());
match backend.unwrap() {
HatBackend::KiroAgent { agent, .. } => assert_eq!(agent, "my-agent"),
_ => panic!("Expected KiroAgent backend"),
}
}
#[test]
fn test_get_hat_backend_inherits_global() {
let yaml = r#"
cli:
backend: "gemini"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let event_loop = EventLoop::new(config);
let hat_id = HatId::new("builder");
let backend = event_loop.get_hat_backend(&hat_id);
assert!(backend.is_none());
}
#[test]
fn test_hatless_mode_registers_ralph_catch_all() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
assert!(event_loop.registry().is_empty());
event_loop.initialize("Test prompt");
let next_hat = event_loop.next_hat();
assert!(next_hat.is_some(), "Should have pending events for ralph");
assert_eq!(next_hat.unwrap().as_str(), "ralph");
}
#[test]
fn test_hatless_mode_builds_ralph_prompt() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let ralph_id = HatId::new("ralph");
let prompt = event_loop.build_prompt(&ralph_id);
assert!(prompt.is_some(), "Should build prompt for ralph");
let prompt = prompt.unwrap();
assert!(
prompt.contains("You are Ralph"),
"Should identify as Ralph with RFC2119 style"
);
assert!(
prompt.contains("## WORKFLOW"),
"Should have workflow section"
);
assert!(
prompt.contains("## EVENT WRITING"),
"Should have event writing section"
);
assert!(
prompt.contains("LOOP_COMPLETE"),
"Should reference completion event"
);
}
#[test]
fn test_always_hatless_ralph_executes_all_iterations() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.start", "build.done"]
publishes: ["build.task"]
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Implement feature X");
assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
event_loop.build_prompt(&HatId::new("ralph")); event_loop
.bus
.publish(Event::new("build.task", "Build feature X"));
assert_eq!(
event_loop.next_hat().unwrap().as_str(),
"ralph",
"build.task should route to Ralph"
);
event_loop.build_prompt(&HatId::new("ralph")); event_loop
.bus
.publish(Event::new("build.done", "Feature X complete"));
assert_eq!(
event_loop.next_hat().unwrap().as_str(),
"ralph",
"build.done should route to Ralph"
);
}
#[test]
fn test_wave_results_activate_synthesizer() {
let yaml = r#"
hats:
coordinator:
name: "Coordinator"
triggers: ["review.start"]
publishes: ["review.perspective"]
instructions: "Dispatch reviewers as a wave."
reviewer:
name: "Reviewer"
triggers: ["review.perspective"]
publishes: ["review.done"]
concurrency: 3
instructions: "Review code from your specialty."
synthesizer:
name: "Synthesizer"
triggers: ["review.done"]
publishes: ["review.complete"]
instructions: "SYNTHESIZER MODE - Aggregate all review.done findings into a report."
aggregate:
mode: wait_for_all
timeout: 300
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Review the code");
assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
prompt.contains("Coordinator"),
"Should activate coordinator for review.start"
);
event_loop
.bus
.publish(Event::new("review.done", "Rust review findings"));
event_loop
.bus
.publish(Event::new("review.done", "Frontend review findings"));
event_loop
.bus
.publish(Event::new("review.done", "Docs review findings"));
assert!(
event_loop.next_hat().is_some(),
"Should have pending events for next hat"
);
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
prompt.contains("SYNTHESIZER MODE"),
"Should activate synthesizer for review.done events"
);
assert!(
!prompt.contains("Dispatch reviewers"),
"Should NOT have coordinator instructions"
);
assert!(
prompt.contains("review.done"),
"Should contain review.done events in context"
);
}
#[test]
fn test_always_hatless_solo_mode_unchanged() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
assert!(
event_loop.registry().is_empty(),
"Solo mode has no custom hats"
);
event_loop.initialize("Do something");
assert_eq!(event_loop.next_hat().unwrap().as_str(), "ralph");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
!prompt.contains("## HATS"),
"Solo mode should not have HATS section"
);
}
#[test]
fn test_active_hat_gets_publishing_guide_not_topology() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.start", "build.done", "build.blocked"]
publishes: ["build.task"]
builder:
name: "Builder"
description: "Builds code"
triggers: ["build.task"]
publishes: ["build.done", "build.blocked"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
prompt.contains("## ACTIVE HAT"),
"Should have ACTIVE HAT section when hat is triggered"
);
assert!(
!prompt.contains("| Hat | Triggers On | Publishes |"),
"Should NOT have topology table when hat is active"
);
assert!(
!prompt.contains("```mermaid"),
"Should NOT have Mermaid diagram when hat is active"
);
assert!(
prompt.contains("### Event Publishing Guide"),
"Should have Event Publishing Guide for active hat"
);
assert!(
prompt.contains("`build.task` → Received by: Builder"),
"Should show Builder receives build.task"
);
}
#[test]
fn test_always_hatless_no_backend_delegation() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
backend: "gemini" # This backend should NEVER be used
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.bus.publish(Event::new("build.task", "Test"));
let next = event_loop.next_hat();
assert_eq!(
next.unwrap().as_str(),
"ralph",
"Ralph handles all iterations"
);
}
#[test]
fn test_always_hatless_collects_all_pending_events() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.start"]
builder:
name: "Builder"
triggers: ["build.task"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("task.start", "Start task"));
event_loop
.bus
.publish(Event::new("build.task", "Build something"));
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
!prompt.contains("task.start"),
"task.start should be filtered once a downstream event is pending"
);
assert!(
prompt.contains("build.task"),
"Should include build.task event"
);
}
#[test]
fn test_determine_active_hats() {
let yaml = r#"
hats:
security_reviewer:
name: "Security Reviewer"
triggers: ["review.security"]
architecture_reviewer:
name: "Architecture Reviewer"
triggers: ["review.architecture"]
correctness_reviewer:
name: "Correctness Reviewer"
triggers: ["review.correctness"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let event_loop = EventLoop::new(config);
let events = vec![
Event::new("review.security", "Check for vulnerabilities"),
Event::new("review.architecture", "Review design patterns"),
];
let active_hats = event_loop.determine_active_hats(&events);
assert_eq!(active_hats.len(), 2, "Should return exactly 2 active hats");
let hat_ids: Vec<&str> = active_hats.iter().map(|h| h.id.as_str()).collect();
assert!(
hat_ids.contains(&"security_reviewer"),
"Should include security_reviewer"
);
assert!(
hat_ids.contains(&"architecture_reviewer"),
"Should include architecture_reviewer"
);
assert!(
!hat_ids.contains(&"correctness_reviewer"),
"Should NOT include correctness_reviewer"
);
}
#[test]
fn test_get_active_hat_id_with_pending_event() {
let yaml = r#"
hats:
security_reviewer:
name: "Security Reviewer"
triggers: ["review.security"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("review.security", "Check authentication"));
let active_hat_id = event_loop.get_active_hat_id();
assert_eq!(
active_hat_id.as_str(),
"security_reviewer",
"Should return security_reviewer, not ralph"
);
}
#[test]
fn test_get_active_hat_id_no_pending_returns_ralph() {
let yaml = r#"
hats:
security_reviewer:
name: "Security Reviewer"
triggers: ["review.security"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let event_loop = EventLoop::new(config);
let active_hat_id = event_loop.get_active_hat_id();
assert_eq!(
active_hat_id.as_str(),
"ralph",
"Should return ralph when no pending events"
);
}
#[test]
fn test_get_active_hat_id_deterministic_with_multiple_pending() {
let yaml = r#"
hats:
zebra_hat:
name: "Zebra"
triggers: ["work.*"]
alpha_hat:
name: "Alpha"
triggers: ["work.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("work.start", "Begin work"));
let active = event_loop.get_active_hat_id();
assert_eq!(
active.as_str(),
"alpha_hat",
"get_active_hat_id should return alphabetically first matching hat"
);
for _ in 0..100 {
let active = event_loop.get_active_hat_id();
assert_eq!(active.as_str(), "alpha_hat");
}
}
#[test]
fn test_get_active_hat_id_matches_prompt_active_hat_selection() {
let yaml = r#"
hats:
investigator:
name: "Investigator"
triggers: ["debug.start", "hypothesis.confirmed"]
tester:
name: "Tester"
triggers: ["hypothesis.test"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("debug.start", "Investigate a bug"));
event_loop
.bus
.publish(Event::new("hypothesis.test", "Test the hypothesis"));
let preview_active_hat = event_loop.get_active_hat_id();
event_loop
.build_prompt(&HatId::new("ralph"))
.expect("prompt should build");
let built_active_hat = event_loop
.state
.last_active_hat_ids
.first()
.expect("build_prompt should set active hats")
.clone();
assert_eq!(
preview_active_hat.as_str(),
"tester",
"downstream hypothesis.test should outrank kickoff debug.start in preview selection"
);
assert_eq!(
built_active_hat.as_str(),
"tester",
"build_prompt should select tester when debug.start and hypothesis.test are both pending"
);
assert_eq!(
preview_active_hat, built_active_hat,
"display hat preview should match prompt-selected active hat"
);
}
#[test]
fn test_get_active_hat_id_prefers_semantic_event_over_targeted_task_resume() {
let yaml = r#"
hats:
investigator:
name: "Investigator"
triggers: ["task.resume", "debug.start", "hypothesis.confirmed"]
tester:
name: "Tester"
triggers: ["hypothesis.test"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("task.resume", "Recovery").with_target("investigator"));
event_loop
.bus
.publish(Event::new("hypothesis.test", "Test the hypothesis"));
let preview_active_hat = event_loop.get_active_hat_id();
assert_eq!(
preview_active_hat.as_str(),
"tester",
"semantic downstream work should outrank fallback task.resume for display selection"
);
event_loop
.build_prompt(&HatId::new("ralph"))
.expect("prompt should build");
let built_active_hat = event_loop
.state
.last_active_hat_ids
.first()
.expect("build_prompt should set active hats")
.clone();
assert_eq!(
built_active_hat.as_str(),
"tester",
"prompt-selected active hat should ignore fallback task.resume when real work is pending"
);
}
#[test]
fn test_get_active_hat_id_honors_direct_target_before_topic_lookup() {
let yaml = r#"
hats:
alpha_hat:
name: "Alpha"
triggers: ["task.resume"]
zebra_hat:
name: "Zebra"
triggers: ["task.resume"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop
.bus
.publish(Event::new("task.resume", "Recovery").with_target("zebra_hat"));
let active_hat_id = event_loop.get_active_hat_id();
assert_eq!(
active_hat_id.as_str(),
"zebra_hat",
"direct event targets should override generic topic subscriber ordering"
);
}
#[test]
fn test_determine_active_hat_ids_excludes_entrypoint_hats_when_progressed_events_exist() {
let yaml = r#"
event_loop:
starting_event: "review.start"
completion_promise: "review.complete"
hats:
coordinator:
name: "Coordinator"
triggers: ["review.start"]
publishes: ["review.perspective"]
synthesizer:
name: "Synthesizer"
triggers: ["review.done"]
publishes: ["review.complete"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Review the auth module");
let events = vec![
Event::new("review.start", "Review the auth module"),
Event::new("review.done", "## Rust Review\n..."),
Event::new("review.done", "## Frontend Review\n..."),
];
let active = event_loop.determine_active_hat_ids(&events);
assert_eq!(
active.len(),
1,
"Only the synthesizer should be active, not coordinator + synthesizer"
);
assert_eq!(
active[0].as_str(),
"synthesizer",
"The synthesizer (triggered by review.done) should be selected over the coordinator (triggered by stale review.start)"
);
}
#[test]
fn test_determine_active_hat_ids_falls_back_to_entrypoint_when_no_progressed_events() {
let yaml = r#"
event_loop:
starting_event: "review.start"
hats:
coordinator:
name: "Coordinator"
triggers: ["review.start"]
publishes: ["review.perspective"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Review the auth module");
let events = vec![Event::new("review.start", "Review the auth module")];
let active = event_loop.determine_active_hat_ids(&events);
assert_eq!(active.len(), 1);
assert_eq!(active[0].as_str(), "coordinator");
}
#[test]
fn test_check_for_user_prompt_detects_user_prompt_event() {
let config: RalphConfig = serde_yaml::from_str("hats: {}").unwrap();
let event_loop = EventLoop::new(config);
let events = vec![
Event::new("build.task", "Some task"),
Event::new(
"user.prompt",
r#"<event topic="user.prompt" id="q1">What is the feature name?</event>"#,
),
Event::new("other.event", "Other"),
];
let user_prompt = event_loop.check_for_user_prompt(&events);
assert!(user_prompt.is_some(), "Should detect user.prompt event");
assert_eq!(user_prompt.unwrap().id, "q1");
}
#[test]
fn test_check_for_user_prompt_returns_none_when_no_user_prompt() {
let config: RalphConfig = serde_yaml::from_str("hats: {}").unwrap();
let event_loop = EventLoop::new(config);
let events = vec![
Event::new("build.task", "Some task"),
Event::new("build.done", "Task completed"),
];
let user_prompt = event_loop.check_for_user_prompt(&events);
assert!(
user_prompt.is_none(),
"Should not detect user.prompt when not present"
);
}
#[test]
fn test_extract_prompt_id_from_xml_format() {
let config: RalphConfig = serde_yaml::from_str("hats: {}").unwrap();
let event_loop = EventLoop::new(config);
let event = Event::new(
"user.prompt",
r#"<event topic="user.prompt" id="q42">What's the deadline?</event>"#,
);
let events = vec![event];
let user_prompt = event_loop.check_for_user_prompt(&events).unwrap();
assert_eq!(user_prompt.id, "q42");
}
#[test]
fn test_initialize_stores_objective_in_ralph() {
let yaml = r#"
hats:
test_writer:
name: "Test Writer"
triggers: ["tdd.start"]
publishes: ["test.written"]
instructions: "Write failing tests."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Implement a binary search tree with insert and search");
let ralph_id = HatId::new("ralph");
let prompt1 = event_loop.build_prompt(&ralph_id).unwrap();
assert!(
prompt1.contains("## OBJECTIVE"),
"Iteration 1 should have OBJECTIVE section"
);
assert!(
prompt1.contains("Implement a binary search tree"),
"Iteration 1 should show the objective"
);
event_loop
.bus
.publish(Event::new("test.written", "tests/bst_test.rs"));
let prompt2 = event_loop.build_prompt(&ralph_id).unwrap();
assert!(
prompt2.contains("## OBJECTIVE"),
"Iteration 2+ should still have OBJECTIVE section"
);
assert!(
prompt2.contains("Implement a binary search tree"),
"Objective should persist across iterations"
);
}
#[test]
fn test_done_section_suppressed_for_active_hat_via_event_loop() {
let yaml = r#"
hats:
implementer:
name: "Implementer"
triggers: ["test.written"]
publishes: ["test.passing"]
instructions: "Make the failing test pass."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Build a calculator");
let ralph_id = HatId::new("ralph");
let _ = event_loop.build_prompt(&ralph_id);
event_loop
.bus
.publish(Event::new("test.written", "tests/calc_test.rs"));
let prompt = event_loop.build_prompt(&ralph_id).unwrap();
assert!(
!prompt.contains("## DONE"),
"DONE section should be suppressed when a hat is active"
);
assert!(
!prompt.contains("You MUST emit a completion event"),
"Completion instruction should not appear for active hat"
);
assert!(
prompt.contains("## OBJECTIVE"),
"OBJECTIVE should still be visible to active hat"
);
assert!(
prompt.contains("Build a calculator"),
"Objective content should be visible"
);
}
#[test]
fn test_consecutive_failures_increments_on_failed_output() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
let ralph = HatId::new("ralph");
event_loop.process_output(&ralph, "output", false);
assert_eq!(event_loop.state.consecutive_failures, 1);
event_loop.process_output(&ralph, "output", false);
assert_eq!(event_loop.state.consecutive_failures, 2);
}
#[test]
fn test_consecutive_failures_resets_on_success() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
let ralph = HatId::new("ralph");
event_loop.process_output(&ralph, "output", false);
assert_eq!(event_loop.state.consecutive_failures, 1);
event_loop.process_output(&ralph, "output", true);
assert_eq!(event_loop.state.consecutive_failures, 0);
}
#[test]
fn test_cost_based_termination() {
let yaml = r"
event_loop:
max_cost_usd: 10.0
";
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.add_cost(9.99);
assert_eq!(
event_loop.check_termination(),
None,
"Should NOT terminate below max cost"
);
event_loop.add_cost(0.01);
assert_eq!(
event_loop.check_termination(),
Some(TerminationReason::MaxCost),
"Should terminate at exactly max cost"
);
}
#[test]
fn test_malformed_events_increment_counter() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
std::fs::write(&events_path, "not valid json\n").unwrap();
let _ = event_loop.process_events_from_jsonl();
assert_eq!(
event_loop.state.consecutive_malformed_events, 1,
"First malformed line should set counter to 1"
);
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&events_path)
.unwrap();
writeln!(file, "also not json").unwrap();
let _ = event_loop.process_events_from_jsonl();
assert_eq!(
event_loop.state.consecutive_malformed_events, 2,
"Second malformed line should set counter to 2"
);
}
#[test]
fn test_malformed_counter_resets_on_valid_event() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Test");
std::fs::write(&events_path, "not valid json\n").unwrap();
let _ = event_loop.process_events_from_jsonl();
assert_eq!(event_loop.state.consecutive_malformed_events, 1);
write_event_to_jsonl(&events_path, "build.done", "success");
let _ = event_loop.process_events_from_jsonl();
assert_eq!(
event_loop.state.consecutive_malformed_events, 0,
"Counter should reset when valid events are parsed"
);
}
#[test]
fn test_validation_failure_termination_at_threshold() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.state.consecutive_malformed_events = 2;
assert_eq!(
event_loop.check_termination(),
None,
"Should NOT terminate at 2 malformed events (threshold is 3)"
);
event_loop.state.consecutive_malformed_events = 3;
assert_eq!(
event_loop.check_termination(),
Some(TerminationReason::ValidationFailure),
"Should terminate at 3 malformed events"
);
}
#[test]
fn test_stop_requested_termination_clears_signal() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let event_loop = EventLoop::new(config);
let stop_path = temp_dir.path().join(".ralph/stop-requested");
std::fs::create_dir_all(stop_path.parent().unwrap()).unwrap();
std::fs::write(&stop_path, "").unwrap();
assert_eq!(
event_loop.check_termination(),
Some(TerminationReason::Stopped),
"Should terminate when stop requested signal exists"
);
assert!(
!stop_path.exists(),
"Stop signal should be removed after detection"
);
}
#[test]
fn test_format_event_wraps_top_level_prompts() {
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Build a web server");
let ralph = HatId::new("ralph");
let prompt = event_loop.build_prompt(&ralph).unwrap();
assert!(
prompt.contains("<top-level-prompt>"),
"task.start events should be wrapped in <top-level-prompt> tags"
);
event_loop
.bus
.publish(Event::new("build.done", "completed"));
let prompt2 = event_loop.build_prompt(&ralph).unwrap();
assert!(
!prompt2.contains("<top-level-prompt>"),
"Non-top-level events should NOT be wrapped in <top-level-prompt> tags"
);
}
#[test]
fn test_check_ralph_completion_detection() {
let config = RalphConfig::default();
let event_loop = EventLoop::new(config);
assert!(
event_loop.check_ralph_completion(r#"<event topic="LOOP_COMPLETE">done</event>"#),
"Should detect completion event"
);
assert!(
!event_loop.check_ralph_completion("LOOP_COMPLETE\nMore text"),
"Completion requires emitted event, not plain text"
);
assert!(
!event_loop.check_ralph_completion("no match here"),
"Should not detect completion in unrelated text"
);
}
#[test]
fn test_scratchpad_injection_with_content() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let scratchpad_path = temp_dir.path().join(".ralph/agent/scratchpad.md");
std::fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
std::fs::write(
&scratchpad_path,
"## Progress\n- [x] Step 1\n- [ ] Step 2\n",
)
.unwrap();
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
prompt.contains("<scratchpad"),
"Prompt should contain scratchpad header"
);
assert!(
prompt.contains("Step 1"),
"Prompt should contain scratchpad content"
);
assert!(
prompt.contains("Step 2"),
"Prompt should contain scratchpad content"
);
}
#[test]
fn test_scratchpad_injection_no_file() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
!prompt.contains("<scratchpad path="),
"Prompt should NOT contain scratchpad injection when file doesn't exist"
);
}
#[test]
fn test_scratchpad_injection_empty_file() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let scratchpad_path = temp_dir.path().join(".ralph/agent/scratchpad.md");
std::fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
std::fs::write(&scratchpad_path, " \n\n ").unwrap();
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
!prompt.contains("<scratchpad path="),
"Prompt should NOT contain scratchpad injection when file is empty/whitespace"
);
}
#[test]
fn test_scratchpad_injection_ordering() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let scratchpad_path = temp_dir.path().join(".ralph/agent/scratchpad.md");
std::fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
std::fs::write(&scratchpad_path, "scratchpad marker content").unwrap();
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
let scratchpad_pos = prompt
.find("<scratchpad")
.expect("Should contain scratchpad");
let orientation_pos = prompt
.find("### 0a. ORIENTATION")
.expect("Should contain orientation");
assert!(
scratchpad_pos < orientation_pos,
"Scratchpad should appear before ORIENTATION in the prompt"
);
}
#[test]
fn test_scratchpad_injection_tail_truncation() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let scratchpad_path = temp_dir.path().join(".ralph/agent/scratchpad.md");
std::fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
let mut large_content = String::new();
large_content.push_str("### Initial Analysis\n\n");
for i in 0..500 {
large_content.push_str(&format!("Line {}: some padding content here\n", i));
}
large_content.push_str("### Research Phase\n\n");
for i in 500..1000 {
large_content.push_str(&format!("Line {}: some padding content here\n", i));
}
large_content.push_str("### Implementation Notes\n\n");
for i in 1000..2000 {
large_content.push_str(&format!("Line {}: some padding content here\n", i));
}
assert!(
large_content.len() > 16000,
"Test content should exceed budget"
);
std::fs::write(&scratchpad_path, &large_content).unwrap();
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
prompt.contains("<scratchpad"),
"Prompt should contain scratchpad header even when truncated"
);
assert!(
prompt.contains("earlier content truncated"),
"Prompt should indicate truncation occurred"
);
assert!(
prompt.contains("discarded sections:"),
"Prompt should summarize discarded section headings"
);
assert!(
prompt.contains("### Initial Analysis"),
"Prompt should list the discarded heading"
);
assert!(
prompt.contains("Line 1999"),
"Last line should be preserved (tail kept)"
);
assert!(
!prompt.contains("Line 0:"),
"First line should be truncated (head removed)"
);
}
#[test]
fn test_build_done_backpressure_accepts_mutants_warning() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: pass\nmutants: warn (65%)";
write_event_to_jsonl(&events_path, "build.done", payload);
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"build.done".to_string()),
"build.done with mutants warning should pass through. Got: {:?}",
pending_topics
);
assert!(
!pending_topics.contains(&"build.blocked".to_string()),
"build.done should not be blocked by mutation warnings"
);
}
#[test]
fn test_build_done_backpressure_rejects_high_complexity() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 12\nduplication: pass";
write_event_to_jsonl(&events_path, "build.done", payload);
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"build.blocked".to_string()),
"build.done with high complexity should be blocked. Got: {:?}",
pending_topics
);
assert!(
!pending_topics.contains(&"build.done".to_string()),
"build.done should not pass through when complexity is too high"
);
}
#[test]
fn test_build_done_backpressure_rejects_duplication() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: fail";
write_event_to_jsonl(&events_path, "build.done", payload);
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"build.blocked".to_string()),
"build.done with duplication should be blocked. Got: {:?}",
pending_topics
);
assert!(
!pending_topics.contains(&"build.done".to_string()),
"build.done should not pass through when duplication fails"
);
}
#[test]
fn test_build_done_backpressure_rejects_performance_regression() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let payload = "tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass\ncomplexity: 7\nduplication: pass\nperformance: regression";
write_event_to_jsonl(&events_path, "build.done", payload);
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"build.blocked".to_string()),
"build.done with performance regression should be blocked. Got: {:?}",
pending_topics
);
assert!(
!pending_topics.contains(&"build.done".to_string()),
"build.done should not pass through when performance regresses"
);
}
#[test]
fn test_review_done_backpressure_accepts_verified() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "review.done", "tests: pass\nbuild: pass");
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"review.done".to_string()),
"Verified review.done should pass through. Got: {:?}",
pending_topics
);
}
#[test]
fn test_review_done_backpressure_rejects_unverified() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "review.done", "Looks good, approved!");
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"review.blocked".to_string()),
"Unverified review.done should be blocked. Got: {:?}",
pending_topics
);
assert!(
!pending_topics.contains(&"review.done".to_string()),
"review.done should not pass through without evidence"
);
}
#[test]
fn test_review_done_backpressure_rejects_failed_checks() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "review.done", "tests: fail\nbuild: pass");
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"review.blocked".to_string()),
"review.done with failed tests should be blocked. Got: {:?}",
pending_topics
);
}
#[test]
fn test_verify_passed_backpressure_accepts_quality_report() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let payload = "quality.tests: pass\nquality.coverage: 82%\nquality.lint: pass\nquality.audit: pass\nquality.mutation: 72%\nquality.complexity: 7";
write_event_to_jsonl(&events_path, "verify.passed", payload);
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"verify.passed".to_string()),
"verify.passed with quality report should pass through. Got: {:?}",
pending_topics
);
assert!(
!pending_topics.contains(&"verify.failed".to_string()),
"verify.passed should not be blocked by quality report"
);
}
#[test]
fn test_verify_passed_backpressure_rejects_missing_quality_report() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "verify.passed", "All good");
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"verify.failed".to_string()),
"verify.passed without quality report should be blocked. Got: {:?}",
pending_topics
);
assert!(
!pending_topics.contains(&"verify.passed".to_string()),
"verify.passed should not pass through without quality report"
);
}
#[test]
fn test_verify_passed_backpressure_rejects_failed_thresholds() {
use tempfile::tempdir;
let temp_dir = tempdir().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let payload = "quality.tests: pass\nquality.coverage: 60%\nquality.lint: pass\nquality.audit: pass\nquality.mutation: 50%\nquality.complexity: 12";
write_event_to_jsonl(&events_path, "verify.passed", payload);
let _ = event_loop.process_events_from_jsonl();
let empty = Vec::new();
let pending_topics: Vec<String> = event_loop
.bus
.hat_ids()
.flat_map(|id| {
event_loop
.bus
.peek_pending(id)
.unwrap_or(&empty)
.iter()
.map(|e| e.topic.to_string())
.collect::<Vec<_>>()
})
.collect();
assert!(
pending_topics.contains(&"verify.failed".to_string()),
"verify.passed with failing thresholds should be blocked. Got: {:?}",
pending_topics
);
assert!(
!pending_topics.contains(&"verify.passed".to_string()),
"verify.passed should not pass through with failing thresholds"
);
}
#[test]
fn test_inject_robot_skill_when_enabled() {
let yaml = r#"
RObot:
enabled: true
telegram:
bot_token: "fake-token"
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
prompt.contains("<robot-skill>"),
"Prompt should contain <robot-skill> when RObot is enabled"
);
assert!(
prompt.contains("human.interact"),
"Robot skill should mention human.interact"
);
assert!(
prompt.contains("</robot-skill>"),
"Robot skill should have closing tag"
);
}
#[test]
fn test_inject_robot_skill_skipped_when_disabled() {
let config = RalphConfig::default(); let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test prompt");
let prompt = event_loop.build_prompt(&HatId::new("ralph")).unwrap();
assert!(
!prompt.contains("<robot-skill>"),
"Prompt should NOT contain <robot-skill> when RObot is disabled"
);
}
#[test]
fn test_persistent_mode_suppresses_loop_complete() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let agent_dir = temp_dir.path().join(".agent");
fs::create_dir_all(&agent_dir).unwrap();
let scratchpad_path = agent_dir.join("scratchpad.md");
fs::write(&scratchpad_path, "## Tasks\n- [x] All done\n").unwrap();
let mut config = RalphConfig::default();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
config.event_loop.persistent = true;
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
let events_path = temp_dir.path().join("events.jsonl");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason, None,
"Persistent mode should suppress LOOP_COMPLETE termination"
);
let ralph_id = HatId::new("ralph");
let pending = event_loop.bus.peek_pending(&ralph_id);
assert!(
pending.is_some_and(|events| events
.iter()
.any(|e| e.topic.as_str() == "task.resume" && e.payload.contains("Persistent mode"))),
"A task.resume event should be injected after suppressed LOOP_COMPLETE"
);
}
#[test]
fn test_non_persistent_mode_terminates_on_loop_complete() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let agent_dir = temp_dir.path().join(".agent");
fs::create_dir_all(&agent_dir).unwrap();
let scratchpad_path = agent_dir.join("scratchpad.md");
fs::write(&scratchpad_path, "## Tasks\n- [x] All done\n").unwrap();
let mut config = RalphConfig::default();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
config.event_loop.persistent = false;
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
let events_path = temp_dir.path().join("events.jsonl");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"Non-persistent mode should terminate on LOOP_COMPLETE"
);
}
#[test]
fn test_persistent_mode_still_respects_hard_limits() {
let yaml = r"
event_loop:
max_iterations: 2
persistent: true
";
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.state.iteration = 2;
assert_eq!(
event_loop.check_termination(),
Some(TerminationReason::MaxIterations),
"Persistent mode should still respect max_iterations"
);
}
#[test]
fn test_termination_reason_mappings() {
let cases = vec![
(TerminationReason::CompletionPromise, "completed", 0, true),
(TerminationReason::MaxIterations, "max_iterations", 2, false),
(TerminationReason::MaxRuntime, "max_runtime", 2, false),
(TerminationReason::MaxCost, "max_cost", 2, false),
(
TerminationReason::ConsecutiveFailures,
"consecutive_failures",
1,
false,
),
(TerminationReason::LoopThrashing, "loop_thrashing", 1, false),
(
TerminationReason::ValidationFailure,
"validation_failure",
1,
false,
),
(TerminationReason::Stopped, "stopped", 1, false),
(TerminationReason::Interrupted, "interrupted", 130, false),
(
TerminationReason::RestartRequested,
"restart_requested",
3,
false,
),
];
for (reason, expected_str, expected_code, is_success) in cases {
assert_eq!(reason.as_str(), expected_str);
assert_eq!(reason.exit_code(), expected_code);
assert_eq!(reason.is_success(), is_success);
}
}
#[test]
fn test_termination_status_texts() {
let cases = vec![
(
TerminationReason::CompletionPromise,
"All tasks completed successfully.",
),
(
TerminationReason::MaxIterations,
"Stopped at iteration limit.",
),
(TerminationReason::MaxRuntime, "Stopped at runtime limit."),
(TerminationReason::MaxCost, "Stopped at cost limit."),
(
TerminationReason::ConsecutiveFailures,
"Too many consecutive failures.",
),
(
TerminationReason::LoopThrashing,
"Loop thrashing detected - same hat repeatedly blocked.",
),
(
TerminationReason::ValidationFailure,
"Too many consecutive malformed JSONL events.",
),
(TerminationReason::Stopped, "Manually stopped."),
(TerminationReason::Interrupted, "Interrupted by signal."),
(
TerminationReason::RestartRequested,
"Restarting by human request.",
),
];
for (reason, expected) in cases {
assert_eq!(termination_status_text(&reason), expected);
}
}
#[test]
fn test_format_duration_variants() {
use std::time::Duration;
assert_eq!(format_duration(Duration::from_secs(45)), "45s");
assert_eq!(format_duration(Duration::from_secs(61)), "1m 1s");
assert_eq!(format_duration(Duration::from_hours(1)), "1h 0m 0s");
assert_eq!(format_duration(Duration::from_secs(3661)), "1h 1m 1s");
}
#[test]
fn test_extract_task_id_first_line_and_default() {
assert_eq!(
EventLoop::extract_task_id(" task-123 \nMore details"),
"task-123"
);
assert_eq!(EventLoop::extract_task_id(""), "unknown");
}
#[test]
fn test_mutation_warning_reason_variants() {
let fail = MutationEvidence {
status: MutationStatus::Fail,
score_percent: Some(12.5),
};
assert_eq!(
EventLoop::mutation_warning_reason(&fail, Some(80.0)).unwrap(),
"mutation testing failed"
);
let warn = MutationEvidence {
status: MutationStatus::Warn,
score_percent: Some(65.5),
};
assert_eq!(
EventLoop::mutation_warning_reason(&warn, Some(80.0)).unwrap(),
"mutation score below threshold (65.50%)"
);
let unknown = MutationEvidence {
status: MutationStatus::Unknown,
score_percent: None,
};
assert_eq!(
EventLoop::mutation_warning_reason(&unknown, Some(80.0)).unwrap(),
"mutation testing status unknown"
);
let pass_low = MutationEvidence {
status: MutationStatus::Pass,
score_percent: Some(70.0),
};
assert_eq!(
EventLoop::mutation_warning_reason(&pass_low, Some(80.0)).unwrap(),
"mutation score 70.00% below threshold 80.00%"
);
let pass_missing = MutationEvidence {
status: MutationStatus::Pass,
score_percent: None,
};
assert_eq!(
EventLoop::mutation_warning_reason(&pass_missing, Some(80.0)).unwrap(),
"mutation score missing (threshold 80.00%)"
);
let pass_high = MutationEvidence {
status: MutationStatus::Pass,
score_percent: Some(95.0),
};
assert_eq!(
EventLoop::mutation_warning_reason(&pass_high, Some(80.0)),
None
);
let pass_no_threshold = MutationEvidence {
status: MutationStatus::Pass,
score_percent: Some(10.0),
};
assert_eq!(
EventLoop::mutation_warning_reason(&pass_no_threshold, None),
None
);
}
#[test]
fn test_extract_prompt_id_prefers_xml_id() {
let payload = r#"<event topic="user.prompt" id="q42">Question?</event>"#;
assert_eq!(EventLoop::extract_prompt_id(payload), "q42");
}
#[test]
fn test_extract_prompt_id_fallback_prefix() {
let id = EventLoop::extract_prompt_id("Plain question");
assert!(id.starts_with('q'));
assert!(id.len() > 1);
}
#[test]
fn test_check_for_user_prompt_extracts_id_and_text() {
let event_loop = EventLoop::new(RalphConfig::default());
let payload = r#"<event topic="user.prompt" id="q7">Need input</event>"#;
let events = vec![
Event::new("build.done", "ok"),
Event::new("user.prompt", payload),
];
let prompt = event_loop.check_for_user_prompt(&events).expect("prompt");
assert_eq!(prompt.id, "q7");
assert_eq!(prompt.text, payload);
}
#[test]
fn test_task_counts_and_open_task_list() {
use crate::loop_context::LoopContext;
use crate::task::{Task, TaskStatus};
use crate::task_store::TaskStore;
let temp_dir = tempfile::tempdir().unwrap();
let loop_context = LoopContext::primary(temp_dir.path().to_path_buf());
let event_loop = EventLoop::with_context(RalphConfig::default(), loop_context);
let tasks_path = temp_dir.path().join(".ralph/agent/tasks.jsonl");
let mut store = TaskStore::load(&tasks_path).unwrap();
let mut closed = Task::new("Closed task".to_string(), 1);
closed.status = TaskStatus::Closed;
let open = Task::new("Open task".to_string(), 1);
let open_id = open.id.clone();
store.add(closed);
store.add(open);
store.save().unwrap();
let (open_count, closed_count) = event_loop.count_tasks();
assert_eq!(open_count, 1);
assert_eq!(closed_count, 1);
let open_list = event_loop.get_open_task_list();
assert_eq!(open_list.len(), 1);
assert!(open_list[0].contains(&open_id));
assert!(open_list[0].contains("Open task"));
}
#[test]
fn test_verify_tasks_complete_missing_and_pending() {
use crate::loop_context::LoopContext;
use crate::task::Task;
use crate::task_store::TaskStore;
let temp_dir = tempfile::tempdir().unwrap();
let loop_context = LoopContext::primary(temp_dir.path().to_path_buf());
let event_loop = EventLoop::with_context(RalphConfig::default(), loop_context);
assert!(event_loop.verify_tasks_complete().unwrap());
let tasks_path = temp_dir.path().join(".ralph/agent/tasks.jsonl");
let mut store = TaskStore::load(&tasks_path).unwrap();
store.add(Task::new("Open task".to_string(), 1));
store.save().unwrap();
assert!(!event_loop.verify_tasks_complete().unwrap());
}
#[test]
fn test_verify_scratchpad_complete_variants() {
use crate::loop_context::LoopContext;
use std::fs;
let temp_dir = tempfile::tempdir().unwrap();
let loop_context = LoopContext::primary(temp_dir.path().to_path_buf());
let event_loop = EventLoop::with_context(RalphConfig::default(), loop_context);
assert!(event_loop.verify_scratchpad_complete().is_err());
let scratchpad_path = temp_dir.path().join(".ralph/agent/scratchpad.md");
fs::create_dir_all(scratchpad_path.parent().unwrap()).unwrap();
fs::write(&scratchpad_path, "## Tasks\n- [ ] Pending\n").unwrap();
assert!(!event_loop.verify_scratchpad_complete().unwrap());
fs::write(&scratchpad_path, "## Tasks\n- [x] Done\n- [~] Cancelled\n").unwrap();
assert!(event_loop.verify_scratchpad_complete().unwrap());
}
#[test]
fn test_termination_reason_exit_codes() {
let cases = [
(TerminationReason::CompletionPromise, 0),
(TerminationReason::ConsecutiveFailures, 1),
(TerminationReason::LoopThrashing, 1),
(TerminationReason::ValidationFailure, 1),
(TerminationReason::Stopped, 1),
(TerminationReason::MaxIterations, 2),
(TerminationReason::MaxRuntime, 2),
(TerminationReason::MaxCost, 2),
(TerminationReason::Interrupted, 130),
(TerminationReason::RestartRequested, 3),
];
for (reason, code) in cases {
assert_eq!(reason.exit_code(), code, "{reason:?} exit code mismatch");
}
}
#[test]
fn test_termination_reason_strings_and_flags() {
let cases = [
(TerminationReason::CompletionPromise, "completed", true),
(TerminationReason::MaxIterations, "max_iterations", false),
(TerminationReason::MaxRuntime, "max_runtime", false),
(TerminationReason::MaxCost, "max_cost", false),
(
TerminationReason::ConsecutiveFailures,
"consecutive_failures",
false,
),
(TerminationReason::LoopThrashing, "loop_thrashing", false),
(
TerminationReason::ValidationFailure,
"validation_failure",
false,
),
(TerminationReason::Stopped, "stopped", false),
(TerminationReason::Interrupted, "interrupted", false),
(
TerminationReason::RestartRequested,
"restart_requested",
false,
),
];
for (reason, expected_str, is_success) in cases {
assert_eq!(reason.as_str(), expected_str, "{reason:?} as_str mismatch");
assert_eq!(
reason.is_success(),
is_success,
"{reason:?} success mismatch"
);
}
}
#[test]
fn test_has_pending_human_events_detects_guidance() {
let mut event_loop = EventLoop::new(RalphConfig::default());
event_loop
.bus
.publish(Event::new("human.guidance", "Please focus on tests"));
assert!(event_loop.has_pending_human_events());
}
#[test]
fn test_has_pending_human_events_ignores_non_human() {
let mut event_loop = EventLoop::new(RalphConfig::default());
event_loop.bus.publish(Event::new("task.start", "Do work"));
assert!(!event_loop.has_pending_human_events());
}
#[test]
fn test_get_hat_publishes_returns_configured_topics() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.start"]
publishes: ["task.plan", "build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let event_loop = EventLoop::new(config);
let publishes = event_loop.get_hat_publishes(&HatId::new("planner"));
assert_eq!(
publishes,
vec!["task.plan".to_string(), "build.done".to_string()]
);
let missing = event_loop.get_hat_publishes(&HatId::new("missing"));
assert!(missing.is_empty());
}
#[test]
fn test_inject_fallback_event_targets_last_hat() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["task.resume"]
publishes: ["task.plan"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
let planner_id = HatId::new("planner");
event_loop.state.last_hat = Some(planner_id.clone());
assert!(event_loop.inject_fallback_event());
let pending = event_loop
.bus
.peek_pending(&planner_id)
.expect("planner pending");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].topic.as_str(), "task.resume");
assert_eq!(
pending[0].target.as_ref().map(|id| id.as_str()),
Some("planner")
);
assert!(
pending[0]
.payload
.contains("Previous iteration by hat `planner` did not publish an event"),
"Fallback payload should name the stalled hat"
);
assert!(
pending[0].payload.contains("Allowed topics: `task.plan`"),
"Fallback payload should list allowed publish topics"
);
let ralph_id = HatId::new("ralph");
let ralph_pending = event_loop.bus.peek_pending(&ralph_id);
assert!(ralph_pending.is_none_or(|events| events.is_empty()));
}
#[test]
fn test_inject_fallback_event_defaults_to_ralph() {
let mut event_loop = EventLoop::new(RalphConfig::default());
event_loop.state.last_hat = None;
assert!(event_loop.inject_fallback_event());
let ralph_id = HatId::new("ralph");
let pending = event_loop
.bus
.peek_pending(&ralph_id)
.expect("ralph pending");
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].topic.as_str(), "task.resume");
assert!(pending[0].target.is_none());
assert!(pending[0].payload.contains("Review the scratchpad"));
}
#[test]
fn test_paths_use_loop_context_when_present() {
use crate::loop_context::LoopContext;
let temp_dir = tempfile::tempdir().unwrap();
let loop_context = LoopContext::primary(temp_dir.path().to_path_buf());
let event_loop = EventLoop::with_context(RalphConfig::default(), loop_context);
assert_eq!(
event_loop.tasks_path(),
temp_dir.path().join(".ralph/agent/tasks.jsonl")
);
assert_eq!(
event_loop.scratchpad_path(),
temp_dir.path().join(".ralph/agent/scratchpad.md")
);
}
#[test]
fn test_custom_scratchpad_overrides_loop_context() {
use crate::loop_context::LoopContext;
let temp_dir = tempfile::tempdir().unwrap();
let loop_context = LoopContext::primary(temp_dir.path().to_path_buf());
let mut config = RalphConfig::default();
config.core.scratchpad.path = ".ralph/debug/global.md".to_string();
let event_loop = EventLoop::with_context(config, loop_context);
assert_eq!(
event_loop.scratchpad_path(),
temp_dir.path().join(".ralph/debug/global.md"),
"Custom scratchpad in config should be resolved relative to workspace"
);
}
#[test]
fn test_paths_fallback_to_config_when_no_context() {
let temp_dir = tempfile::tempdir().unwrap();
let scratchpad_path = temp_dir.path().join("scratchpad.md");
let mut config = RalphConfig::default();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
let event_loop = EventLoop::new(config);
assert_eq!(
event_loop.tasks_path(),
std::path::PathBuf::from(".ralph/agent/tasks.jsonl")
);
assert_eq!(event_loop.scratchpad_path(), scratchpad_path);
}
#[test]
fn test_record_hat_activations_increments_counts() {
let mut event_loop = EventLoop::new(RalphConfig::default());
let planner = HatId::new("planner");
let reviewer = HatId::new("reviewer");
event_loop.record_hat_activations(&[planner.clone(), reviewer.clone()]);
event_loop.record_hat_activations(std::slice::from_ref(&planner));
assert_eq!(
event_loop.state.hat_activation_counts.get(&planner),
Some(&2)
);
assert_eq!(
event_loop.state.hat_activation_counts.get(&reviewer),
Some(&1)
);
}
#[test]
fn test_check_hat_exhaustion_emits_once_at_limit() {
let yaml = r#"
hats:
reviewer:
name: "Reviewer"
triggers: ["review.done"]
publishes: ["review.blocked"]
max_activations: 2
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
let hat_id = HatId::new("reviewer");
let dropped = vec![
Event::new("review.done", "ok"),
Event::new("build.done", "ok"),
];
event_loop
.state
.hat_activation_counts
.insert(hat_id.clone(), 1);
let (drop, event) = event_loop.check_hat_exhaustion(&hat_id, &dropped);
assert!(!drop);
assert!(event.is_none());
event_loop
.state
.hat_activation_counts
.insert(hat_id.clone(), 2);
let (drop, event) = event_loop.check_hat_exhaustion(&hat_id, &dropped);
assert!(drop);
let exhausted = event.expect("exhausted event");
assert_eq!(exhausted.topic.as_str(), "reviewer.exhausted");
assert!(exhausted.payload.contains("max_activations: 2"));
assert!(exhausted.payload.contains("activations: 2"));
let (drop_again, event_again) = event_loop.check_hat_exhaustion(&hat_id, &dropped);
assert!(drop_again);
assert!(event_again.is_none());
}
#[test]
fn test_scope_enforcement_drops_unauthorized_event() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let yaml = r#"
event_loop:
enforce_hat_scope: true
hats:
builder:
name: "Builder"
triggers: ["build.start"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.state.last_active_hat_ids = vec![HatId::new("builder")];
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
assert!(
!event_loop.state.completion_requested,
"LOOP_COMPLETE should be dropped when builder hat is active (not in publishes)"
);
}
#[test]
fn test_scope_enforcement_allows_authorized_event() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let yaml = r#"
event_loop:
enforce_hat_scope: true
hats:
builder:
name: "Builder"
triggers: ["build.start"]
publishes: ["build.done", "build.blocked"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.state.last_active_hat_ids = vec![HatId::new("builder")];
write_event_to_jsonl(
&events_path,
"build.done",
"tests: pass\nlint: pass\ntypecheck: pass\naudit: pass\ncoverage: pass",
);
let _ = event_loop.process_events_from_jsonl();
assert!(
event_loop.has_pending_events(),
"build.done should pass scope enforcement when builder is active"
);
}
#[test]
fn test_scope_enforcement_skipped_when_no_active_hats() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let yaml = r#"
event_loop:
enforce_hat_scope: true
hats:
builder:
name: "Builder"
triggers: ["build.start"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.state.last_active_hat_ids = vec![];
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
assert!(
event_loop.state.completion_requested,
"LOOP_COMPLETE should be accepted when no active hats (Ralph coordinating)"
);
}
#[test]
fn test_scope_violation_event_published() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let yaml = r#"
event_loop:
enforce_hat_scope: true
hats:
builder:
name: "Builder"
triggers: ["build.start"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.state.last_active_hat_ids = vec![HatId::new("builder")];
write_event_to_jsonl(&events_path, "plan.approved", "Auto-approved");
let _ = event_loop.process_events_from_jsonl();
assert!(
event_loop.has_pending_events(),
"Scope violation event should be published to the bus"
);
}
#[test]
fn test_chain_validation_rejects_completion_without_required_events() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.required_events = vec!["plan.approved".to_string(), "all.built".to_string()];
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "plan.approved", "OK");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason, None,
"LOOP_COMPLETE should be rejected when required events are missing"
);
}
#[test]
fn test_chain_validation_accepts_completion_with_all_required_events() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.required_events = vec!["plan.approved".to_string(), "all.built".to_string()];
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "plan.approved", "OK");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "all.built", "Done");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"LOOP_COMPLETE should be accepted when all required events have been seen"
);
}
#[test]
fn test_chain_validation_tracks_topics_across_iterations() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.required_events = vec![
"research.complete".to_string(),
"plan.approved".to_string(),
"all.built".to_string(),
];
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "research.complete", "findings");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "plan.approved", "ok");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "all.built", "done");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"Topics should be tracked across iterations"
);
}
#[test]
fn test_chain_validation_empty_required_events_allows_completion() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default(); let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"Empty required_events should allow completion (backward compatible)"
);
}
#[test]
fn test_chain_validation_injects_task_resume_on_rejection() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.required_events = vec!["plan.approved".to_string()];
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(reason, None, "Should reject completion");
assert!(
event_loop.has_pending_events(),
"task.resume should be published on rejection"
);
}
#[test]
fn test_loop_cancel_terminates_without_chain_validation() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.cancellation_promise = "loop.cancel".to_string();
config.event_loop.required_events = vec!["plan.approved".to_string(), "all.built".to_string()];
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "loop.cancel", "rejected by human");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_cancellation_event();
assert_eq!(
reason,
Some(TerminationReason::Cancelled),
"loop.cancel should terminate without chain validation"
);
}
#[test]
fn test_default_publishes_satisfies_required_events_for_completion() {
use std::collections::HashMap;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.required_events = vec!["plan.draft".to_string(), "all.built".to_string()];
let mut hats = HashMap::new();
hats.insert(
"planner".to_string(),
crate::config::HatConfig {
name: "planner".to_string(),
description: Some("Plans work".to_string()),
triggers: vec!["research.complete".to_string()],
publishes: vec!["plan.draft".to_string()],
instructions: "Plan".to_string(),
extra_instructions: vec![],
backend: None,
backend_args: None,
default_publishes: Some("plan.draft".to_string()),
max_activations: None,
scratchpad: None,
disallowed_tools: vec![],
timeout: None,
concurrency: 1,
aggregate: None,
},
);
config.hats = hats;
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
let planner_id = HatId::new("planner");
event_loop.check_default_publishes(&planner_id);
write_event_to_jsonl(&events_path, "all.built", "done");
let _ = event_loop.process_events_from_jsonl();
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"default_publishes events should satisfy required_events chain validation"
);
}
#[test]
fn test_default_publishes_completion_promise_triggers_termination() {
use std::collections::HashMap;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.completion_promise = "LOOP_COMPLETE".to_string();
config.event_loop.required_events = vec!["all.built".to_string()];
let mut hats = HashMap::new();
hats.insert(
"final_committer".to_string(),
crate::config::HatConfig {
name: "FinalCommitter".to_string(),
description: Some("Verifies all work is complete".to_string()),
triggers: vec!["all.built".to_string()],
publishes: vec!["LOOP_COMPLETE".to_string()],
instructions: "Verify and complete".to_string(),
extra_instructions: vec![],
backend: None,
backend_args: None,
default_publishes: Some("LOOP_COMPLETE".to_string()),
max_activations: None,
scratchpad: None,
disallowed_tools: vec![],
timeout: None,
concurrency: 1,
aggregate: None,
},
);
config.hats = hats;
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "all.built", "done");
let _ = event_loop.process_events_from_jsonl();
event_loop.state.last_active_hat_ids = vec![HatId::new("final_committer")];
let hat_id = HatId::new("final_committer");
event_loop.check_default_publishes(&hat_id);
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"default_publishes of completion_promise should trigger termination directly, \
not just publish to the bus where it would be lost"
);
}
#[test]
fn test_loop_cancel_exit_code_is_zero() {
assert_eq!(
TerminationReason::Cancelled.exit_code(),
0,
"Cancelled should have exit code 0"
);
}
#[test]
fn test_loop_cancel_is_not_success() {
assert!(
!TerminationReason::Cancelled.is_success(),
"Cancelled should not be a success"
);
}
#[test]
fn test_loop_cancel_takes_priority_over_completion() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.cancellation_promise = "loop.cancel".to_string();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "loop.cancel", "rejected");
write_event_to_jsonl(&events_path, "LOOP_COMPLETE", "Done");
let _ = event_loop.process_events_from_jsonl();
let cancel_reason = event_loop.check_cancellation_event();
assert_eq!(
cancel_reason,
Some(TerminationReason::Cancelled),
"Cancellation should take priority over completion"
);
}
#[test]
fn test_loop_cancel_disabled_when_empty_string() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.cancellation_promise = String::new(); let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "loop.cancel", "rejected");
let _ = event_loop.process_events_from_jsonl();
let reason = event_loop.check_cancellation_event();
assert_eq!(
reason, None,
"loop.cancel should not trigger cancellation when disabled"
);
}
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
struct MockRobotService {
timeout: u64,
should_timeout: bool,
}
impl ralph_proto::RobotService for MockRobotService {
fn send_question(&self, _payload: &str) -> anyhow::Result<i32> {
Ok(1)
}
fn wait_for_response(&self, _events_path: &Path) -> anyhow::Result<Option<String>> {
if self.should_timeout {
Ok(None)
} else {
Ok(Some("approved".to_string()))
}
}
fn send_checkin(
&self,
_: u32,
_: Duration,
_: Option<&ralph_proto::CheckinContext>,
) -> anyhow::Result<i32> {
Ok(0)
}
fn timeout_secs(&self) -> u64 {
self.timeout
}
fn shutdown_flag(&self) -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(false))
}
fn stop(self: Box<Self>) {}
}
struct RestartRequestRobotService;
impl ralph_proto::RobotService for RestartRequestRobotService {
fn send_question(&self, _payload: &str) -> anyhow::Result<i32> {
Ok(1)
}
fn wait_for_response(&self, _events_path: &Path) -> anyhow::Result<Option<String>> {
Ok(Some("Please restart yourself now".to_string()))
}
fn send_checkin(
&self,
_: u32,
_: Duration,
_: Option<&ralph_proto::CheckinContext>,
) -> anyhow::Result<i32> {
Ok(0)
}
fn timeout_secs(&self) -> u64 {
5
}
fn shutdown_flag(&self) -> Arc<AtomicBool> {
Arc::new(AtomicBool::new(false))
}
fn stop(self: Box<Self>) {}
}
#[test]
fn test_human_timeout_injects_timeout_event() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.set_robot_service(Box::new(MockRobotService {
timeout: 5,
should_timeout: true,
}));
write_event_to_jsonl(&events_path, "human.interact", "Please review this plan");
let _ = event_loop.process_events_from_jsonl();
assert!(
event_loop.has_pending_events(),
"human.timeout event should be published on timeout"
);
}
#[test]
fn test_human_response_still_works() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let config = RalphConfig::default();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.set_robot_service(Box::new(MockRobotService {
timeout: 5,
should_timeout: false,
}));
write_event_to_jsonl(&events_path, "human.interact", "Please review this plan");
let _ = event_loop.process_events_from_jsonl();
assert!(
event_loop.has_pending_events(),
"human.response event should be published when response received"
);
}
#[test]
fn test_sync_event_reader_prevents_start_event_double_delivery() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.event_loop.starting_event = Some("work.start".to_string());
let mut event_loop = EventLoop::new(config);
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.initialize("Run the test");
write_event_to_jsonl(&events_path, "work.start", "Run the test");
event_loop.sync_event_reader_to_file_end();
write_event_to_jsonl(&events_path, "seed.ready", "initialized");
let processed = event_loop.process_events_from_jsonl().unwrap();
assert!(
processed.had_events,
"seed.ready should have been processed"
);
let ralph_id = ralph_proto::HatId::new("ralph");
let pending = event_loop.bus.take_pending(&ralph_id);
let work_start_count = pending
.iter()
.filter(|e| e.topic.as_str() == "work.start")
.count();
assert_eq!(
work_start_count, 1,
"work.start must appear exactly once (from initialize), got {work_start_count}"
);
let seed_ready_count = pending
.iter()
.filter(|e| e.topic.as_str() == "seed.ready")
.count();
assert_eq!(
seed_ready_count, 1,
"seed.ready must appear exactly once (from JSONL), got {seed_ready_count}"
);
}
#[test]
fn test_user_prompt_restart_request_creates_restart_signal_file() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
write_event_to_jsonl(&events_path, "user.prompt", "Please restart yourself");
let _ = event_loop.process_events_from_jsonl();
assert!(
temp_dir.path().join(".ralph/restart-requested").exists(),
"user.prompt restart request should create restart signal file"
);
}
#[test]
fn test_human_response_restart_request_creates_restart_signal_file() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let events_path = temp_dir.path().join("events.jsonl");
let mut config = RalphConfig::default();
config.core.workspace_root = temp_dir.path().to_path_buf();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.event_reader = crate::event_reader::EventReader::new(&events_path);
event_loop.set_robot_service(Box::new(RestartRequestRobotService));
write_event_to_jsonl(&events_path, "human.interact", "Need approval");
let _ = event_loop.process_events_from_jsonl();
assert!(
temp_dir.path().join(".ralph/restart-requested").exists(),
"human.response restart request should create restart signal file"
);
}
#[test]
fn test_text_fallback_completions_respects_persistent_mode() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let agent_dir = temp_dir.path().join(".agent");
fs::create_dir_all(&agent_dir).unwrap();
let scratchpad_path = agent_dir.join("scratchpad.md");
fs::write(&scratchpad_path, "## Tasks\n- [x] All done\n").unwrap();
let mut config = RalphConfig::default();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
config.event_loop.persistent = true;
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.request_completion_from_text_fallback();
let reason = event_loop.check_completion_event();
assert_eq!(
reason, None,
"Text fallback completion should be suppressed in persistent mode"
);
}
#[test]
fn test_text_fallback_completions_with_open_runtime_tasks() {
use crate::loop_context::LoopContext;
use crate::task::Task;
use crate::task_store::TaskStore;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let tasks_path = temp_dir.path().join(".ralph/agent/tasks.jsonl");
let mut store = TaskStore::load(&tasks_path).unwrap();
let task1 = Task::new("Open task".to_string(), 1);
store.add(task1);
store.save().unwrap();
let mut config = RalphConfig::default();
config.memories.enabled = true;
config.core.workspace_root = temp_dir.path().to_path_buf();
let loop_context = LoopContext::primary(temp_dir.path().to_path_buf());
let mut event_loop = EventLoop::with_context(config, loop_context);
event_loop.initialize("Test");
event_loop.request_completion_from_text_fallback();
let reason = event_loop.check_completion_event();
assert_eq!(
reason, None,
"Text fallback completion should be rejected with open runtime tasks"
);
assert!(
event_loop.has_pending_events(),
"Rejecting completion should inject task.resume so the loop continues"
);
}
#[test]
fn test_text_fallback_completions_with_missing_required_events() {
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let agent_dir = temp_dir.path().join(".agent");
std::fs::create_dir_all(&agent_dir).unwrap();
let scratchpad_path = agent_dir.join("scratchpad.md");
std::fs::write(&scratchpad_path, "## Tasks\n- [x] All done\n").unwrap();
let mut config = RalphConfig::default();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
config.event_loop.required_events = vec!["review.passed".to_string()];
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.request_completion_from_text_fallback();
let reason = event_loop.check_completion_event();
assert_eq!(
reason, None,
"Text fallback completion should be rejected when required events are missing"
);
assert!(
!event_loop.state().completion_requested,
"completion_requested should be reset after required-events rejection"
);
assert!(
event_loop.has_pending_events(),
"Rejecting completion should inject task.resume so the loop continues"
);
}
#[test]
fn test_text_fallback_completions_succeeds_when_all_checks_pass() {
use std::fs;
use tempfile::TempDir;
let temp_dir = TempDir::new().unwrap();
let agent_dir = temp_dir.path().join(".agent");
fs::create_dir_all(&agent_dir).unwrap();
let scratchpad_path = agent_dir.join("scratchpad.md");
fs::write(&scratchpad_path, "## Tasks\n- [x] Task 1 done\n").unwrap();
let mut config = RalphConfig::default();
config.core.scratchpad.path = scratchpad_path.to_string_lossy().to_string();
let mut event_loop = EventLoop::new(config);
event_loop.initialize("Test");
event_loop.request_completion_from_text_fallback();
let reason = event_loop.check_completion_event();
assert_eq!(
reason,
Some(TerminationReason::CompletionPromise),
"Text fallback completion should succeed when all safety checks pass"
);
}