use crate::config::CoreConfig;
use crate::hat_registry::HatRegistry;
use ralph_proto::{HatId, Topic};
use std::collections::HashMap;
use std::path::Path;
pub struct HatlessRalph {
completion_promise: String,
core: CoreConfig,
hat_topology: Option<HatTopology>,
starting_event: Option<String>,
memories_enabled: bool,
objective: Option<String>,
skill_index: String,
robot_guidance: Vec<String>,
}
pub struct HatTopology {
hats: Vec<HatInfo>,
}
#[derive(Debug, Clone)]
pub struct EventReceiver {
pub name: String,
pub description: String,
pub hat_id: HatId,
pub concurrency: u32,
}
pub struct HatInfo {
pub name: String,
pub description: String,
pub subscribes_to: Vec<String>,
pub publishes: Vec<String>,
pub instructions: String,
pub event_receivers: HashMap<String, Vec<EventReceiver>>,
pub disallowed_tools: Vec<String>,
}
impl HatInfo {
pub fn event_publishing_guide(&self) -> Option<String> {
if self.publishes.is_empty() {
return None;
}
let mut guide = String::from(
"### Event Publishing Guide\n\n\
You MUST publish exactly ONE event when your work is complete.\n\
You MUST use `ralph emit \"<topic>\" \"<brief summary>\"` to publish it.\n\
Plain-language summaries do NOT publish events.\n\
Publishing hands off to the next hat and starts a fresh iteration with clear context.\n\n\
When you publish:\n",
);
for pub_event in &self.publishes {
let receivers = self.event_receivers.get(pub_event);
let receiver_text = match receivers {
Some(r) if !r.is_empty() => r
.iter()
.map(|recv| {
if recv.description.is_empty() {
recv.name.clone()
} else {
format!("{} ({})", recv.name, recv.description)
}
})
.collect::<Vec<_>>()
.join(", "),
_ => "Ralph (coordinates next steps)".to_string(),
};
guide.push_str(&format!(
"- `{}` → Received by: {}\n",
pub_event, receiver_text
));
}
Some(guide)
}
pub fn wave_dispatch_section(&self) -> String {
let mut wave_topics: Vec<(&str, &str, u32)> = Vec::new();
for pub_event in &self.publishes {
if let Some(receivers) = self.event_receivers.get(pub_event) {
for recv in receivers {
if recv.concurrency > 1 {
wave_topics.push((pub_event.as_str(), &recv.name, recv.concurrency));
}
}
}
}
if wave_topics.is_empty() {
return String::new();
}
let mut section = String::from("### Wave Dispatch (Parallel Execution)\n\n");
section.push_str(
"Some downstream hats support parallel execution via waves. \
Use `ralph wave emit` to dispatch multiple items for concurrent processing.\n\n",
);
section.push_str("| Topic | Activates | Max Concurrent |\n");
section.push_str("|-------|-----------|----------------|\n");
for (topic, hat_name, concurrency) in &wave_topics {
section.push_str(&format!(
"| `{}` | {} | {} |\n",
topic, hat_name, concurrency
));
}
section.push('\n');
if let Some((topic, _, _)) = wave_topics.first() {
section.push_str("**Usage:**\n```bash\n");
section.push_str(&format!(
"ralph wave emit {} --payloads \"item1\" \"item2\" \"item3\"\n",
topic
));
section.push_str("```\n\n");
}
section
}
}
impl HatTopology {
pub fn from_registry(registry: &HatRegistry) -> Self {
let hats = registry
.all()
.map(|hat| {
let event_receivers: HashMap<String, Vec<EventReceiver>> = hat
.publishes
.iter()
.map(|pub_topic| {
let receivers: Vec<EventReceiver> = registry
.subscribers(pub_topic)
.into_iter()
.map(|h| {
let concurrency = registry
.get_config(&h.id)
.map(|c| c.concurrency)
.unwrap_or(1);
EventReceiver {
name: h.name.clone(),
description: h.description.clone(),
hat_id: h.id.clone(),
concurrency,
}
})
.collect();
(pub_topic.as_str().to_string(), receivers)
})
.collect();
let disallowed_tools = registry
.get_config(&hat.id)
.map(|c| c.disallowed_tools.clone())
.unwrap_or_default();
HatInfo {
name: hat.name.clone(),
description: hat.description.clone(),
subscribes_to: hat
.subscriptions
.iter()
.map(|t| t.as_str().to_string())
.collect(),
publishes: hat
.publishes
.iter()
.map(|t| t.as_str().to_string())
.collect(),
instructions: hat.instructions.clone(),
event_receivers,
disallowed_tools,
}
})
.collect();
Self { hats }
}
}
impl HatlessRalph {
pub fn new(
completion_promise: impl Into<String>,
core: CoreConfig,
registry: &HatRegistry,
starting_event: Option<String>,
) -> Self {
let hat_topology = if registry.is_empty() {
None
} else {
Some(HatTopology::from_registry(registry))
};
Self {
completion_promise: completion_promise.into(),
core,
hat_topology,
starting_event,
memories_enabled: false, objective: None,
skill_index: String::new(),
robot_guidance: Vec::new(),
}
}
pub fn with_memories_enabled(mut self, enabled: bool) -> Self {
self.memories_enabled = enabled;
self
}
pub fn with_skill_index(mut self, index: String) -> Self {
self.skill_index = index;
self
}
pub fn set_objective(&mut self, objective: String) {
self.objective = Some(objective);
}
pub fn set_robot_guidance(&mut self, guidance: Vec<String>) {
self.robot_guidance = guidance;
}
pub fn clear_robot_guidance(&mut self) {
self.robot_guidance.clear();
}
fn collect_robot_guidance(&self) -> String {
if self.robot_guidance.is_empty() {
return String::new();
}
let mut section = String::from("## ROBOT GUIDANCE\n\n");
if self.robot_guidance.len() == 1 {
section.push_str(&self.robot_guidance[0]);
} else {
for (i, guidance) in self.robot_guidance.iter().enumerate() {
section.push_str(&format!("{}. {}\n", i + 1, guidance));
}
}
section.push_str("\n\n");
section
}
pub fn build_prompt(&self, context: &str, active_hats: &[&ralph_proto::Hat]) -> String {
let mut prompt = self.core_prompt();
if !self.skill_index.is_empty() {
prompt.push_str(&self.skill_index);
prompt.push('\n');
}
if let Some(ref obj) = self.objective {
prompt.push_str(&self.objective_section(obj));
}
let guidance = self.collect_robot_guidance();
if !guidance.is_empty() {
prompt.push_str(&guidance);
}
if !context.trim().is_empty() {
prompt.push_str("## PENDING EVENTS\n\n");
prompt.push_str("You MUST handle these events in this iteration:\n\n");
prompt.push_str(context);
prompt.push_str("\n\n");
}
let has_custom_workflow = active_hats
.iter()
.any(|h| !h.instructions.trim().is_empty());
if !has_custom_workflow {
prompt.push_str(&self.workflow_section());
}
if let Some(topology) = &self.hat_topology {
prompt.push_str(&self.hats_section(topology, active_hats));
}
prompt.push_str(&self.event_writing_section());
if active_hats.is_empty() {
prompt.push_str(&self.done_section(self.objective.as_deref()));
}
prompt
}
fn objective_section(&self, objective: &str) -> String {
format!(
r"## OBJECTIVE
**This is your primary goal. All work must advance this objective.**
> {objective}
You MUST keep this objective in mind throughout the iteration.
You MUST NOT get distracted by workflow mechanics — they serve this goal.
",
objective = objective
)
}
pub fn should_handle(&self, _topic: &Topic) -> bool {
true
}
fn is_fresh_start(&self) -> bool {
if self.starting_event.is_none() {
return false;
}
let path = Path::new(&self.core.scratchpad);
!path.exists()
}
fn core_prompt(&self) -> String {
let guardrails = self
.core
.guardrails
.iter()
.enumerate()
.map(|(i, g)| {
let guardrail = if self.memories_enabled && g.contains("scratchpad is memory") {
g.replace(
"scratchpad is memory",
"save learnings to memories for next time",
)
} else {
g.clone()
};
format!("{}. {guardrail}", 999 + i)
})
.collect::<Vec<_>>()
.join("\n");
let mut prompt = if self.memories_enabled {
r"
### 0a. ORIENTATION
You are Ralph. You are running in a loop. You have fresh context each iteration.
You MUST complete only one atomic task for the overall objective. Leave work for future iterations.
**First thing every iteration:**
1. Review your `<scratchpad>` (auto-injected above) for context on your thinking
2. Review your `<ready-tasks>` (auto-injected above) to see what work exists
3. If tasks exist, pick one. If not, create them from your plan.
"
} else {
r"
### 0a. ORIENTATION
You are Ralph. You are running in a loop. You have fresh context each iteration.
You MUST complete only one atomic task for the overall objective. Leave work for future iterations.
"
}
.to_string();
prompt.push_str(&format!(
r"### 0b. SCRATCHPAD
`{scratchpad}` is your thinking journal for THIS objective.
Its content is auto-injected in `<scratchpad>` tags at the top of your context each iteration.
**Always append** new entries to the end of the file (most recent = bottom).
**Use for:**
- Current understanding and reasoning
- Analysis notes and decisions
- Plan narrative (the 'why' behind your approach)
**Do NOT use for:**
- Tracking what tasks exist or their status (use `ralph tools task`)
- Checklists or todo lists (use `ralph tools task ensure` when a stable key exists, otherwise `ralph tools task add`)
",
scratchpad = self.core.scratchpad,
));
prompt.push_str(&format!(
"### STATE MANAGEMENT\n\n\
**Scratchpad** (`{scratchpad}`) — Your thinking:\n\
- Current understanding and reasoning\n\
- Analysis notes, decisions, plan narrative\n\
- NOT for checklists or status tracking\n\
\n\
**Context Files** (`.ralph/agent/*.md`) — Research artifacts:\n\
- Analysis and temporary notes\n\
- Read when relevant\n\
\n\
**Tool reliability rule:** Assume the workflow commands are available when the loop is already running and use the task-specific command you actually need.\n\
The loop sets `$RALPH_BIN` to the current Ralph executable. Prefer `$RALPH_BIN emit ...` and `$RALPH_BIN tools ...` when you need a direct command form.\n\
Do not spend turns on shell or tool-availability diagnosis unless the task is explicitly about the runtime environment.\n\
Do NOT infer failure from empty or terse stdout alone. Verify the intended side effect in the task/event state or in the files and artifacts the command should have changed.\n\
Keep temporary artifacts where later steps can still inspect them, such as a repo-local `logs/` directory or `/var/tmp` when needed.\n\
\n",
scratchpad = self.core.scratchpad,
));
if let Ok(entries) = std::fs::read_dir(".ralph/agent") {
let md_files: Vec<String> = entries
.filter_map(|e| e.ok())
.filter_map(|e| {
let path = e.path();
let fname = path.file_name().and_then(|s| s.to_str());
if path.extension().and_then(|s| s.to_str()) == Some("md")
&& fname != Some("memories.md")
&& fname != Some("scratchpad.md")
{
path.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
} else {
None
}
})
.collect();
if !md_files.is_empty() {
prompt.push_str("### AVAILABLE CONTEXT FILES\n\n");
prompt.push_str(
"Context files in `.ralph/agent/` (read if relevant to current work):\n",
);
for file in md_files {
prompt.push_str(&format!("- `.ralph/agent/{}`\n", file));
}
prompt.push('\n');
}
}
prompt.push_str(&format!(
r"### GUARDRAILS
{guardrails}
",
guardrails = guardrails,
));
prompt
}
fn workflow_section(&self) -> String {
if self.hat_topology.is_some() {
if self.is_fresh_start() {
return format!(
r#"## WORKFLOW
**FAST PATH**: You MUST publish `{}` immediately to start the hat workflow.
You MUST use `ralph emit "{}" "<brief handoff>"` and stop immediately.
You MUST NOT plan or analyze — delegate now.
"#,
self.starting_event.as_ref().unwrap(),
self.starting_event.as_ref().unwrap()
);
}
if self.memories_enabled {
format!(
r"## WORKFLOW
### 1. PLAN
You MUST update `{scratchpad}` with your understanding and plan.
You MUST check `<ready-tasks>` first.
You MUST represent work items with runtime tasks using `ralph tools task ensure` when you can derive a stable key, otherwise `ralph tools task add`.
You SHOULD search memories with `ralph tools memory search` before acting in unfamiliar areas.
If confidence is 80 or below on a consequential decision, you MUST document it in `.ralph/agent/decisions.md`.
### 2. DELEGATE
You MUST emit exactly ONE next event via `ralph emit` to hand off to specialized hats.
Plain-language summaries do NOT hand off work.
You MUST NOT do implementation work — delegation is your only job.
",
scratchpad = self.core.scratchpad
)
} else {
format!(
r"## WORKFLOW
### 1. PLAN
You MUST update `{scratchpad}` with prioritized tasks to complete the objective end-to-end.
### 2. DELEGATE
You MUST emit exactly ONE next event via `ralph emit` to hand off to specialized hats.
Plain-language summaries do NOT hand off work.
You MUST NOT do implementation work — delegation is your only job.
",
scratchpad = self.core.scratchpad
)
}
} else {
if self.memories_enabled {
format!(
r"## WORKFLOW
### 1. Study the prompt.
You MUST study, explore, and research what needs to be done.
### 2. PLAN
You MUST update `{scratchpad}` with your understanding and plan.
You MUST check `<ready-tasks>` first.
You MUST represent work items with runtime tasks using `ralph tools task ensure` when you can derive a stable key, otherwise `ralph tools task add`.
You SHOULD search memories with `ralph tools memory search` before acting in unfamiliar areas.
If confidence is 80 or below on a consequential decision, you MUST document it in `.ralph/agent/decisions.md`.
### 3. IMPLEMENT
You MUST pick exactly ONE task from `<ready-tasks>` to implement.
You MUST mark it in progress with `ralph tools task start <id>` before implementation.
### 4. VERIFY & COMMIT
You MUST run tests and verify the implementation works.
If the target is runnable or user-facing, you MUST exercise it with the strongest available harness (Playwright, tmux, real CLI/API) before committing.
You SHOULD try at least one realistic failure-path or adversarial input during verification.
If this turn is likely to take more than a few minutes, you SHOULD send `ralph tools interact progress`.
You MUST commit after verification passes - one commit per task.
You SHOULD run `git diff --cached` to review staged changes before committing.
You MUST close the task with `ralph tools task close <id>` AFTER commit.
You SHOULD save learnings to memories with `ralph tools memory add`.
If a command fails, a dependency is missing, or work becomes blocked and you cannot resolve it in this iteration, you MUST record a `fix` memory and `ralph tools task fail <id>` or `ralph tools task reopen <id>` before stopping.
You MUST update scratchpad with what you learned (tasks track what remains).
### 5. EXIT
You MUST exit after completing ONE task.
",
scratchpad = self.core.scratchpad
)
} else {
format!(
r"## WORKFLOW
### 1. Study the prompt.
You MUST study, explore, and research what needs to be done.
You MAY use parallel subagents (up to 10) for searches.
### 2. PLAN
You MUST update `{scratchpad}` with prioritized tasks to complete the objective end-to-end.
### 3. IMPLEMENT
You MUST pick exactly ONE task to implement.
You MUST NOT use more than 1 subagent for build/tests.
### 4. COMMIT
If the target is runnable or user-facing, you MUST exercise it with the strongest available harness (Playwright, tmux, real CLI/API) before committing.
You SHOULD try at least one realistic failure-path or adversarial input during verification.
You MUST commit after completing each atomic unit of work.
You MUST capture the why, not just the what.
You SHOULD run `git diff` before committing to review changes.
You MUST mark the task `[x]` in scratchpad when complete.
### 5. REPEAT
You MUST continue until all tasks are `[x]` or `[~]`.
",
scratchpad = self.core.scratchpad
)
}
}
}
fn hats_section(&self, topology: &HatTopology, active_hats: &[&ralph_proto::Hat]) -> String {
let mut section = String::new();
if active_hats.is_empty() {
section.push_str("## HATS\n\nDelegate via events.\n\n");
if let Some(ref starting_event) = self.starting_event {
section.push_str(&format!(
"**After coordination, publish `{}` to start the workflow.**\n\n",
starting_event
));
}
let mut ralph_triggers: Vec<&str> = vec!["task.start"];
let mut ralph_publishes: Vec<&str> = Vec::new();
for hat in &topology.hats {
for pub_event in &hat.publishes {
if !ralph_triggers.contains(&pub_event.as_str()) {
ralph_triggers.push(pub_event.as_str());
}
}
for sub_event in &hat.subscribes_to {
if !ralph_publishes.contains(&sub_event.as_str()) {
ralph_publishes.push(sub_event.as_str());
}
}
}
section.push_str("| Hat | Triggers On | Publishes | Description |\n");
section.push_str("|-----|-------------|----------|-------------|\n");
section.push_str(&format!(
"| Ralph | {} | {} | Coordinates workflow, delegates to specialized hats |\n",
ralph_triggers.join(", "),
ralph_publishes.join(", ")
));
for hat in &topology.hats {
let subscribes = hat.subscribes_to.join(", ");
let publishes = hat.publishes.join(", ");
section.push_str(&format!(
"| {} | {} | {} | {} |\n",
hat.name, subscribes, publishes, hat.description
));
}
section.push('\n');
section.push_str(&self.generate_mermaid_diagram(topology, &ralph_publishes));
section.push('\n');
if !ralph_publishes.is_empty() {
section.push_str(&format!(
"**CONSTRAINT:** You MUST only publish events from this list: `{}`\n\
Publishing other events will have no effect - no hat will receive them.\n\n",
ralph_publishes.join("`, `")
));
}
self.validate_topology_reachability(topology);
} else {
section.push_str("## ACTIVE HAT\n\n");
for active_hat in active_hats {
let hat_info = topology.hats.iter().find(|h| h.name == active_hat.name);
if !active_hat.instructions.trim().is_empty() {
section.push_str(&format!("### {} Instructions\n\n", active_hat.name));
section.push_str(&active_hat.instructions);
if !active_hat.instructions.ends_with('\n') {
section.push('\n');
}
section.push('\n');
}
if let Some(guide) = hat_info.and_then(|info| info.event_publishing_guide()) {
section.push_str(&guide);
section.push('\n');
}
if let Some(info) = hat_info {
let wave_dispatch = info.wave_dispatch_section();
if !wave_dispatch.is_empty() {
section.push_str(&wave_dispatch);
}
}
if let Some(info) = hat_info
&& !info.disallowed_tools.is_empty()
{
section.push_str("### TOOL RESTRICTIONS\n\n");
section.push_str("You MUST NOT use these tools in this hat:\n");
for tool in &info.disallowed_tools {
section.push_str(&format!("- **{}** — blocked for this hat\n", tool));
}
section.push_str(
"\nUsing a restricted tool is a scope violation. \
File modifications are audited after each iteration.\n\n",
);
}
}
}
section
}
fn generate_mermaid_diagram(&self, topology: &HatTopology, ralph_publishes: &[&str]) -> String {
let node_ids: std::collections::HashMap<&str, String> = topology
.hats
.iter()
.map(|h| {
let id = h
.name
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>();
(h.name.as_str(), id)
})
.collect();
let mut diagram = String::from("```mermaid\nflowchart LR\n");
diagram.push_str(" task.start((task.start)) --> Ralph\n");
for hat in &topology.hats {
let node_id = &node_ids[hat.name.as_str()];
for trigger in &hat.subscribes_to {
if ralph_publishes.contains(&trigger.as_str()) {
if node_id == &hat.name {
diagram.push_str(&format!(" Ralph -->|{}| {}\n", trigger, hat.name));
} else {
diagram.push_str(&format!(
" Ralph -->|{}| {}[{}]\n",
trigger, node_id, hat.name
));
}
}
}
}
for hat in &topology.hats {
let node_id = &node_ids[hat.name.as_str()];
for pub_event in &hat.publishes {
diagram.push_str(&format!(" {} -->|{}| Ralph\n", node_id, pub_event));
}
}
for source_hat in &topology.hats {
let source_id = &node_ids[source_hat.name.as_str()];
for pub_event in &source_hat.publishes {
for target_hat in &topology.hats {
if target_hat.name != source_hat.name
&& target_hat.subscribes_to.contains(pub_event)
{
let target_id = &node_ids[target_hat.name.as_str()];
diagram.push_str(&format!(
" {} -->|{}| {}\n",
source_id, pub_event, target_id
));
}
}
}
}
diagram.push_str("```\n");
diagram
}
fn validate_topology_reachability(&self, topology: &HatTopology) {
use std::collections::HashSet;
use tracing::warn;
let mut reachable_events: HashSet<&str> = HashSet::new();
reachable_events.insert("task.start");
for hat in &topology.hats {
for trigger in &hat.subscribes_to {
reachable_events.insert(trigger.as_str());
}
}
for hat in &topology.hats {
for pub_event in &hat.publishes {
reachable_events.insert(pub_event.as_str());
}
}
for hat in &topology.hats {
let hat_reachable = hat
.subscribes_to
.iter()
.any(|t| reachable_events.contains(t.as_str()));
if !hat_reachable {
warn!(
hat = %hat.name,
triggers = ?hat.subscribes_to,
"Hat has triggers that are never published - it may be unreachable"
);
}
}
}
fn event_writing_section(&self) -> String {
let detailed_output_hint = format!(
"You SHOULD write detailed output to `{}` and emit only a brief event.",
self.core.scratchpad
);
format!(
r#"## EVENT WRITING
Events are routing signals, not data transport. You SHOULD keep payloads brief.
You MUST use `ralph emit` to write events (handles JSON escaping correctly):
```bash
ralph emit "build.done" "tests: pass, lint: pass, typecheck: pass, audit: pass, coverage: pass"
ralph emit "review.done" --json '{{"status": "approved", "issues": 0}}'
```
You MUST NOT use echo/cat to write events because shell escaping breaks JSON.
{detailed_output_hint}
**Constraints:**
- You MUST stop working after publishing an event because a new iteration will start with fresh context
- You MUST NOT continue with additional work after publishing because the next iteration handles it with the appropriate hat persona
"#,
detailed_output_hint = detailed_output_hint
)
}
fn done_section(&self, objective: Option<&str>) -> String {
let mut section = if self.hat_topology.is_some() {
format!(
r"## DONE
You MUST emit the completion event `{}` via `ralph emit` when the objective is complete and all tasks are done.
Stdout text does NOT end the loop in coordinated mode.
",
self.completion_promise
)
} else {
format!(
r"## DONE
You MUST output the literal completion promise `{}` as the final non-empty line when the objective is complete and all tasks are done.
You MUST NOT substitute a prose summary for `{}`.
You MUST NOT print any text after `{}`.
",
self.completion_promise, self.completion_promise, self.completion_promise
)
};
if self.memories_enabled {
section.push_str(
r"
**Before declaring completion:**
1. Run `ralph tools task list` to check for any remaining non-terminal tasks
2. If any tasks are still open or in progress, close, fail, or reopen them first
3. Only declare completion when YOUR tasks for this objective are all terminal
Tasks from other parallel loops are filtered out automatically. You only need to verify tasks YOU created for THIS objective are complete.
You MUST NOT declare completion while tasks remain open.
",
);
}
if let Some(obj) = objective {
section.push_str(&format!(
r"
**Remember your objective:**
> {}
You MUST NOT declare completion until this objective is fully satisfied.
",
obj
));
}
section
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::RalphConfig;
#[test]
fn test_prompt_without_hats() {
let config = RalphConfig::default();
let registry = HatRegistry::new(); let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(prompt.contains(
"You are Ralph. You are running in a loop. You have fresh context each iteration."
));
assert!(prompt.contains("### 0a. ORIENTATION"));
assert!(prompt.contains("MUST complete only one atomic task"));
assert!(prompt.contains("### 0b. SCRATCHPAD"));
assert!(prompt.contains("auto-injected"));
assert!(prompt.contains("**Always append**"));
assert!(prompt.contains("## WORKFLOW"));
assert!(prompt.contains("### 1. Study the prompt"));
assert!(prompt.contains("You MAY use parallel subagents (up to 10)"));
assert!(prompt.contains("### 2. PLAN"));
assert!(prompt.contains("### 3. IMPLEMENT"));
assert!(prompt.contains("You MUST NOT use more than 1 subagent for build/tests"));
assert!(prompt.contains("### 4. COMMIT"));
assert!(prompt.contains("You MUST capture the why"));
assert!(prompt.contains("### 5. REPEAT"));
assert!(!prompt.contains("## HATS"));
assert!(prompt.contains("## EVENT WRITING"));
assert!(prompt.contains("You MUST use `ralph emit`"));
assert!(prompt.contains("You MUST NOT use echo/cat"));
assert!(prompt.contains("LOOP_COMPLETE"));
}
#[test]
fn test_prompt_with_hats() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["planning.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 registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(prompt.contains(
"You are Ralph. You are running in a loop. You have fresh context each iteration."
));
assert!(prompt.contains("### 0a. ORIENTATION"));
assert!(prompt.contains("### 0b. SCRATCHPAD"));
assert!(prompt.contains("## WORKFLOW"));
assert!(prompt.contains("### 1. PLAN"));
assert!(
prompt.contains("### 2. DELEGATE"),
"Multi-hat mode should have DELEGATE step"
);
assert!(
!prompt.contains("### 3. IMPLEMENT"),
"Multi-hat mode should NOT tell Ralph to implement"
);
assert!(
prompt.contains("You MUST stop working after publishing"),
"Should explicitly tell Ralph to stop after publishing event"
);
assert!(prompt.contains("## HATS"));
assert!(prompt.contains("Delegate via events"));
assert!(prompt.contains("| Hat | Triggers On | Publishes |"));
assert!(prompt.contains("## EVENT WRITING"));
assert!(prompt.contains("LOOP_COMPLETE"));
}
#[test]
fn test_should_handle_always_true() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
assert!(ralph.should_handle(&Topic::new("any.topic")));
assert!(ralph.should_handle(&Topic::new("build.task")));
assert!(ralph.should_handle(&Topic::new("unknown.event")));
}
#[test]
fn test_rfc2119_patterns_present() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("You MUST study"),
"Should use RFC2119 MUST with 'study' verb"
);
assert!(
prompt.contains("You MUST complete only one atomic task"),
"Should have RFC2119 MUST complete atomic task constraint"
);
assert!(
prompt.contains("You MAY use parallel subagents"),
"Should mention parallel subagents with MAY"
);
assert!(
prompt.contains("You MUST NOT use more than 1 subagent"),
"Should limit to 1 subagent for builds with MUST NOT"
);
assert!(
prompt.contains("You MUST capture the why"),
"Should emphasize 'why' in commits with MUST"
);
assert!(
prompt.contains("### GUARDRAILS"),
"Should have guardrails section"
);
assert!(
prompt.contains("999."),
"Guardrails should use high numbers"
);
}
#[test]
fn test_scratchpad_format_documented() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(prompt.contains("auto-injected"));
assert!(prompt.contains("**Always append**"));
}
#[test]
fn test_starting_event_in_prompt() {
let yaml = r#"
hats:
tdd_writer:
name: "TDD Writer"
triggers: ["tdd.start"]
publishes: ["test.written"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new(
"LOOP_COMPLETE",
config.core.clone(),
®istry,
Some("tdd.start".to_string()),
);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("After coordination, publish `tdd.start` to start the workflow"),
"Prompt should include starting_event delegation instruction"
);
}
#[test]
fn test_no_starting_event_instruction_when_none() {
let yaml = r#"
hats:
some_hat:
name: "Some Hat"
triggers: ["some.event"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(
!prompt.contains("After coordination, publish"),
"Prompt should NOT include starting_event delegation when None"
);
}
#[test]
fn test_hat_instructions_propagated_to_prompt() {
let yaml = r#"
hats:
tdd_writer:
name: "TDD Writer"
triggers: ["tdd.start"]
publishes: ["test.written"]
instructions: |
You are a Test-Driven Development specialist.
Always write failing tests before implementation.
Focus on edge cases and error handling.
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new(
"LOOP_COMPLETE",
config.core.clone(),
®istry,
Some("tdd.start".to_string()),
);
let tdd_writer = registry
.get(&ralph_proto::HatId::new("tdd_writer"))
.unwrap();
let prompt = ralph.build_prompt("", &[tdd_writer]);
assert!(
prompt.contains("### TDD Writer Instructions"),
"Prompt should include hat instructions section header"
);
assert!(
prompt.contains("Test-Driven Development specialist"),
"Prompt should include actual instructions content"
);
assert!(
prompt.contains("Always write failing tests"),
"Prompt should include full instructions"
);
}
#[test]
fn test_empty_instructions_not_rendered() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(
!prompt.contains("### Builder Instructions"),
"Prompt should NOT include instructions section for hat with empty instructions"
);
}
#[test]
fn test_multiple_hats_with_instructions() {
let yaml = r#"
hats:
planner:
name: "Planner"
triggers: ["planning.start"]
publishes: ["build.task"]
instructions: "Plan carefully before implementation."
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done"]
instructions: "Focus on clean, testable code."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let planner = registry.get(&ralph_proto::HatId::new("planner")).unwrap();
let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
let prompt = ralph.build_prompt("", &[planner, builder]);
assert!(
prompt.contains("### Planner Instructions"),
"Prompt should include Planner instructions section"
);
assert!(
prompt.contains("Plan carefully before implementation"),
"Prompt should include Planner instructions content"
);
assert!(
prompt.contains("### Builder Instructions"),
"Prompt should include Builder instructions section"
);
assert!(
prompt.contains("Focus on clean, testable code"),
"Prompt should include Builder instructions content"
);
}
#[test]
fn test_fast_path_with_starting_event() {
let yaml = r#"
core:
scratchpad: "/nonexistent/path/scratchpad.md"
hats:
tdd_writer:
name: "TDD Writer"
triggers: ["tdd.start"]
publishes: ["test.written"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new(
"LOOP_COMPLETE",
config.core.clone(),
®istry,
Some("tdd.start".to_string()),
);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("FAST PATH"),
"Prompt should indicate fast path when starting_event set and no scratchpad"
);
assert!(
prompt.contains("You MUST publish `tdd.start` immediately"),
"Prompt should instruct immediate event publishing with MUST"
);
assert!(
prompt.contains("ralph emit \"tdd.start\""),
"Fast path should require explicit event emission"
);
assert!(
!prompt.contains("### 1. PLAN"),
"Fast path should skip PLAN step"
);
}
#[test]
fn test_events_context_included_in_prompt() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let events_context = r"[task.start] User's task: Review this code for security vulnerabilities
[build.done] Build completed successfully";
let prompt = ralph.build_prompt(events_context, &[]);
assert!(
prompt.contains("## PENDING EVENTS"),
"Prompt should contain PENDING EVENTS section"
);
assert!(
prompt.contains("Review this code for security vulnerabilities"),
"Prompt should contain the user's task"
);
assert!(
prompt.contains("Build completed successfully"),
"Prompt should contain all events from context"
);
}
#[test]
fn test_empty_context_no_pending_events_section() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(
!prompt.contains("## PENDING EVENTS"),
"Empty context should not produce PENDING EVENTS section"
);
}
#[test]
fn test_whitespace_only_context_no_pending_events_section() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt(" \n\t ", &[]);
assert!(
!prompt.contains("## PENDING EVENTS"),
"Whitespace-only context should not produce PENDING EVENTS section"
);
}
#[test]
fn test_events_section_before_workflow() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let events_context = "[task.start] Implement feature X";
let prompt = ralph.build_prompt(events_context, &[]);
let events_pos = prompt
.find("## PENDING EVENTS")
.expect("Should have PENDING EVENTS");
let workflow_pos = prompt.find("## WORKFLOW").expect("Should have WORKFLOW");
assert!(
events_pos < workflow_pos,
"PENDING EVENTS ({}) should come before WORKFLOW ({})",
events_pos,
workflow_pos
);
}
#[test]
fn test_only_active_hat_instructions_included() {
let yaml = r#"
hats:
security_reviewer:
name: "Security Reviewer"
triggers: ["review.security"]
instructions: "Review code for security vulnerabilities."
architecture_reviewer:
name: "Architecture Reviewer"
triggers: ["review.architecture"]
instructions: "Review system design and architecture."
correctness_reviewer:
name: "Correctness Reviewer"
triggers: ["review.correctness"]
instructions: "Review logic and correctness."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let security_hat = registry
.get(&ralph_proto::HatId::new("security_reviewer"))
.unwrap();
let active_hats = vec![security_hat];
let prompt = ralph.build_prompt("Event: review.security - Check auth", &active_hats);
assert!(
prompt.contains("### Security Reviewer Instructions"),
"Should include Security Reviewer instructions section"
);
assert!(
prompt.contains("Review code for security vulnerabilities"),
"Should include Security Reviewer instructions content"
);
assert!(
!prompt.contains("### Architecture Reviewer Instructions"),
"Should NOT include Architecture Reviewer instructions"
);
assert!(
!prompt.contains("Review system design and architecture"),
"Should NOT include Architecture Reviewer instructions content"
);
assert!(
!prompt.contains("### Correctness Reviewer Instructions"),
"Should NOT include Correctness Reviewer instructions"
);
}
#[test]
fn test_multiple_active_hats_all_included() {
let yaml = r#"
hats:
security_reviewer:
name: "Security Reviewer"
triggers: ["review.security"]
instructions: "Review code for security vulnerabilities."
architecture_reviewer:
name: "Architecture Reviewer"
triggers: ["review.architecture"]
instructions: "Review system design and architecture."
correctness_reviewer:
name: "Correctness Reviewer"
triggers: ["review.correctness"]
instructions: "Review logic and correctness."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let security_hat = registry
.get(&ralph_proto::HatId::new("security_reviewer"))
.unwrap();
let arch_hat = registry
.get(&ralph_proto::HatId::new("architecture_reviewer"))
.unwrap();
let active_hats = vec![security_hat, arch_hat];
let prompt = ralph.build_prompt("Events", &active_hats);
assert!(
prompt.contains("### Security Reviewer Instructions"),
"Should include Security Reviewer instructions"
);
assert!(
prompt.contains("Review code for security vulnerabilities"),
"Should include Security Reviewer content"
);
assert!(
prompt.contains("### Architecture Reviewer Instructions"),
"Should include Architecture Reviewer instructions"
);
assert!(
prompt.contains("Review system design and architecture"),
"Should include Architecture Reviewer content"
);
assert!(
!prompt.contains("### Correctness Reviewer Instructions"),
"Should NOT include Correctness Reviewer instructions"
);
}
#[test]
fn test_no_active_hats_no_instructions() {
let yaml = r#"
hats:
security_reviewer:
name: "Security Reviewer"
triggers: ["review.security"]
instructions: "Review code for security vulnerabilities."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let active_hats: Vec<&ralph_proto::Hat> = vec![];
let prompt = ralph.build_prompt("Events", &active_hats);
assert!(
!prompt.contains("### Security Reviewer Instructions"),
"Should NOT include instructions when no active hats"
);
assert!(
!prompt.contains("Review code for security vulnerabilities"),
"Should NOT include instructions content when no active hats"
);
assert!(prompt.contains("## HATS"), "Should still have HATS section");
assert!(
prompt.contains("| Hat | Triggers On | Publishes |"),
"Should still have topology table"
);
}
#[test]
fn test_topology_table_only_when_ralph_coordinating() {
let yaml = r#"
hats:
security_reviewer:
name: "Security Reviewer"
triggers: ["review.security"]
instructions: "Security instructions."
architecture_reviewer:
name: "Architecture Reviewer"
triggers: ["review.architecture"]
instructions: "Architecture instructions."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt_coordinating = ralph.build_prompt("Events", &[]);
assert!(
prompt_coordinating.contains("## HATS"),
"Should have HATS section when coordinating"
);
assert!(
prompt_coordinating.contains("| Hat | Triggers On | Publishes |"),
"Should have topology table when coordinating"
);
assert!(
prompt_coordinating.contains("```mermaid"),
"Should have Mermaid diagram when coordinating"
);
let security_hat = registry
.get(&ralph_proto::HatId::new("security_reviewer"))
.unwrap();
let prompt_active = ralph.build_prompt("Events", &[security_hat]);
assert!(
prompt_active.contains("## ACTIVE HAT"),
"Should have ACTIVE HAT section when hat is active"
);
assert!(
!prompt_active.contains("| Hat | Triggers On | Publishes |"),
"Should NOT have topology table when hat is active"
);
assert!(
!prompt_active.contains("```mermaid"),
"Should NOT have Mermaid diagram when hat is active"
);
assert!(
prompt_active.contains("### Security Reviewer Instructions"),
"Should still have the active hat's instructions"
);
}
#[test]
fn test_scratchpad_always_included() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("### 0b. SCRATCHPAD"),
"Scratchpad section should be included"
);
assert!(
prompt.contains("`.ralph/agent/scratchpad.md`"),
"Scratchpad path should be referenced"
);
assert!(
prompt.contains("auto-injected"),
"Auto-injection should be documented"
);
}
#[test]
fn test_scratchpad_included_with_memories_enabled() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None)
.with_memories_enabled(true);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("### 0b. SCRATCHPAD"),
"Scratchpad section should be included even with memories enabled"
);
assert!(
prompt.contains("**Always append**"),
"Append instruction should be documented"
);
assert!(
!prompt.contains("### 0c. TASKS"),
"Tasks section should NOT be in core_prompt — injected via skills pipeline"
);
}
#[test]
fn test_no_tasks_section_in_core_prompt() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(
!prompt.contains("### 0c. TASKS"),
"Tasks section should NOT be in core_prompt — injected via skills pipeline"
);
}
#[test]
fn test_workflow_references_both_scratchpad_and_tasks_with_memories() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None)
.with_memories_enabled(true);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("update scratchpad"),
"Workflow should reference scratchpad when memories enabled"
);
assert!(
prompt.contains("ralph tools task"),
"Workflow should reference tasks CLI when memories enabled"
);
}
#[test]
fn test_multi_hat_mode_workflow_with_memories_enabled() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None)
.with_memories_enabled(true);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("scratchpad"),
"Multi-hat workflow should reference scratchpad when memories enabled"
);
assert!(
prompt.contains("ralph tools task ensure"),
"Multi-hat workflow should reference tasks CLI when memories enabled"
);
}
#[test]
fn test_guardrails_adapt_to_memories_mode() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None)
.with_memories_enabled(true);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("### GUARDRAILS"),
"Guardrails section should be present"
);
}
#[test]
fn test_guardrails_present_without_memories() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("### GUARDRAILS"),
"Guardrails section should be present"
);
}
#[test]
fn test_task_closure_verification_in_done_section() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None)
.with_memories_enabled(true);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("ralph tools task list"),
"Should reference task list command in DONE section"
);
assert!(
prompt.contains("MUST NOT declare completion while tasks remain open"),
"Should require tasks closed before completion"
);
}
#[test]
fn test_workflow_verify_and_commit_step() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None)
.with_memories_enabled(true);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("### 4. VERIFY & COMMIT"),
"Should have VERIFY & COMMIT step in workflow"
);
assert!(
prompt.contains("run tests and verify"),
"Should require verification"
);
assert!(
prompt.contains("ralph tools task start"),
"Should reference task start command"
);
assert!(
prompt.contains("ralph tools task close"),
"Should reference task close command"
);
}
#[test]
fn test_scratchpad_mode_still_has_commit_step() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("### 4. COMMIT"),
"Should have COMMIT step in workflow"
);
assert!(
prompt.contains("mark the task `[x]`"),
"Should mark task in scratchpad"
);
assert!(
!prompt.contains("### 0c. TASKS"),
"Scratchpad mode should not have TASKS section"
);
}
#[test]
fn test_objective_section_present_with_set_objective() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Implement user authentication with JWT tokens".to_string());
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("## OBJECTIVE"),
"Should have OBJECTIVE section when objective is set"
);
assert!(
prompt.contains("Implement user authentication with JWT tokens"),
"OBJECTIVE should contain the original user prompt"
);
assert!(
prompt.contains("This is your primary goal"),
"OBJECTIVE should emphasize this is the primary goal"
);
}
#[test]
fn test_objective_reinforced_in_done_section() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Fix the login bug in auth module".to_string());
let prompt = ralph.build_prompt("", &[]);
let done_pos = prompt.find("## DONE").expect("Should have DONE section");
let after_done = &prompt[done_pos..];
assert!(
after_done.contains("Remember your objective"),
"DONE section should remind about objective"
);
assert!(
after_done.contains("Fix the login bug in auth module"),
"DONE section should restate the objective"
);
}
#[test]
fn test_objective_appears_before_pending_events() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Build feature X".to_string());
let context = "Event: task.start - Build feature X";
let prompt = ralph.build_prompt(context, &[]);
let objective_pos = prompt.find("## OBJECTIVE").expect("Should have OBJECTIVE");
let events_pos = prompt
.find("## PENDING EVENTS")
.expect("Should have PENDING EVENTS");
assert!(
objective_pos < events_pos,
"OBJECTIVE ({}) should appear before PENDING EVENTS ({})",
objective_pos,
events_pos
);
}
#[test]
fn test_no_objective_when_not_set() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let context = "Event: build.done - Build completed successfully";
let prompt = ralph.build_prompt(context, &[]);
assert!(
!prompt.contains("## OBJECTIVE"),
"Should NOT have OBJECTIVE section when objective not set"
);
}
#[test]
fn test_objective_set_correctly() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Review this PR for security issues".to_string());
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("Review this PR for security issues"),
"Should show the stored objective"
);
}
#[test]
fn test_objective_with_events_context() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Implement feature Y".to_string());
let context =
"Event: build.done - Previous build succeeded\nEvent: test.passed - All tests green";
let prompt = ralph.build_prompt(context, &[]);
assert!(
prompt.contains("## OBJECTIVE"),
"Should have OBJECTIVE section"
);
assert!(
prompt.contains("Implement feature Y"),
"OBJECTIVE should contain the stored objective"
);
}
#[test]
fn test_done_section_without_objective() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("", &[]);
assert!(prompt.contains("## DONE"), "Should have DONE section");
assert!(
prompt.contains("LOOP_COMPLETE"),
"DONE should mention completion event"
);
assert!(
prompt.contains("final non-empty line"),
"Solo DONE section should require literal terminal output"
);
assert!(
!prompt.contains("Remember your objective"),
"Should NOT have objective reinforcement without objective"
);
}
#[test]
fn test_objective_persists_across_iterations() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Build a REST API with authentication".to_string());
let context = "Event: build.done - Build completed";
let prompt = ralph.build_prompt(context, &[]);
assert!(
prompt.contains("## OBJECTIVE"),
"OBJECTIVE should persist even without task.start in context"
);
assert!(
prompt.contains("Build a REST API with authentication"),
"Stored objective should appear in later iterations"
);
}
#[test]
fn test_done_section_suppressed_when_hat_active() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done"]
instructions: "Build the code."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Implement feature X".to_string());
let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
let prompt = ralph.build_prompt("Event: build.task - Do the build", &[builder]);
assert!(
!prompt.contains("## DONE"),
"DONE section should be suppressed when a hat is active"
);
assert!(
!prompt.contains("LOOP_COMPLETE"),
"Completion promise should NOT appear when a hat is active"
);
assert!(
prompt.contains("## OBJECTIVE"),
"OBJECTIVE should still appear even when hat is active"
);
assert!(
prompt.contains("Implement feature X"),
"Objective content should be visible to active hat"
);
}
#[test]
fn test_done_section_present_when_coordinating() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Complete the TDD cycle".to_string());
let prompt = ralph.build_prompt("Event: build.done - Build finished", &[]);
assert!(
prompt.contains("## DONE"),
"DONE section should appear when Ralph is coordinating"
);
assert!(
prompt.contains("LOOP_COMPLETE"),
"Completion promise should appear when coordinating"
);
assert!(
prompt.contains("via `ralph emit`"),
"Coordinating DONE section should require explicit event emission"
);
}
#[test]
fn test_objective_in_done_section_when_coordinating() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Deploy the application".to_string());
let prompt = ralph.build_prompt("", &[]);
let done_pos = prompt.find("## DONE").expect("Should have DONE section");
let after_done = &prompt[done_pos..];
assert!(
after_done.contains("Remember your objective"),
"DONE section should remind about objective when coordinating"
);
assert!(
after_done.contains("Deploy the application"),
"DONE section should contain the objective text"
);
}
#[test]
fn test_event_publishing_guide_with_receivers() {
let yaml = r#"
hats:
builder:
name: "Builder"
description: "Builds and tests code"
triggers: ["build.task"]
publishes: ["build.done", "build.blocked"]
confessor:
name: "Confessor"
description: "Produces a ConfessionReport; rewarded for honesty"
triggers: ["build.done"]
publishes: ["confession.done"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
let prompt = ralph.build_prompt("[build.task] Build the feature", &[builder]);
assert!(
prompt.contains("### Event Publishing Guide"),
"Should include Event Publishing Guide section"
);
assert!(
prompt.contains("When you publish:"),
"Guide should explain what happens when publishing"
);
assert!(
prompt.contains("You MUST use `ralph emit"),
"Guide should require explicit event emission"
);
assert!(
prompt.contains("`build.done` → Received by: Confessor"),
"Should show Confessor receives build.done"
);
assert!(
prompt.contains("Produces a ConfessionReport; rewarded for honesty"),
"Should include receiver's description"
);
assert!(
prompt.contains("`build.blocked` → Received by: Ralph (coordinates next steps)"),
"Should show Ralph receives orphan events"
);
}
#[test]
fn test_event_publishing_guide_no_publishes() {
let yaml = r#"
hats:
observer:
name: "Observer"
description: "Only observes"
triggers: ["events.*"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let observer = registry.get(&ralph_proto::HatId::new("observer")).unwrap();
let prompt = ralph.build_prompt("[events.start] Start", &[observer]);
assert!(
!prompt.contains("### Event Publishing Guide"),
"Should NOT include Event Publishing Guide when hat has no publishes"
);
}
#[test]
fn test_event_publishing_guide_all_orphan_events() {
let yaml = r#"
hats:
solo:
name: "Solo"
triggers: ["solo.start"]
publishes: ["solo.done", "solo.failed"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let solo = registry.get(&ralph_proto::HatId::new("solo")).unwrap();
let prompt = ralph.build_prompt("[solo.start] Go", &[solo]);
assert!(
prompt.contains("### Event Publishing Guide"),
"Should include guide even for orphan events"
);
assert!(
prompt.contains("`solo.done` → Received by: Ralph (coordinates next steps)"),
"Orphan solo.done should go to Ralph"
);
assert!(
prompt.contains("`solo.failed` → Received by: Ralph (coordinates next steps)"),
"Orphan solo.failed should go to Ralph"
);
}
#[test]
fn test_event_publishing_guide_multiple_receivers() {
let yaml = r#"
hats:
broadcaster:
name: "Broadcaster"
triggers: ["broadcast.start"]
publishes: ["signal.sent"]
listener1:
name: "Listener1"
description: "First listener"
triggers: ["signal.sent"]
listener2:
name: "Listener2"
description: "Second listener"
triggers: ["signal.sent"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let broadcaster = registry
.get(&ralph_proto::HatId::new("broadcaster"))
.unwrap();
let prompt = ralph.build_prompt("[broadcast.start] Go", &[broadcaster]);
assert!(
prompt.contains("### Event Publishing Guide"),
"Should include guide"
);
assert!(
prompt.contains("Listener1 (First listener)"),
"Should list Listener1 as receiver"
);
assert!(
prompt.contains("Listener2 (Second listener)"),
"Should list Listener2 as receiver"
);
}
#[test]
fn test_event_publishing_guide_includes_self() {
let yaml = r#"
hats:
looper:
name: "Looper"
triggers: ["loop.continue", "loop.start"]
publishes: ["loop.continue"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let looper = registry.get(&ralph_proto::HatId::new("looper")).unwrap();
let prompt = ralph.build_prompt("[loop.start] Start", &[looper]);
assert!(
prompt.contains("### Event Publishing Guide"),
"Should include guide"
);
assert!(
prompt.contains("`loop.continue` → Received by: Looper"),
"Self-loop event should show the hat itself as receiver"
);
}
#[test]
fn test_event_publishing_guide_self_loop_shows_self_as_receiver() {
let yaml = r#"
hats:
processor:
name: "Processor"
description: "Processes work with retry"
triggers: ["start", "process.retry"]
publishes: ["process.done", "process.retry"]
validator:
name: "Validator"
triggers: ["process.done"]
publishes: ["validate.pass"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let processor = registry.get(&ralph_proto::HatId::new("processor")).unwrap();
let prompt = ralph.build_prompt("[start] Go", &[processor]);
assert!(
prompt.contains("`process.retry` → Received by: Processor"),
"Self-loop event should show the hat itself as receiver, not Ralph. Got:\n{}",
prompt
.lines()
.filter(|l| l.contains("process.retry"))
.collect::<Vec<_>>()
.join("\n")
);
assert!(
prompt.contains("`process.done` → Received by: Validator"),
"Non-self event should still show correct receiver"
);
}
#[test]
fn test_event_publishing_guide_receiver_without_description() {
let yaml = r#"
hats:
sender:
name: "Sender"
triggers: ["send.start"]
publishes: ["message.sent"]
receiver:
name: "NoDescReceiver"
triggers: ["message.sent"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let sender = registry.get(&ralph_proto::HatId::new("sender")).unwrap();
let prompt = ralph.build_prompt("[send.start] Go", &[sender]);
assert!(
prompt.contains("`message.sent` → Received by: NoDescReceiver"),
"Should show receiver name without parentheses when no description"
);
assert!(
!prompt.contains("NoDescReceiver ()"),
"Should NOT have empty parentheses for receiver without description"
);
}
#[test]
fn test_constraint_lists_valid_events_when_coordinating() {
let yaml = r#"
hats:
test_writer:
name: "Test Writer"
triggers: ["tdd.start"]
publishes: ["test.written"]
implementer:
name: "Implementer"
triggers: ["test.written"]
publishes: ["test.passing"]
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("[task.start] Do TDD for feature X", &[]);
assert!(
prompt.contains("**CONSTRAINT:**"),
"Prompt should include CONSTRAINT when coordinating"
);
assert!(
prompt.contains("tdd.start"),
"CONSTRAINT should list tdd.start as valid event"
);
assert!(
prompt.contains("test.written"),
"CONSTRAINT should list test.written as valid event"
);
assert!(
prompt.contains("Publishing other events will have no effect"),
"CONSTRAINT should warn about invalid events"
);
}
#[test]
fn test_no_constraint_when_hat_is_active() {
let yaml = r#"
hats:
builder:
name: "Builder"
triggers: ["build.task"]
publishes: ["build.done"]
instructions: "Build the code."
"#;
let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
let registry = HatRegistry::from_config(&config);
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let builder = registry.get(&ralph_proto::HatId::new("builder")).unwrap();
let prompt = ralph.build_prompt("[build.task] Build feature X", &[builder]);
assert!(
!prompt.contains("**CONSTRAINT:** You MUST only publish events from this list"),
"Active hat should NOT have coordinating CONSTRAINT"
);
assert!(
prompt.contains("### Event Publishing Guide"),
"Active hat should have Event Publishing Guide"
);
}
#[test]
fn test_no_constraint_when_no_hats() {
let config = RalphConfig::default();
let registry = HatRegistry::new(); let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("[task.start] Do something", &[]);
assert!(
!prompt.contains("**CONSTRAINT:**"),
"Solo mode should NOT have CONSTRAINT"
);
}
#[test]
fn test_single_guidance_injection() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_robot_guidance(vec!["Focus on error handling first".to_string()]);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("## ROBOT GUIDANCE"),
"Should include ROBOT GUIDANCE section"
);
assert!(
prompt.contains("Focus on error handling first"),
"Should contain the guidance message"
);
assert!(
!prompt.contains("1. Focus on error handling first"),
"Single guidance should not be numbered"
);
}
#[test]
fn test_multiple_guidance_squashing() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_robot_guidance(vec![
"Focus on error handling".to_string(),
"Use the existing retry pattern".to_string(),
"Check edge cases for empty input".to_string(),
]);
let prompt = ralph.build_prompt("", &[]);
assert!(
prompt.contains("## ROBOT GUIDANCE"),
"Should include ROBOT GUIDANCE section"
);
assert!(
prompt.contains("1. Focus on error handling"),
"First guidance should be numbered 1"
);
assert!(
prompt.contains("2. Use the existing retry pattern"),
"Second guidance should be numbered 2"
);
assert!(
prompt.contains("3. Check edge cases for empty input"),
"Third guidance should be numbered 3"
);
}
#[test]
fn test_guidance_appears_in_prompt_before_events() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_objective("Build feature X".to_string());
ralph.set_robot_guidance(vec!["Use the new API".to_string()]);
let prompt = ralph.build_prompt("Event: build.task - Do the work", &[]);
let objective_pos = prompt.find("## OBJECTIVE").expect("Should have OBJECTIVE");
let guidance_pos = prompt
.find("## ROBOT GUIDANCE")
.expect("Should have ROBOT GUIDANCE");
let events_pos = prompt
.find("## PENDING EVENTS")
.expect("Should have PENDING EVENTS");
assert!(
objective_pos < guidance_pos,
"OBJECTIVE ({}) should come before ROBOT GUIDANCE ({})",
objective_pos,
guidance_pos
);
assert!(
guidance_pos < events_pos,
"ROBOT GUIDANCE ({}) should come before PENDING EVENTS ({})",
guidance_pos,
events_pos
);
}
#[test]
fn test_guidance_cleared_after_injection() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let mut ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
ralph.set_robot_guidance(vec!["First guidance".to_string()]);
let prompt1 = ralph.build_prompt("", &[]);
assert!(
prompt1.contains("## ROBOT GUIDANCE"),
"First prompt should have guidance"
);
ralph.clear_robot_guidance();
let prompt2 = ralph.build_prompt("", &[]);
assert!(
!prompt2.contains("## ROBOT GUIDANCE"),
"After clearing, prompt should not have guidance"
);
}
#[test]
fn test_no_injection_when_no_guidance() {
let config = RalphConfig::default();
let registry = HatRegistry::new();
let ralph = HatlessRalph::new("LOOP_COMPLETE", config.core.clone(), ®istry, None);
let prompt = ralph.build_prompt("Event: build.task - Do the work", &[]);
assert!(
!prompt.contains("## ROBOT GUIDANCE"),
"Should NOT include ROBOT GUIDANCE when no guidance set"
);
}
}