932 lines
32 KiB
Rust
932 lines
32 KiB
Rust
use anyhow::Result;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::fmt::Write as _;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// Maximum retry attempts per step before marking the goal as blocked.
|
|
const MAX_STEP_ATTEMPTS: u32 = 3;
|
|
|
|
// ── Data Structures ─────────────────────────────────────────────
|
|
|
|
/// Root state persisted to `{workspace}/state/goals.json`.
|
|
/// Format matches the `goal-tracker` skill's file layout.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
pub struct GoalState {
|
|
#[serde(default)]
|
|
pub goals: Vec<Goal>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Goal {
|
|
pub id: String,
|
|
pub description: String,
|
|
#[serde(default)]
|
|
pub status: GoalStatus,
|
|
#[serde(default)]
|
|
pub priority: GoalPriority,
|
|
#[serde(default)]
|
|
pub created_at: String,
|
|
#[serde(default)]
|
|
pub updated_at: String,
|
|
#[serde(default)]
|
|
pub steps: Vec<Step>,
|
|
/// Accumulated context from previous step results.
|
|
#[serde(default)]
|
|
pub context: String,
|
|
/// Last error encountered during step execution.
|
|
#[serde(default)]
|
|
pub last_error: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum GoalStatus {
|
|
#[default]
|
|
Pending,
|
|
InProgress,
|
|
Completed,
|
|
Blocked,
|
|
Cancelled,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for GoalStatus {
|
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
let s = String::deserialize(d)?;
|
|
Ok(match s.as_str() {
|
|
"in_progress" => Self::InProgress,
|
|
"completed" => Self::Completed,
|
|
"blocked" => Self::Blocked,
|
|
"cancelled" => Self::Cancelled,
|
|
_ => Self::Pending,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum GoalPriority {
|
|
Low = 0,
|
|
#[default]
|
|
Medium = 1,
|
|
High = 2,
|
|
Critical = 3,
|
|
}
|
|
|
|
impl PartialOrd for GoalPriority {
|
|
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
|
Some(self.cmp(other))
|
|
}
|
|
}
|
|
|
|
impl Ord for GoalPriority {
|
|
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
|
(*self as u8).cmp(&(*other as u8))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Step {
|
|
pub id: String,
|
|
pub description: String,
|
|
#[serde(default)]
|
|
pub status: StepStatus,
|
|
#[serde(default)]
|
|
pub result: Option<String>,
|
|
#[serde(default)]
|
|
pub attempts: u32,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum StepStatus {
|
|
#[default]
|
|
Pending,
|
|
InProgress,
|
|
Completed,
|
|
Failed,
|
|
Blocked,
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for StepStatus {
|
|
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
|
let s = String::deserialize(d)?;
|
|
Ok(match s.as_str() {
|
|
"in_progress" => Self::InProgress,
|
|
"completed" => Self::Completed,
|
|
"failed" => Self::Failed,
|
|
"blocked" => Self::Blocked,
|
|
_ => Self::Pending,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── GoalEngine ──────────────────────────────────────────────────
|
|
|
|
pub struct GoalEngine {
|
|
state_path: PathBuf,
|
|
}
|
|
|
|
impl GoalEngine {
|
|
pub fn new(workspace_dir: &Path) -> Self {
|
|
Self {
|
|
state_path: workspace_dir.join("state").join("goals.json"),
|
|
}
|
|
}
|
|
|
|
/// Load goal state from disk. Returns empty state if file doesn't exist.
|
|
pub async fn load_state(&self) -> Result<GoalState> {
|
|
if !self.state_path.exists() {
|
|
return Ok(GoalState::default());
|
|
}
|
|
let bytes = tokio::fs::read(&self.state_path).await?;
|
|
if bytes.is_empty() {
|
|
return Ok(GoalState::default());
|
|
}
|
|
let state: GoalState = serde_json::from_slice(&bytes)?;
|
|
Ok(state)
|
|
}
|
|
|
|
/// Atomic save: write to .tmp then rename.
|
|
pub async fn save_state(&self, state: &GoalState) -> Result<()> {
|
|
if let Some(parent) = self.state_path.parent() {
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
}
|
|
let tmp = self.state_path.with_extension("json.tmp");
|
|
let data = serde_json::to_vec_pretty(state)?;
|
|
tokio::fs::write(&tmp, data).await?;
|
|
tokio::fs::rename(&tmp, &self.state_path).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Select the next actionable (goal_index, step_index) pair.
|
|
///
|
|
/// Strategy: highest-priority in-progress goal, first pending step
|
|
/// that hasn't exceeded `MAX_STEP_ATTEMPTS`.
|
|
pub fn select_next_actionable(state: &GoalState) -> Option<(usize, usize)> {
|
|
let mut best: Option<(usize, usize, GoalPriority)> = None;
|
|
|
|
for (gi, goal) in state.goals.iter().enumerate() {
|
|
if goal.status != GoalStatus::InProgress {
|
|
continue;
|
|
}
|
|
if let Some(si) = goal
|
|
.steps
|
|
.iter()
|
|
.position(|s| s.status == StepStatus::Pending && s.attempts < MAX_STEP_ATTEMPTS)
|
|
{
|
|
match best {
|
|
Some((_, _, ref bp)) if goal.priority <= *bp => {}
|
|
_ => best = Some((gi, si, goal.priority)),
|
|
}
|
|
}
|
|
}
|
|
|
|
best.map(|(gi, si, _)| (gi, si))
|
|
}
|
|
|
|
/// Build a focused prompt for the agent to execute one step.
|
|
pub fn build_step_prompt(goal: &Goal, step: &Step) -> String {
|
|
let mut prompt = String::new();
|
|
|
|
let _ = writeln!(
|
|
prompt,
|
|
"[Goal Loop] Executing step for goal: {}\n",
|
|
goal.description
|
|
);
|
|
|
|
// Completed steps summary
|
|
let completed: Vec<&Step> = goal
|
|
.steps
|
|
.iter()
|
|
.filter(|s| s.status == StepStatus::Completed)
|
|
.collect();
|
|
if !completed.is_empty() {
|
|
prompt.push_str("Completed steps:\n");
|
|
for s in &completed {
|
|
let _ = writeln!(
|
|
prompt,
|
|
"- [done] {}: {}",
|
|
s.description,
|
|
s.result.as_deref().unwrap_or("(no result)")
|
|
);
|
|
}
|
|
prompt.push('\n');
|
|
}
|
|
|
|
// Accumulated context
|
|
if !goal.context.is_empty() {
|
|
let _ = write!(prompt, "Context so far:\n{}\n\n", goal.context);
|
|
}
|
|
|
|
// Current step
|
|
let _ = write!(
|
|
prompt,
|
|
"Current step: {}\n\
|
|
Please execute this step. Provide a clear summary of what you did and the outcome.\n",
|
|
step.description
|
|
);
|
|
|
|
// Retry warning
|
|
if step.attempts > 0 {
|
|
let _ = write!(
|
|
prompt,
|
|
"\nWARNING: This step has failed {} time(s) before. \
|
|
Last error: {}\n\
|
|
Try a different approach.\n",
|
|
step.attempts,
|
|
goal.last_error.as_deref().unwrap_or("unknown")
|
|
);
|
|
}
|
|
|
|
prompt
|
|
}
|
|
|
|
/// Simple heuristic: output containing error indicators → failure.
|
|
pub fn interpret_result(output: &str) -> bool {
|
|
let lower = output.to_ascii_lowercase();
|
|
let failure_indicators = [
|
|
"failed to",
|
|
"error:",
|
|
"unable to",
|
|
"cannot ",
|
|
"could not",
|
|
"fatal:",
|
|
"panic:",
|
|
];
|
|
!failure_indicators.iter().any(|ind| lower.contains(ind))
|
|
}
|
|
|
|
pub fn max_step_attempts() -> u32 {
|
|
MAX_STEP_ATTEMPTS
|
|
}
|
|
|
|
/// Find in-progress goals that have no actionable steps remaining.
|
|
///
|
|
/// A goal is "stalled" when it is `InProgress` but every step is either
|
|
/// completed, blocked, or has exhausted its retry attempts. These goals
|
|
/// need a reflection session to decide: add new steps, mark completed,
|
|
/// mark blocked, or escalate to the user.
|
|
pub fn find_stalled_goals(state: &GoalState) -> Vec<usize> {
|
|
state
|
|
.goals
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, g)| g.status == GoalStatus::InProgress)
|
|
.filter(|(_, g)| {
|
|
!g.steps.is_empty()
|
|
&& !g
|
|
.steps
|
|
.iter()
|
|
.any(|s| s.status == StepStatus::Pending && s.attempts < MAX_STEP_ATTEMPTS)
|
|
})
|
|
.map(|(i, _)| i)
|
|
.collect()
|
|
}
|
|
|
|
/// Build a reflection prompt for a stalled goal.
|
|
///
|
|
/// The agent is asked to review the goal's overall progress and decide
|
|
/// what to do next: add new steps, mark the goal completed, or escalate.
|
|
pub fn build_reflection_prompt(goal: &Goal) -> String {
|
|
let mut prompt = String::new();
|
|
|
|
let _ = writeln!(prompt, "[Goal Reflection] Goal: {}\n", goal.description);
|
|
|
|
prompt.push_str("All steps have been attempted. Here is the current state:\n\n");
|
|
|
|
for s in &goal.steps {
|
|
let status_tag = match s.status {
|
|
StepStatus::Completed => "done",
|
|
StepStatus::Failed | StepStatus::Blocked => "blocked",
|
|
_ if s.attempts >= MAX_STEP_ATTEMPTS => "exhausted",
|
|
_ => "pending",
|
|
};
|
|
let result = s.result.as_deref().unwrap_or("(no result)");
|
|
let _ = writeln!(prompt, "- [{status_tag}] {}: {result}", s.description);
|
|
}
|
|
|
|
if !goal.context.is_empty() {
|
|
let _ = write!(prompt, "\nAccumulated context:\n{}\n", goal.context);
|
|
}
|
|
|
|
if let Some(ref err) = goal.last_error {
|
|
let _ = write!(prompt, "\nLast error: {err}\n");
|
|
}
|
|
|
|
prompt.push_str(
|
|
"\nReflect on this goal and take ONE of the following actions:\n\
|
|
1. If the goal is effectively achieved, update state/goals.json to mark it `completed`.\n\
|
|
2. If some steps failed but you can try a different approach, add NEW steps to \
|
|
state/goals.json with fresh descriptions (don't reuse failed step IDs).\n\
|
|
3. If the goal is truly blocked and needs human input, mark it `blocked` in \
|
|
state/goals.json and explain what you need from the user.\n\
|
|
4. Use memory_store to record what you learned from the failures.\n\n\
|
|
Be decisive. Do not leave the goal in its current state.",
|
|
);
|
|
|
|
prompt
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::TempDir;
|
|
|
|
fn sample_goal_state() -> GoalState {
|
|
GoalState {
|
|
goals: vec![
|
|
Goal {
|
|
id: "g1".into(),
|
|
description: "Build automation platform".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: "2026-01-01T00:00:00Z".into(),
|
|
updated_at: "2026-01-01T00:00:00Z".into(),
|
|
steps: vec![
|
|
Step {
|
|
id: "s1".into(),
|
|
description: "Research tools".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("Found 3 tools".into()),
|
|
attempts: 1,
|
|
},
|
|
Step {
|
|
id: "s2".into(),
|
|
description: "Setup environment".into(),
|
|
status: StepStatus::Pending,
|
|
result: None,
|
|
attempts: 0,
|
|
},
|
|
Step {
|
|
id: "s3".into(),
|
|
description: "Write code".into(),
|
|
status: StepStatus::Pending,
|
|
result: None,
|
|
attempts: 0,
|
|
},
|
|
],
|
|
context: "Using Python + Selenium".into(),
|
|
last_error: None,
|
|
},
|
|
Goal {
|
|
id: "g2".into(),
|
|
description: "Learn Rust".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::Medium,
|
|
created_at: "2026-01-02T00:00:00Z".into(),
|
|
updated_at: "2026-01-02T00:00:00Z".into(),
|
|
steps: vec![Step {
|
|
id: "s1".into(),
|
|
description: "Read the book".into(),
|
|
status: StepStatus::Pending,
|
|
result: None,
|
|
attempts: 0,
|
|
}],
|
|
context: String::new(),
|
|
last_error: None,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn goal_loop_config_serde_roundtrip() {
|
|
let toml_str = r#"
|
|
enabled = true
|
|
interval_minutes = 15
|
|
step_timeout_secs = 180
|
|
max_steps_per_cycle = 5
|
|
channel = "lark"
|
|
target = "oc_test"
|
|
"#;
|
|
let config: crate::config::schema::GoalLoopConfig = toml::from_str(toml_str).unwrap();
|
|
assert!(config.enabled);
|
|
assert_eq!(config.interval_minutes, 15);
|
|
assert_eq!(config.step_timeout_secs, 180);
|
|
assert_eq!(config.max_steps_per_cycle, 5);
|
|
assert_eq!(config.channel.as_deref(), Some("lark"));
|
|
assert_eq!(config.target.as_deref(), Some("oc_test"));
|
|
}
|
|
|
|
#[test]
|
|
fn goal_loop_config_defaults() {
|
|
let config = crate::config::schema::GoalLoopConfig::default();
|
|
assert!(!config.enabled);
|
|
assert_eq!(config.interval_minutes, 10);
|
|
assert_eq!(config.step_timeout_secs, 120);
|
|
assert_eq!(config.max_steps_per_cycle, 3);
|
|
assert!(config.channel.is_none());
|
|
assert!(config.target.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn goal_state_serde_roundtrip() {
|
|
let state = sample_goal_state();
|
|
let json = serde_json::to_string_pretty(&state).unwrap();
|
|
let parsed: GoalState = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(parsed.goals.len(), 2);
|
|
assert_eq!(parsed.goals[0].steps.len(), 3);
|
|
assert_eq!(parsed.goals[0].steps[0].status, StepStatus::Completed);
|
|
}
|
|
|
|
#[test]
|
|
fn select_next_actionable_picks_highest_priority() {
|
|
let state = sample_goal_state();
|
|
let result = GoalEngine::select_next_actionable(&state);
|
|
// g1 (High) step s2 should be selected over g2 (Medium)
|
|
assert_eq!(result, Some((0, 1)));
|
|
}
|
|
|
|
#[test]
|
|
fn select_next_actionable_skips_exhausted_steps() {
|
|
let mut state = sample_goal_state();
|
|
// Exhaust s2 attempts
|
|
state.goals[0].steps[1].attempts = MAX_STEP_ATTEMPTS;
|
|
let result = GoalEngine::select_next_actionable(&state);
|
|
// Should skip s2, pick s3
|
|
assert_eq!(result, Some((0, 2)));
|
|
}
|
|
|
|
#[test]
|
|
fn select_next_actionable_skips_non_in_progress_goals() {
|
|
let mut state = sample_goal_state();
|
|
state.goals[0].status = GoalStatus::Completed;
|
|
let result = GoalEngine::select_next_actionable(&state);
|
|
// g1 completed, should pick g2 s1
|
|
assert_eq!(result, Some((1, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn select_next_actionable_returns_none_when_nothing_actionable() {
|
|
let state = GoalState::default();
|
|
assert!(GoalEngine::select_next_actionable(&state).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn build_step_prompt_includes_goal_and_step() {
|
|
let state = sample_goal_state();
|
|
let prompt = GoalEngine::build_step_prompt(&state.goals[0], &state.goals[0].steps[1]);
|
|
assert!(prompt.contains("Build automation platform"));
|
|
assert!(prompt.contains("Setup environment"));
|
|
assert!(prompt.contains("Research tools"));
|
|
assert!(prompt.contains("Using Python + Selenium"));
|
|
assert!(!prompt.contains("WARNING")); // no retries yet
|
|
}
|
|
|
|
#[test]
|
|
fn build_step_prompt_includes_retry_warning() {
|
|
let mut state = sample_goal_state();
|
|
state.goals[0].steps[1].attempts = 2;
|
|
state.goals[0].last_error = Some("connection refused".into());
|
|
let prompt = GoalEngine::build_step_prompt(&state.goals[0], &state.goals[0].steps[1]);
|
|
assert!(prompt.contains("WARNING"));
|
|
assert!(prompt.contains("2 time(s)"));
|
|
assert!(prompt.contains("connection refused"));
|
|
}
|
|
|
|
#[test]
|
|
fn interpret_result_success() {
|
|
assert!(GoalEngine::interpret_result(
|
|
"Successfully set up the environment"
|
|
));
|
|
assert!(GoalEngine::interpret_result("Done. All tasks completed."));
|
|
}
|
|
|
|
#[test]
|
|
fn interpret_result_failure() {
|
|
assert!(!GoalEngine::interpret_result("Failed to install package"));
|
|
assert!(!GoalEngine::interpret_result(
|
|
"Error: connection timeout occurred"
|
|
));
|
|
assert!(!GoalEngine::interpret_result("Unable to find the resource"));
|
|
assert!(!GoalEngine::interpret_result("cannot open file"));
|
|
assert!(!GoalEngine::interpret_result("Fatal: repository not found"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_save_state_roundtrip() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let engine = GoalEngine::new(tmp.path());
|
|
|
|
// Initially empty
|
|
let empty = engine.load_state().await.unwrap();
|
|
assert!(empty.goals.is_empty());
|
|
|
|
// Save and reload
|
|
let state = sample_goal_state();
|
|
engine.save_state(&state).await.unwrap();
|
|
let loaded = engine.load_state().await.unwrap();
|
|
assert_eq!(loaded.goals.len(), 2);
|
|
assert_eq!(loaded.goals[0].id, "g1");
|
|
assert_eq!(loaded.goals[1].priority, GoalPriority::Medium);
|
|
}
|
|
|
|
#[test]
|
|
fn priority_ordering() {
|
|
assert!(GoalPriority::Critical > GoalPriority::High);
|
|
assert!(GoalPriority::High > GoalPriority::Medium);
|
|
assert!(GoalPriority::Medium > GoalPriority::Low);
|
|
}
|
|
|
|
#[test]
|
|
fn goal_status_default_is_pending() {
|
|
assert_eq!(GoalStatus::default(), GoalStatus::Pending);
|
|
}
|
|
|
|
#[test]
|
|
fn step_status_default_is_pending() {
|
|
assert_eq!(StepStatus::default(), StepStatus::Pending);
|
|
}
|
|
|
|
#[test]
|
|
fn find_stalled_goals_detects_exhausted_steps() {
|
|
let state = GoalState {
|
|
goals: vec![Goal {
|
|
id: "g1".into(),
|
|
description: "Stalled goal".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![
|
|
Step {
|
|
id: "s1".into(),
|
|
description: "Done step".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
},
|
|
Step {
|
|
id: "s2".into(),
|
|
description: "Exhausted step".into(),
|
|
status: StepStatus::Pending,
|
|
result: None,
|
|
attempts: 3, // >= MAX_STEP_ATTEMPTS
|
|
},
|
|
],
|
|
context: String::new(),
|
|
last_error: Some("step failed 3 times".into()),
|
|
}],
|
|
};
|
|
|
|
let stalled = GoalEngine::find_stalled_goals(&state);
|
|
assert_eq!(stalled, vec![0]);
|
|
}
|
|
|
|
#[test]
|
|
fn find_stalled_goals_ignores_actionable_goals() {
|
|
let state = sample_goal_state(); // has pending steps with attempts=0
|
|
let stalled = GoalEngine::find_stalled_goals(&state);
|
|
assert!(stalled.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn find_stalled_goals_ignores_completed_goals() {
|
|
let state = GoalState {
|
|
goals: vec![Goal {
|
|
id: "g1".into(),
|
|
description: "Done".into(),
|
|
status: GoalStatus::Completed,
|
|
priority: GoalPriority::Medium,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![Step {
|
|
id: "s1".into(),
|
|
description: "Only step".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
}],
|
|
context: String::new(),
|
|
last_error: None,
|
|
}],
|
|
};
|
|
|
|
let stalled = GoalEngine::find_stalled_goals(&state);
|
|
assert!(stalled.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_reflection_prompt_includes_step_summary() {
|
|
let goal = Goal {
|
|
id: "g1".into(),
|
|
description: "Test reflection".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![
|
|
Step {
|
|
id: "s1".into(),
|
|
description: "Completed step".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("worked".into()),
|
|
attempts: 1,
|
|
},
|
|
Step {
|
|
id: "s2".into(),
|
|
description: "Failed step".into(),
|
|
status: StepStatus::Pending,
|
|
result: None,
|
|
attempts: 3,
|
|
},
|
|
],
|
|
context: "some context".into(),
|
|
last_error: Some("policy_denied".into()),
|
|
};
|
|
|
|
let prompt = GoalEngine::build_reflection_prompt(&goal);
|
|
assert!(prompt.contains("[Goal Reflection]"));
|
|
assert!(prompt.contains("Test reflection"));
|
|
assert!(prompt.contains("[done] Completed step"));
|
|
assert!(prompt.contains("[exhausted] Failed step"));
|
|
assert!(prompt.contains("some context"));
|
|
assert!(prompt.contains("policy_denied"));
|
|
assert!(prompt.contains("memory_store"));
|
|
}
|
|
|
|
// ── Self-healing deserialization tests ───────────────────────
|
|
|
|
#[test]
|
|
fn goal_status_deserializes_all_valid_variants() {
|
|
let cases = vec![
|
|
("\"pending\"", GoalStatus::Pending),
|
|
("\"in_progress\"", GoalStatus::InProgress),
|
|
("\"completed\"", GoalStatus::Completed),
|
|
("\"blocked\"", GoalStatus::Blocked),
|
|
("\"cancelled\"", GoalStatus::Cancelled),
|
|
];
|
|
for (json_str, expected) in cases {
|
|
let parsed: GoalStatus =
|
|
serde_json::from_str(json_str).unwrap_or_else(|e| panic!("{json_str}: {e}"));
|
|
assert_eq!(parsed, expected, "GoalStatus mismatch for {json_str}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn goal_status_self_healing_unknown_variants() {
|
|
for variant in &[
|
|
"\"unknown\"",
|
|
"\"invalid\"",
|
|
"\"PENDING\"",
|
|
"\"IN_PROGRESS\"",
|
|
"\"\"",
|
|
] {
|
|
let parsed: GoalStatus =
|
|
serde_json::from_str(variant).unwrap_or_else(|e| panic!("{variant}: {e}"));
|
|
assert_eq!(parsed, GoalStatus::Pending);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn step_status_deserializes_all_valid_variants() {
|
|
let cases = vec![
|
|
("\"pending\"", StepStatus::Pending),
|
|
("\"in_progress\"", StepStatus::InProgress),
|
|
("\"completed\"", StepStatus::Completed),
|
|
("\"failed\"", StepStatus::Failed),
|
|
("\"blocked\"", StepStatus::Blocked),
|
|
];
|
|
for (json_str, expected) in cases {
|
|
let parsed: StepStatus =
|
|
serde_json::from_str(json_str).unwrap_or_else(|e| panic!("{json_str}: {e}"));
|
|
assert_eq!(parsed, expected, "StepStatus mismatch for {json_str}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn step_status_self_healing_unknown_variants() {
|
|
for variant in &["\"unknown\"", "\"done\"", "\"FAILED\"", "\"\""] {
|
|
let parsed: StepStatus =
|
|
serde_json::from_str(variant).unwrap_or_else(|e| panic!("{variant}: {e}"));
|
|
assert_eq!(parsed, StepStatus::Pending);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn goal_status_self_healing_in_full_goal_json() {
|
|
let json = r#"{"id":"g1","description":"test","status":"totally_bogus","steps":[]}"#;
|
|
let goal: Goal = serde_json::from_str(json).unwrap();
|
|
assert_eq!(goal.status, GoalStatus::Pending);
|
|
}
|
|
|
|
// ── find_stalled_goals edge cases ───────────────────────────
|
|
|
|
#[test]
|
|
fn find_stalled_goals_empty_steps_not_stalled() {
|
|
let state = GoalState {
|
|
goals: vec![Goal {
|
|
id: "g1".into(),
|
|
description: "No steps".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![],
|
|
context: String::new(),
|
|
last_error: None,
|
|
}],
|
|
};
|
|
assert!(GoalEngine::find_stalled_goals(&state).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn find_stalled_goals_multiple_stalled() {
|
|
let stalled_goal = |id: &str| Goal {
|
|
id: id.into(),
|
|
description: format!("Stalled {id}"),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::Medium,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![Step {
|
|
id: "s1".into(),
|
|
description: "Exhausted".into(),
|
|
status: StepStatus::Pending,
|
|
result: None,
|
|
attempts: MAX_STEP_ATTEMPTS,
|
|
}],
|
|
context: String::new(),
|
|
last_error: None,
|
|
};
|
|
let state = GoalState {
|
|
goals: vec![stalled_goal("g1"), stalled_goal("g2"), stalled_goal("g3")],
|
|
};
|
|
assert_eq!(GoalEngine::find_stalled_goals(&state), vec![0, 1, 2]);
|
|
}
|
|
|
|
#[test]
|
|
fn find_stalled_goals_all_steps_completed_is_stalled() {
|
|
let state = GoalState {
|
|
goals: vec![Goal {
|
|
id: "g1".into(),
|
|
description: "All done but still in-progress".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![
|
|
Step {
|
|
id: "s1".into(),
|
|
description: "Done".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
},
|
|
Step {
|
|
id: "s2".into(),
|
|
description: "Also done".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
},
|
|
],
|
|
context: String::new(),
|
|
last_error: None,
|
|
}],
|
|
};
|
|
assert_eq!(GoalEngine::find_stalled_goals(&state), vec![0]);
|
|
}
|
|
|
|
#[test]
|
|
fn find_stalled_goals_mix_completed_and_blocked_steps() {
|
|
let state = GoalState {
|
|
goals: vec![Goal {
|
|
id: "g1".into(),
|
|
description: "Mixed".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![
|
|
Step {
|
|
id: "s1".into(),
|
|
description: "Done".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
},
|
|
Step {
|
|
id: "s2".into(),
|
|
description: "Blocked".into(),
|
|
status: StepStatus::Blocked,
|
|
result: None,
|
|
attempts: 0,
|
|
},
|
|
],
|
|
context: String::new(),
|
|
last_error: None,
|
|
}],
|
|
};
|
|
assert_eq!(GoalEngine::find_stalled_goals(&state), vec![0]);
|
|
}
|
|
|
|
// ── build_reflection_prompt edge cases ───────────────────────
|
|
|
|
#[test]
|
|
fn build_reflection_prompt_empty_context_omits_section() {
|
|
let goal = Goal {
|
|
id: "g1".into(),
|
|
description: "Empty context".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![Step {
|
|
id: "s1".into(),
|
|
description: "Step".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
}],
|
|
context: String::new(),
|
|
last_error: None,
|
|
};
|
|
let prompt = GoalEngine::build_reflection_prompt(&goal);
|
|
assert!(!prompt.contains("Accumulated context"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_reflection_prompt_no_last_error_omits_section() {
|
|
let goal = Goal {
|
|
id: "g1".into(),
|
|
description: "No error".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![Step {
|
|
id: "s1".into(),
|
|
description: "Step".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
}],
|
|
context: "some ctx".into(),
|
|
last_error: None,
|
|
};
|
|
let prompt = GoalEngine::build_reflection_prompt(&goal);
|
|
assert!(!prompt.contains("Last error"));
|
|
}
|
|
|
|
#[test]
|
|
fn build_reflection_prompt_all_done_tags() {
|
|
let goal = Goal {
|
|
id: "g1".into(),
|
|
description: "All done".into(),
|
|
status: GoalStatus::InProgress,
|
|
priority: GoalPriority::High,
|
|
created_at: String::new(),
|
|
updated_at: String::new(),
|
|
steps: vec![
|
|
Step {
|
|
id: "s1".into(),
|
|
description: "First".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
},
|
|
Step {
|
|
id: "s2".into(),
|
|
description: "Second".into(),
|
|
status: StepStatus::Completed,
|
|
result: Some("ok".into()),
|
|
attempts: 1,
|
|
},
|
|
],
|
|
context: String::new(),
|
|
last_error: None,
|
|
};
|
|
let prompt = GoalEngine::build_reflection_prompt(&goal);
|
|
assert!(prompt.contains("[done] First"));
|
|
assert!(prompt.contains("[done] Second"));
|
|
assert!(!prompt.contains("[exhausted]"));
|
|
assert!(!prompt.contains("[blocked]"));
|
|
}
|
|
|
|
// ── GoalPriority comparison and serde ────────────────────────
|
|
|
|
#[test]
|
|
fn priority_all_comparisons() {
|
|
assert!(GoalPriority::Critical > GoalPriority::High);
|
|
assert!(GoalPriority::High > GoalPriority::Medium);
|
|
assert!(GoalPriority::Medium > GoalPriority::Low);
|
|
assert!(GoalPriority::Low < GoalPriority::Critical);
|
|
}
|
|
|
|
#[test]
|
|
fn priority_serde_roundtrip_all_variants() {
|
|
for priority in &[
|
|
GoalPriority::Low,
|
|
GoalPriority::Medium,
|
|
GoalPriority::High,
|
|
GoalPriority::Critical,
|
|
] {
|
|
let json = serde_json::to_string(priority).unwrap();
|
|
let parsed: GoalPriority = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(*priority, parsed);
|
|
}
|
|
}
|
|
}
|