#![allow(dead_code)]
use crate::agent::AgentRunner;
use crate::error::{OrchestratorError, Result};
use crate::history::{AcceptanceAttempt, OutputCollector};
use crate::openspec::Change;
use tracing::{info, warn};
use super::output::OutputHandler;
const ACCEPTANCE_OUTPUT_FALLBACK: &str = "No acceptance output captured";
pub fn build_acceptance_tail_findings(
stdout_tail: Option<String>,
stderr_tail: Option<String>,
) -> Vec<String> {
let stdout = stdout_tail.filter(|text| !text.trim().is_empty());
let stderr = stderr_tail.filter(|text| !text.trim().is_empty());
let selected = stdout
.or(stderr)
.unwrap_or_else(|| ACCEPTANCE_OUTPUT_FALLBACK.to_string());
let lines = selected
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.is_empty()
&& !trimmed.starts_with("ACCEPTANCE:")
&& !trimmed.starts_with("FINDINGS:")
})
.map(|line| line.to_string())
.collect::<Vec<_>>();
if lines.is_empty() {
vec![ACCEPTANCE_OUTPUT_FALLBACK.to_string()]
} else {
lines
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptanceResult {
Pass,
Fail { findings: Vec<String> },
Continue,
Gated,
CommandFailed {
error: String,
findings: Vec<String>,
},
PermissionStalled {
blocker: crate::events::StalledBlocker,
},
Cancelled,
}
impl AcceptanceResult {
pub fn is_pass(&self) -> bool {
matches!(self, AcceptanceResult::Pass)
}
}
pub async fn acceptance_test_streaming<O, F>(
change: &Change,
agent: &mut AgentRunner,
ai_runner: &crate::ai_command_runner::AiCommandRunner,
_config: &crate::config::OrchestratorConfig,
output: &O,
cancel_check: F,
) -> Result<(AcceptanceResult, u32, String)>
where
O: OutputHandler,
F: Fn() -> bool,
{
use crate::agent::OutputLine;
info!("Running acceptance test for: {}", change.id);
output.on_info(&format!("Acceptance test: {}", change.id));
let commit_hash = crate::vcs::git::commands::get_current_commit(".")
.await
.ok();
let base_branch = crate::vcs::git::commands::get_current_branch(".")
.await
.ok()
.flatten();
let (mut child, mut output_rx, start_time, command) = agent
.run_acceptance_streaming_with_runner(&change.id, ai_runner, None, base_branch.as_deref())
.await?;
output.on_info(&format!("Acceptance started: {}", change.id));
output.on_info(&format!(" Command: {}", command));
let mut output_collector = OutputCollector::new();
let mut full_stdout = String::new();
const MARKER_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_secs(30);
let mut marker_detected = false;
let mut marker_deadline: Option<tokio::time::Instant> = None;
let mut early_terminated = false;
loop {
let recv_future = output_rx.recv();
let line = if let Some(deadline) = marker_deadline {
match tokio::time::timeout_at(deadline, recv_future).await {
Ok(Some(line)) => line,
Ok(None) => break, Err(_) => {
warn!(
"Acceptance marker grace period expired for {}, terminating process",
change.id
);
let _ = child.terminate();
early_terminated = true;
break;
}
}
} else {
match tokio::time::timeout(std::time::Duration::from_millis(50), recv_future).await {
Ok(Some(line)) => line,
Ok(None) => break, Err(_) => {
if cancel_check() {
warn!("Acceptance test cancelled while waiting for output");
output.on_warn("Acceptance test cancelled");
let _ = child.terminate();
return Ok((AcceptanceResult::Cancelled, 0, command));
}
continue;
}
}
};
if cancel_check() {
warn!("Acceptance test cancelled for: {}", change.id);
output.on_warn("Acceptance test cancelled");
let _ = child.terminate();
return Ok((AcceptanceResult::Cancelled, 0, command));
}
match line {
OutputLine::Stdout(s) => {
output_collector.add_stdout(&s);
full_stdout.push_str(&s);
full_stdout.push('\n');
output.on_stdout(&s);
if !marker_detected && crate::acceptance::detect_verdict_in_line(&s).is_some() {
marker_detected = true;
marker_deadline = Some(tokio::time::Instant::now() + MARKER_GRACE_PERIOD);
info!(
"Acceptance canonical verdict detected for {}, starting {}s grace period",
change.id,
MARKER_GRACE_PERIOD.as_secs()
);
}
}
OutputLine::Stderr(s) => {
output_collector.add_stderr(&s);
output.on_agent_stderr(&s);
}
}
}
let status = loop {
if cancel_check() {
warn!(
"Acceptance test cancelled while waiting for child status for: {}",
change.id
);
output.on_warn("Acceptance test cancelled");
let _ = child.terminate();
return Ok((AcceptanceResult::Cancelled, 0, command));
}
match tokio::time::timeout(std::time::Duration::from_millis(50), child.wait()).await {
Ok(status) => {
break status.map_err(|e| {
OrchestratorError::AgentCommand(format!(
"Failed to wait for acceptance command for change '{}': {}",
change.id, e
))
})?;
}
Err(_) => continue,
}
};
let stdout_tail = output_collector.stdout_tail();
let stderr_tail = output_collector.stderr_tail();
let tail_findings = build_acceptance_tail_findings(stdout_tail.clone(), stderr_tail.clone());
let verdict_finalized_run = early_terminated && marker_detected;
if !status.success() && !verdict_finalized_run {
let error_msg = format!(
"Acceptance command failed with exit code: {:?}",
status.code()
);
let attempt_number = agent.next_acceptance_attempt_number(&change.id);
let attempt = AcceptanceAttempt {
attempt: attempt_number,
passed: false,
duration: start_time.elapsed(),
findings: Some(tail_findings.clone()),
exit_code: status.code(),
stdout_tail,
stderr_tail,
commit_hash: commit_hash.clone(),
};
agent.record_acceptance_attempt(&change.id, attempt);
output.on_error(&error_msg);
return Ok((
AcceptanceResult::CommandFailed {
error: error_msg,
findings: tail_findings,
},
attempt_number,
command,
));
}
let parsed_result = crate::acceptance::parse_acceptance_output(&full_stdout);
let (result, passed) = match parsed_result {
crate::acceptance::AcceptanceResult::Pass => {
info!("Acceptance test passed for: {}", change.id);
output.on_info("Acceptance test: PASS");
(AcceptanceResult::Pass, true)
}
crate::acceptance::AcceptanceResult::Fail {
findings: parsed_findings,
} => {
info!("Acceptance test failed for: {}", change.id);
output.on_warn("Acceptance test: FAIL");
(
AcceptanceResult::Fail {
findings: parsed_findings,
},
false,
)
}
crate::acceptance::AcceptanceResult::Continue => {
info!("Acceptance requires continuation for: {}", change.id);
output.on_info("Acceptance test: CONTINUE");
(AcceptanceResult::Continue, false)
}
crate::acceptance::AcceptanceResult::Gated => {
info!("Acceptance gated for: {}", change.id);
output.on_warn("Acceptance test: GATED");
(AcceptanceResult::Gated, false)
}
};
let attempt_number = agent.next_acceptance_attempt_number(&change.id);
let attempt = AcceptanceAttempt {
attempt: attempt_number,
passed,
duration: start_time.elapsed(),
findings: Some(tail_findings.clone()),
exit_code: status.code(),
stdout_tail,
stderr_tail,
commit_hash: commit_hash.clone(),
};
agent.record_acceptance_attempt(&change.id, attempt);
Ok((result, attempt_number, command))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_acceptance_tail_findings_prefers_stdout() {
let findings = build_acceptance_tail_findings(
Some("stdout line 1\nstdout line 2".to_string()),
Some("stderr line".to_string()),
);
assert_eq!(findings, vec!["stdout line 1", "stdout line 2"]);
}
#[test]
fn test_build_acceptance_tail_findings_falls_back_to_stderr() {
let findings =
build_acceptance_tail_findings(Some(" ".to_string()), Some("stderr".to_string()));
assert_eq!(findings, vec!["stderr"]);
}
#[test]
fn test_build_acceptance_tail_findings_fallback_message() {
let findings = build_acceptance_tail_findings(None, Some("\n\n".to_string()));
assert_eq!(findings, vec!["No acceptance output captured"]);
}
#[test]
fn test_acceptance_result_is_pass() {
assert!(AcceptanceResult::Pass.is_pass());
assert!(!AcceptanceResult::Fail {
findings: vec!["error".to_string()]
}
.is_pass());
assert!(!AcceptanceResult::CommandFailed {
error: "test".to_string(),
findings: vec!["failure".to_string()],
}
.is_pass());
assert!(!AcceptanceResult::PermissionStalled {
blocker: crate::events::StalledBlocker::acceptance_infrastructure("permission denied"),
}
.is_pass());
assert!(!AcceptanceResult::Cancelled.is_pass());
assert!(!AcceptanceResult::Gated.is_pass());
}
#[test]
fn test_build_acceptance_tail_findings_filters_acceptance_marker() {
let findings = build_acceptance_tail_findings(
Some("line 1\nACCEPTANCE: FAIL\nline 2".to_string()),
None,
);
assert_eq!(findings, vec!["line 1", "line 2"]);
}
#[test]
fn test_build_acceptance_tail_findings_filters_findings_line() {
let findings = build_acceptance_tail_findings(
Some("error 1\nFINDINGS:\n- item 1\n- item 2".to_string()),
None,
);
assert_eq!(findings, vec!["error 1", "- item 1", "- item 2"]);
}
#[test]
fn test_build_acceptance_tail_findings_filters_both_markers() {
let findings = build_acceptance_tail_findings(
Some("ACCEPTANCE: FAIL\nFINDINGS:\nactual error\nanother line".to_string()),
None,
);
assert_eq!(findings, vec!["actual error", "another line"]);
}
#[test]
fn test_tail_findings_includes_preamble_parse_does_not() {
let stdout = "preamble\nACCEPTANCE: FAIL\nFINDINGS:\n- Finding 1\n- Finding 2\npostamble"
.to_string();
let tail = build_acceptance_tail_findings(Some(stdout.clone()), None);
assert!(tail.iter().any(|l| l.contains("preamble")));
assert!(tail.iter().any(|l| l.contains("postamble")));
assert!(tail.iter().any(|l| l.contains("Finding 1")));
match crate::acceptance::parse_acceptance_output(&stdout) {
crate::acceptance::AcceptanceResult::Fail { findings } => {
assert_eq!(findings, vec!["Finding 1", "Finding 2"]);
assert!(!findings.iter().any(|f| f.contains("preamble")));
assert!(!findings.iter().any(|f| f.contains("postamble")));
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_findings_is_preferred_source_for_fail_result() {
let stdout =
"ACCEPTANCE: FAIL\nFINDINGS:\n- src/foo.rs:10 issue A\n- src/bar.rs:5 issue B\n"
.to_string();
match crate::acceptance::parse_acceptance_output(&stdout) {
crate::acceptance::AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 2);
assert_eq!(findings[0], "src/foo.rs:10 issue A");
assert_eq!(findings[1], "src/bar.rs:5 issue B");
}
_ => panic!("Expected Fail"),
}
}
}