use crate::config::GitFlowConfig;
use crate::stage::Stage;
use crate::state::State;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AgentResult {
pub status: AgentStatus,
pub exit_code: Option<i32>,
pub reason: Option<String>,
pub commits: Option<u32>,
pub summary: Option<String>,
#[serde(default, deserialize_with = "deserialize_verdict_lenient")]
pub verdict: Option<Verdict>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentStatus {
Success,
Failed,
RateLimited,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
Pass,
Gaps,
}
fn deserialize_verdict_lenient<'de, D>(deserializer: D) -> Result<Option<Verdict>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = <Option<serde_json::Value> as serde::Deserialize>::deserialize(deserializer)?;
Ok(raw.and_then(|v| {
v.as_str().and_then(|s| match s {
"pass" => Some(Verdict::Pass),
"gaps" => Some(Verdict::Gaps),
_ => None,
})
}))
}
#[derive(Debug, thiserror::Error)]
pub enum ResultError {
#[error("I/O error reading agent output: {0}")]
Io(#[from] std::io::Error),
#[error("phase directory not found")]
NoPhaseDir,
}
pub fn parse_devflow_result(stdout: &str) -> Option<AgentResult> {
if let Some(inner) = extract_json_result_text(stdout)
&& let Some(result) = parse_marker_lines(&inner)
{
return Some(result);
}
parse_marker_lines(stdout)
}
pub fn detect_rate_limit(stdout: &str) -> Option<String> {
detect_claude_rate_limit(stdout).or_else(|| detect_codex_rate_limit(stdout))
}
fn detect_claude_rate_limit(stdout: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(stdout.trim()).ok()?;
let rate_limited = json_has_str(&value, "subtype", "error_rate_limit")
|| json_has_i64(&value, "api_error_status", 429)
|| json_has_i64(&value, "status", 429)
|| json_has_i64(&value, "status_code", 429);
if !rate_limited {
return None;
}
json_find_key(&value, "retry_after")
.and_then(json_scalar_to_string)
.or_else(|| json_find_key(&value, "message").and_then(json_scalar_to_string))
.or_else(|| json_find_key(&value, "error").and_then(json_scalar_to_string))
.or_else(|| Some("usage limit".to_string()))
}
fn detect_codex_rate_limit(stdout: &str) -> Option<String> {
let stdout: String = stdout
.lines()
.filter(|line| {
serde_json::from_str::<serde_json::Value>(line)
.map(|v| !v.is_object())
.unwrap_or(true)
})
.collect::<Vec<_>>()
.join("\n");
let stdout = stdout.as_str();
let lower = stdout.to_ascii_lowercase();
if let Some(idx) = lower.find("try again at ") {
let start = idx + "try again at ".len();
let retry = stdout[start..]
.lines()
.next()
.unwrap_or_default()
.trim()
.trim_end_matches(['.', ',', ';'])
.trim();
if !retry.is_empty() {
return Some(retry.to_string());
}
}
if lower.contains("usage limit") || lower.contains("rate limit") || lower.contains("429") {
stdout
.lines()
.find(|line| {
let line = line.to_ascii_lowercase();
line.contains("usage limit") || line.contains("rate limit") || line.contains("429")
})
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.or_else(|| Some("usage limit".to_string()))
} else {
None
}
}
fn extract_json_result_text(stdout: &str) -> Option<String> {
let trimmed = stdout.trim();
if !trimmed.starts_with('{') {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
value.get("result")?.as_str().map(str::to_string)
}
fn json_scan<'a, T>(
value: &'a serde_json::Value,
visit: impl Fn(&'a serde_json::Map<String, serde_json::Value>) -> Option<T>,
) -> Option<T> {
let mut stack = vec![value];
while let Some(current) = stack.pop() {
match current {
serde_json::Value::Object(map) => {
if let Some(found) = visit(map) {
return Some(found);
}
for child in map.values().rev() {
stack.push(child);
}
}
serde_json::Value::Array(values) => {
for child in values.iter().rev() {
stack.push(child);
}
}
_ => {}
}
}
None
}
fn json_has_str(value: &serde_json::Value, key: &str, expected: &str) -> bool {
json_scan(value, |map| {
(map.get(key)?.as_str()? == expected).then_some(())
})
.is_some()
}
fn json_has_i64(value: &serde_json::Value, key: &str, expected: i64) -> bool {
json_scan(value, |map| {
(map.get(key)?.as_i64()? == expected).then_some(())
})
.is_some()
}
fn json_find_key<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
json_scan(value, |map| map.get(key))
}
fn json_scalar_to_string(value: &serde_json::Value) -> Option<String> {
match value {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
}
}
fn detect_claude_envelope_failure(stdout: &str) -> Option<AgentResult> {
let trimmed = stdout.trim();
if !trimmed.starts_with('{') {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
let is_error = value.get("is_error")?.as_bool()?;
if !is_error {
return None;
}
let num_turns = value.get("num_turns").and_then(serde_json::Value::as_u64);
let base_reason = value
.get("result")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.or_else(|| {
value
.get("subtype")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| "agent reported is_error".to_string());
let reason = match num_turns {
Some(n) => format!("{base_reason} (num_turns: {n})"),
None => base_reason,
};
Some(AgentResult {
status: AgentStatus::Failed,
exit_code: None,
reason: Some(reason),
commits: None,
summary: None,
verdict: None,
})
}
fn is_codex_event_stream(events: &[serde_json::Value]) -> bool {
events.iter().any(|v| {
v.get("type")
.and_then(serde_json::Value::as_str)
.is_some_and(|t| t == "thread.started" || t.starts_with("turn."))
})
}
fn parse_codex_event_result(stdout: &str) -> Option<AgentResult> {
let events: Vec<serde_json::Value> = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.collect();
if !is_codex_event_stream(&events) {
return None;
}
let marker = events.iter().rev().find_map(|v| {
if v.get("type").and_then(serde_json::Value::as_str) != Some("item.completed") {
return None;
}
let item = v.get("item")?;
if item.get("type").and_then(serde_json::Value::as_str) != Some("agent_message") {
return None;
}
let text = item.get("text").and_then(serde_json::Value::as_str)?;
parse_marker_lines(text)
});
if marker.is_some() {
return marker;
}
let terminal = events.iter().rev().find(|v| {
matches!(
v.get("type").and_then(serde_json::Value::as_str),
Some("turn.completed") | Some("turn.failed")
)
})?;
if terminal.get("type").and_then(serde_json::Value::as_str) != Some("turn.failed") {
return None;
}
let reason = terminal
.get("error")
.and_then(|e| e.get("message"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| "codex turn failed".to_string());
Some(AgentResult {
status: AgentStatus::Failed,
exit_code: None,
reason: Some(reason),
commits: None,
summary: None,
verdict: None,
})
}
fn parse_marker_lines(stdout: &str) -> Option<AgentResult> {
let tail: String = stdout
.chars()
.rev()
.take(4000)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
for line in tail.lines().rev() {
let Some(json_str) = line
.strip_prefix("DEVFLOW_RESULT: ")
.or_else(|| line.strip_prefix("devflow_result: "))
.or_else(|| line.strip_prefix("DEVFLOW_RESULT:"))
.or_else(|| line.strip_prefix("devflow_result:"))
else {
continue;
};
let json_str = json_str.trim();
if let Ok(result) = serde_json::from_str::<AgentResult>(json_str) {
return Some(result);
}
}
None
}
pub fn evaluate_layer1(project_root: &Path, phase: u32) -> Option<AgentResult> {
let stdout_path = devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase));
let bytes = std::fs::read(&stdout_path).ok()?;
let stdout = String::from_utf8_lossy(&bytes);
detect_claude_rate_limit(&stdout)
.map(rate_limited_result)
.or_else(|| detect_claude_envelope_failure(&stdout))
.or_else(|| parse_devflow_result(&stdout))
.or_else(|| parse_codex_event_result(&stdout))
.or_else(|| detect_codex_rate_limit(&stdout).map(rate_limited_result))
}
fn rate_limited_result(retry: String) -> AgentResult {
AgentResult {
status: AgentStatus::RateLimited,
exit_code: None,
reason: Some(format!("rate limited until {retry}")),
commits: None,
summary: None,
verdict: None,
}
}
pub fn evaluate_layer2(
project_root: &Path,
phase: u32,
git_flow: &GitFlowConfig,
stage: Stage,
) -> Result<Option<AgentResult>, ResultError> {
let exit_path = devflow_dir(project_root).join(format!("phase-{:02}-exit", phase));
let exit_code: i32 = match std::fs::read_to_string(&exit_path) {
Ok(s) => s.trim().parse().unwrap_or(-1),
Err(_) => return Ok(None), };
let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
let branch_exists = std::process::Command::new("git")
.args(["rev-parse", "--verify", &branch])
.current_dir(project_root)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let commits: u32 = if branch_exists {
let range = format!("{}..{branch}", git_flow.develop);
std::process::Command::new("git")
.args(["rev-list", "--count", &range])
.current_dir(project_root)
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
.unwrap_or(0)
} else {
0
};
let commit_gated = matches!(stage, Stage::Plan | Stage::Code);
let no_work_done = commit_gated && commits == 0;
Ok(Some(AgentResult {
status: if exit_code != 0 || no_work_done {
AgentStatus::Failed
} else {
AgentStatus::Success
},
exit_code: Some(exit_code),
reason: if exit_code != 0 {
Some(format!(
"agent exited with code {} ({} commits on {})",
exit_code, commits, branch
))
} else if no_work_done {
Some(format!(
"no commits found on {} (agent exit code was {})",
branch, exit_code
))
} else {
Some(format!(
"{} commits on {} (agent exit code was {})",
commits, branch, exit_code
))
},
commits: Some(commits),
summary: None,
verdict: None,
}))
}
pub fn evaluate_layer3(
project_root: &Path,
phase: u32,
git_flow: &GitFlowConfig,
) -> Result<AgentResult, ResultError> {
let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
let commits = std::process::Command::new("git")
.args([
"rev-list",
"--count",
&format!("{}..{branch}", git_flow.develop),
])
.current_dir(project_root)
.output()
.ok()
.and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse().ok())
.unwrap_or(0);
Ok(AgentResult {
status: AgentStatus::Unknown,
exit_code: None,
reason: if commits > 0 {
Some(format!(
"unverified — agent process is gone but {} commits exist on {}",
commits, branch
))
} else {
Some("no work detected — agent process is gone with no commits".into())
},
commits: Some(commits),
summary: None,
verdict: None,
})
}
pub fn evaluate_agent_result(
project_root: &Path,
state: &State,
git_flow: &GitFlowConfig,
) -> Result<AgentResult, ResultError> {
if let Some(result) = evaluate_layer1(project_root, state.phase) {
return Ok(result);
}
if let Some(result) = evaluate_layer2(project_root, state.phase, git_flow, state.stage)? {
return Ok(result);
}
evaluate_layer3(project_root, state.phase, git_flow)
}
fn devflow_dir(project_root: &Path) -> PathBuf {
project_root.join(".devflow")
}
pub fn stdout_path(project_root: &Path, phase: u32) -> PathBuf {
devflow_dir(project_root).join(format!("phase-{:02}-stdout", phase))
}
pub fn stderr_path(project_root: &Path, phase: u32) -> PathBuf {
devflow_dir(project_root).join(format!("phase-{phase:02}-stderr.log"))
}
pub fn exit_code_path(project_root: &Path, phase: u32) -> PathBuf {
devflow_dir(project_root).join(format!("phase-{:02}-exit", phase))
}
pub fn agent_pid_path(project_root: &Path, phase: u32) -> PathBuf {
devflow_dir(project_root).join(format!("phase-{:02}-agent-pid", phase))
}
pub fn cleanup_phase_files(project_root: &Path, phase: u32) {
let _ = std::fs::remove_file(stdout_path(project_root, phase));
let _ = std::fs::remove_file(exit_code_path(project_root, phase));
let _ = std::fs::remove_file(agent_pid_path(project_root, phase));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::GitFlowConfig;
use crate::mode::Mode;
use crate::stage::Stage;
use crate::state::{AgentKind, State};
use std::process::Command;
fn state_in(root: &Path, phase: u32) -> State {
let mut state = State::new(phase, AgentKind::Claude, Mode::Auto, root.to_path_buf());
state.stage = Stage::Code;
state
}
fn git(root: &Path, args: &[&str]) {
let output = Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed\nstdout: {}\nstderr: {}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn init_repo_with_feature_commit(root: &Path, phase: u32) {
git(root, &["init"]);
git(root, &["config", "user.email", "devflow@example.com"]);
git(root, &["config", "user.name", "DevFlow Tests"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "tag.gpgsign", "false"]);
git(root, &["checkout", "-b", "develop"]);
std::fs::write(root.join("README.md"), "base\n").unwrap();
git(root, &["add", "README.md"]);
git(root, &["commit", "-m", "base"]);
let branch = format!("feature/phase-{phase:02}");
git(root, &["checkout", "-b", &branch]);
std::fs::write(root.join("phase.txt"), "feature work\n").unwrap();
git(root, &["add", "phase.txt"]);
git(root, &["commit", "-m", "feature work"]);
}
fn init_repo_with_feature_no_commit(root: &Path, phase: u32) {
git(root, &["init"]);
git(root, &["config", "user.email", "devflow@example.com"]);
git(root, &["config", "user.name", "DevFlow Tests"]);
git(root, &["config", "commit.gpgsign", "false"]);
git(root, &["config", "tag.gpgsign", "false"]);
git(root, &["checkout", "-b", "develop"]);
std::fs::write(root.join("README.md"), "base\n").unwrap();
git(root, &["add", "README.md"]);
git(root, &["commit", "-m", "base"]);
let branch = format!("feature/phase-{phase:02}");
git(root, &["checkout", "-b", &branch]);
}
#[test]
fn parse_success_marker() {
let stdout = "some output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
}
#[test]
fn parse_failed_marker_with_reason() {
let stdout =
"work done\nDEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"clippy errors\"}\n";
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(result.reason.unwrap(), "clippy errors");
}
#[test]
fn parse_missing_marker_returns_none() {
let stdout = "just some output\nno marker here\n";
assert!(parse_devflow_result(stdout).is_none());
}
#[test]
fn parse_malformed_json_returns_none() {
let stdout = "DEVFLOW_RESULT: {not valid json}\n";
assert!(parse_devflow_result(stdout).is_none());
}
#[test]
fn parse_lowercase_marker() {
let stdout = "devflow_result: {\"status\":\"success\"}\n";
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
}
#[test]
fn parse_marker_without_space_after_colon() {
let stdout = "DEVFLOW_RESULT:{\"status\":\"success\"}\n";
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
}
#[test]
fn parse_lowercase_no_space_marker() {
let stdout = "devflow_result:{\"status\":\"success\"}\n";
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
}
#[test]
fn parse_finds_last_marker_in_tail() {
let stdout = "DEVFLOW_RESULT: {\"status\":\"failed\"}\nsome more output\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
}
#[test]
fn parse_marker_lines_returns_last_marker_in_long_output() {
let stdout = format!(
"{}\nDEVFLOW_RESULT: {{\"status\":\"failed\"}}\n{}\n\
DEVFLOW_RESULT: {{\"status\":\"success\"}}\n",
"prefix".repeat(900),
"tail output".repeat(100)
);
let result = parse_marker_lines(&stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
}
#[test]
fn parse_marker_only_in_last_4000_chars() {
let prefix = "a".repeat(5000);
let stdout = format!("DEVFLOW_RESULT: {{\"status\":\"success\"}}\n{prefix}");
assert!(parse_devflow_result(&stdout).is_none());
}
#[test]
fn parse_marker_with_commits_and_summary() {
let stdout = r#"DEVFLOW_RESULT: {"status":"success","commits":3,"summary":"added tests"}"#;
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.commits, Some(3));
assert_eq!(result.summary.unwrap(), "added tests");
}
#[test]
fn parse_marker_inside_json_result_envelope() {
let stdout = r#"{"type":"result","subtype":"success","result":"All done.\nDEVFLOW_RESULT: {\"status\": \"success\", \"commits\": 2}","session_id":"abc"}"#;
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.commits, Some(2));
}
#[test]
fn parse_failed_marker_inside_json_envelope() {
let stdout = r#"{"result":"work\nDEVFLOW_RESULT: {\"status\": \"failed\", \"reason\": \"tests failed\"}"}"#;
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(result.reason.unwrap(), "tests failed");
}
#[test]
fn parse_json_envelope_without_marker_returns_none() {
let stdout = r#"{"result":"did some work but forgot the marker","session_id":"x"}"#;
assert!(parse_devflow_result(stdout).is_none());
}
#[test]
fn detect_claude_json_rate_limit_by_subtype() {
let stdout = r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z","result":"rate limited"}"#;
assert_eq!(
detect_rate_limit(stdout).as_deref(),
Some("2026-06-18T15:45:30Z")
);
}
#[test]
fn detect_claude_json_rate_limit_by_429() {
let stdout = r#"{"type":"result","api_error_status":429,"error":{"message":"Too many requests. Try later."}}"#;
assert_eq!(
detect_rate_limit(stdout).as_deref(),
Some("Too many requests. Try later.")
);
}
#[test]
fn detect_codex_try_again_rate_limit() {
let stdout = "Usage limit reached. Try again at 3:45 PM.\n";
assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
}
#[test]
fn detect_rate_limit_finds_marker_in_deeply_nested_json_without_overflow() {
const DEPTH: usize = 100;
let mut stdout = String::new();
for _ in 0..DEPTH {
stdout.push_str(r#"{"nested":"#);
}
stdout.push_str(r#"{"type":"result","subtype":"error_rate_limit","retry_after":"deep"}"#);
for _ in 0..DEPTH {
stdout.push('}');
}
assert_eq!(detect_rate_limit(&stdout).as_deref(), Some("deep"));
}
#[test]
fn detect_rate_limit_ignores_normal_stdout() {
let stdout = "implemented feature\nDEVFLOW_RESULT: {\"status\":\"success\"}\n";
assert!(detect_rate_limit(stdout).is_none());
}
#[test]
fn claude_envelope_is_error_detected() {
let stdout = r#"{"type":"result","subtype":"error","is_error":true,"num_turns":2,"result":"tool call failed","session_id":"abc"}"#;
let result = detect_claude_envelope_failure(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
}
#[test]
fn claude_is_error_overrides_success_marker() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(
stdout_path(dir.path(), 9),
r#"{"type":"result","is_error":true,"num_turns":3,"result":"oops\nDEVFLOW_RESULT: {\"status\":\"success\"}","session_id":"abc"}"#,
)
.unwrap();
let result = evaluate_layer1(dir.path(), 9).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
}
#[test]
fn claude_envelope_is_error_false_defers() {
let stdout = r#"{"type":"result","is_error":false,"num_turns":1,"result":"did some work","session_id":"abc"}"#;
assert!(detect_claude_envelope_failure(stdout).is_none());
}
#[test]
fn claude_envelope_marker_still_wins() {
let stdout = r#"{"type":"result","is_error":false,"result":"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2}","session_id":"abc"}"#;
assert!(detect_claude_envelope_failure(stdout).is_none());
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.commits, Some(2));
}
#[test]
fn codex_event_stream_parses_turn_failed() {
let stdout = concat!(
"{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
"{\"type\":\"turn.started\"}\n",
"{\"type\":\"item.started\",\"item\":{}}\n",
"{\"type\":\"turn.failed\",\"error\":{\"message\":\"sandbox denied write\"}}\n",
);
let result = parse_codex_event_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(result.reason.as_deref(), Some("sandbox denied write"));
}
#[test]
fn codex_turn_completed_no_marker_defers() {
let stdout = concat!(
"{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
"{\"type\":\"turn.started\"}\n",
"{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
);
assert!(parse_codex_event_result(stdout).is_none());
}
#[test]
fn codex_agent_message_marker_failed_wins_over_bare_turn_completed() {
let stdout = concat!(
"{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
"{\"type\":\"item.completed\",\"item\":{\"id\":\"item_7\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"failed\\\", \\\"reason\\\": \\\"interactive input unavailable\\\"}\"}}\n",
"{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
);
let result = parse_codex_event_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(
result.reason.as_deref(),
Some("interactive input unavailable")
);
}
#[test]
fn codex_agent_message_marker_success_short_circuits() {
let stdout = concat!(
"{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
"{\"type\":\"item.completed\",\"item\":{\"id\":\"item_2\",\"type\":\"agent_message\",\"text\":\"DEVFLOW_RESULT: {\\\"status\\\": \\\"success\\\"}\"}}\n",
"{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
);
let result = parse_codex_event_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
}
#[test]
fn detect_rate_limit_ignores_json_event_lines() {
let stdout = concat!(
"{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
"{\"type\":\"item.completed\",\"item\":{\"id\":\"item_4\",\"type\":\"command_execution\",\"aggregated_output\":\"| API keys | Rate limiting per key? |\"}}\n",
"{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}\n",
);
assert_eq!(detect_rate_limit(stdout), None);
}
#[test]
fn detect_rate_limit_still_reads_codex_plain_text() {
let stdout = "Rate limit reached.\nTry again at 3:45 PM.\n";
assert_eq!(detect_rate_limit(stdout).as_deref(), Some("3:45 PM"));
}
#[test]
fn codex_event_stream_ignores_progress_and_unparseable_lines() {
let stdout = concat!(
"{\"type\":\"thread.started\",\"thread_id\":\"t1\"}\n",
"not json at all\n",
"{\"type\":\"item.started\",\"item\":{}}\n",
"{\"type\":\"item.updated\",\"item\":{}}\n",
"{\"type\":\"turn.failed\",\"error\":{\"message\":\"boom\"}}\n",
);
let result = parse_codex_event_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(result.reason.as_deref(), Some("boom"));
}
#[test]
fn claude_envelope_not_consumed_by_codex_parser() {
let stdout = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":4,"result":"All done.","session_id":"abc"}"#;
assert!(parse_codex_event_result(stdout).is_none());
}
#[test]
fn evaluate_layer1_reports_rate_limited_without_marker() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(
stdout_path(dir.path(), 7),
r#"{"type":"result","subtype":"error_rate_limit","retry_after":"2026-06-18T15:45:30Z"}"#,
)
.unwrap();
let result = evaluate_layer1(dir.path(), 7).unwrap();
assert_eq!(result.status, AgentStatus::RateLimited);
assert_eq!(
result.reason.as_deref(),
Some("rate limited until 2026-06-18T15:45:30Z")
);
}
#[test]
fn evaluate_layer1_rate_limit_envelope_with_is_error_is_rate_limited() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(
stdout_path(dir.path(), 7),
r#"{"type":"result","subtype":"error_rate_limit","is_error":true,"retry_after":"2026-06-18T15:45:30Z"}"#,
)
.unwrap();
let result = evaluate_layer1(dir.path(), 7).unwrap();
assert_eq!(result.status, AgentStatus::RateLimited);
assert_eq!(
result.reason.as_deref(),
Some("rate limited until 2026-06-18T15:45:30Z")
);
}
#[test]
fn evaluate_layer1_finds_marker_despite_invalid_utf8_bytes() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
let mut bytes = b"progress \xff\xfe garbage\n".to_vec();
bytes.extend_from_slice(
b"DEVFLOW_RESULT: {\"status\":\"failed\",\"reason\":\"review: bad\"}\n",
);
std::fs::write(stdout_path(dir.path(), 5), bytes).unwrap();
let result = evaluate_layer1(dir.path(), 5).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(result.reason.as_deref(), Some("review: bad"));
}
#[test]
fn cleanup_removes_phase_files() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".devflow")).unwrap();
std::fs::write(root.join(".devflow/phase-01-stdout"), "test").unwrap();
std::fs::write(root.join(".devflow/phase-01-exit"), "0").unwrap();
cleanup_phase_files(root, 1);
assert!(!root.join(".devflow/phase-01-stdout").exists());
assert!(!root.join(".devflow/phase-01-exit").exists());
}
#[test]
fn cleanup_handles_missing_files() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
cleanup_phase_files(root, 1);
}
#[test]
fn evaluate_agent_result_reads_files_end_to_end() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(
stdout_path(dir.path(), 6),
"done\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":2,\"summary\":\"ok\"}\n",
)
.unwrap();
std::fs::write(exit_code_path(dir.path(), 6), "0").unwrap();
let state = state_in(dir.path(), 6);
let result = evaluate_agent_result(dir.path(), &state, &GitFlowConfig::default()).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.commits, Some(2));
assert_eq!(result.summary.as_deref(), Some("ok"));
}
#[test]
fn evaluate_layer1_finds_devflow_result_in_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(
stdout_path(dir.path(), 3),
"output\ndevflow_result: {\"status\":\"failed\",\"reason\":\"bad output\"}\n",
)
.unwrap();
let result = evaluate_layer1(dir.path(), 3).unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(result.reason.as_deref(), Some("bad output"));
}
#[test]
fn evaluate_layer2_falls_back_to_exit_code_and_commit_count() {
let dir = tempfile::tempdir().unwrap();
init_repo_with_feature_commit(dir.path(), 4);
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
let state = state_in(dir.path(), 4);
let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
.unwrap()
.unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.exit_code, Some(0));
assert_eq!(result.commits, Some(1));
assert!(result.reason.unwrap().contains("1 commits"));
}
#[test]
fn evaluate_layer2_exit_zero_no_commits_is_failed() {
let dir = tempfile::tempdir().unwrap();
init_repo_with_feature_no_commit(dir.path(), 4);
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(exit_code_path(dir.path(), 4), "0").unwrap();
let state = state_in(dir.path(), 4);
let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
.unwrap()
.unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(result.exit_code, Some(0));
assert_eq!(result.commits, Some(0));
assert!(result.reason.unwrap().contains("no commits"));
}
#[test]
fn evaluate_layer2_nonzero_exit_is_failed() {
let dir = tempfile::tempdir().unwrap();
init_repo_with_feature_commit(dir.path(), 4);
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(exit_code_path(dir.path(), 4), "1").unwrap();
let state = state_in(dir.path(), 4);
let result = evaluate_layer2(dir.path(), 4, &GitFlowConfig::default(), state.stage)
.unwrap()
.unwrap();
assert_eq!(result.status, AgentStatus::Failed);
assert_eq!(result.exit_code, Some(1));
assert!(result.reason.unwrap().contains("exited with code 1"));
}
#[test]
fn layer2_nonzero_exit_is_failed_all_stages() {
let dir = tempfile::tempdir().unwrap();
init_repo_with_feature_no_commit(dir.path(), 10);
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(exit_code_path(dir.path(), 10), "1").unwrap();
for stage in [
Stage::Define,
Stage::Plan,
Stage::Code,
Stage::Validate,
Stage::Ship,
] {
let result = evaluate_layer2(dir.path(), 10, &GitFlowConfig::default(), stage)
.unwrap()
.unwrap();
assert_eq!(
result.status,
AgentStatus::Failed,
"stage {stage:?} should be Failed on nonzero exit"
);
}
}
#[test]
fn layer2_skips_commit_gate_for_define_and_validate() {
let dir = tempfile::tempdir().unwrap();
init_repo_with_feature_no_commit(dir.path(), 11);
std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
std::fs::write(exit_code_path(dir.path(), 11), "0").unwrap();
for stage in [Stage::Define, Stage::Validate] {
let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), stage)
.unwrap()
.unwrap();
assert_ne!(
result.status,
AgentStatus::Failed,
"stage {stage:?} should not be Failed for zero commits"
);
}
let result = evaluate_layer2(dir.path(), 11, &GitFlowConfig::default(), Stage::Code)
.unwrap()
.unwrap();
assert_eq!(result.status, AgentStatus::Failed);
}
#[test]
fn evaluate_layer3_falls_back_to_commit_count() {
let dir = tempfile::tempdir().unwrap();
init_repo_with_feature_commit(dir.path(), 5);
let result = evaluate_layer3(dir.path(), 5, &GitFlowConfig::default()).unwrap();
assert_eq!(result.status, AgentStatus::Unknown);
assert_eq!(result.exit_code, None);
assert_eq!(result.commits, Some(1));
assert!(result.reason.unwrap().contains("1 commits"));
}
#[test]
fn parse_devflow_result_reads_verdict() {
let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"gaps"}"#;
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.verdict, Some(Verdict::Gaps));
}
#[test]
fn parse_devflow_result_reads_verdict_pass() {
let stdout = r#"DEVFLOW_RESULT: {"status":"success","verdict":"pass"}"#;
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.verdict, Some(Verdict::Pass));
}
#[test]
fn parse_devflow_result_verdict_absent_is_none() {
let stdout = r#"DEVFLOW_RESULT: {"status":"success"}"#;
let result = parse_devflow_result(stdout).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.verdict, None);
}
#[test]
fn parse_devflow_result_malformed_verdict_is_none_not_parse_error() {
let unknown = r#"DEVFLOW_RESULT: {"status":"success","verdict":"wat"}"#;
let result = parse_devflow_result(unknown).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.verdict, None);
let miscased = r#"DEVFLOW_RESULT: {"status":"success","verdict":"Pass"}"#;
let result = parse_devflow_result(miscased).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.verdict, None);
}
#[test]
fn parse_devflow_result_non_string_verdict_type_is_none_not_parse_error() {
let bool_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":true}"#;
let result = parse_devflow_result(bool_verdict).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.verdict, None);
let numeric_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":123}"#;
let result = parse_devflow_result(numeric_verdict).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.verdict, None);
let object_verdict = r#"DEVFLOW_RESULT: {"status":"success","verdict":{"x":1}}"#;
let result = parse_devflow_result(object_verdict).unwrap();
assert_eq!(result.status, AgentStatus::Success);
assert_eq!(result.verdict, None);
}
}