pub const SUPPORTED_BLOCKER_CATEGORIES: &[&str] = &[
"credential",
"external_approval",
"policy",
"external_service",
"pending_verification",
"infrastructure",
"schema_incompatibility",
"human_decision",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcceptanceBlocker {
pub category: String,
pub evidence: Vec<String>,
pub unblock_condition: String,
pub next_action: String,
pub resumable: bool,
pub prerequisite_owner: Option<String>,
pub evidence_ids: Vec<String>,
}
impl AcceptanceBlocker {
pub fn to_stalled_blocker(&self) -> crate::events::StalledBlocker {
crate::events::StalledBlocker {
category: self.category.clone(),
phase: "acceptance".to_string(),
gate: "acceptance".to_string(),
error_summary: format!(
"validated external acceptance blocker ({}): {}",
self.category,
self.evidence.join(" | ")
),
evidence: self.evidence.clone(),
unblock_condition: Some(self.unblock_condition.clone()),
prerequisite_owner: self.prerequisite_owner.clone(),
next_action: self.next_action.clone(),
resumable: self.resumable,
worktree_preserved: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockerRejection {
Missing,
NotAnObject,
MissingCategory,
UnsupportedCategory(String),
EmptyEvidence,
MissingUnblockCondition,
MissingNextAction,
MissingResumable,
}
impl BlockerRejection {
pub fn reason(&self) -> String {
match self {
BlockerRejection::Missing => {
"no structured blocker payload accompanied the gated verdict".to_string()
}
BlockerRejection::NotAnObject => "blocker payload is not a JSON object".to_string(),
BlockerRejection::MissingCategory => {
"blocker payload has no explicit category".to_string()
}
BlockerRejection::UnsupportedCategory(category) => format!(
"blocker category '{category}' is not one of: {}",
SUPPORTED_BLOCKER_CATEGORIES.join(", ")
),
BlockerRejection::EmptyEvidence => {
"blocker payload has no concrete evidence entries".to_string()
}
BlockerRejection::MissingUnblockCondition => {
"blocker payload has no verifiable unblock_condition".to_string()
}
BlockerRejection::MissingNextAction => "blocker payload has no next_action".to_string(),
BlockerRejection::MissingResumable => {
"blocker payload has no boolean resumable field".to_string()
}
}
}
}
pub fn validate_acceptance_blocker(
payload: Option<&serde_json::Value>,
) -> std::result::Result<AcceptanceBlocker, BlockerRejection> {
let Some(payload) = payload else {
return Err(BlockerRejection::Missing);
};
if payload.is_null() {
return Err(BlockerRejection::Missing);
}
let object = payload.as_object().ok_or(BlockerRejection::NotAnObject)?;
let category = object
.get("category")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(BlockerRejection::MissingCategory)?;
if !SUPPORTED_BLOCKER_CATEGORIES.contains(&category) {
return Err(BlockerRejection::UnsupportedCategory(category.to_string()));
}
let evidence = string_list(object.get("evidence"));
if evidence.is_empty() {
return Err(BlockerRejection::EmptyEvidence);
}
let unblock_condition = object
.get("unblock_condition")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(BlockerRejection::MissingUnblockCondition)?;
let next_action = object
.get("next_action")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(BlockerRejection::MissingNextAction)?;
let resumable = object
.get("resumable")
.and_then(serde_json::Value::as_bool)
.ok_or(BlockerRejection::MissingResumable)?;
Ok(AcceptanceBlocker {
category: category.to_string(),
evidence,
unblock_condition: unblock_condition.to_string(),
next_action: next_action.to_string(),
resumable,
prerequisite_owner: object
.get("prerequisite_owner")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
evidence_ids: string_list(object.get("evidence_ids")),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingSeverity {
Major,
Minor,
}
impl FindingSeverity {
pub fn as_str(self) -> &'static str {
match self {
FindingSeverity::Major => "major",
FindingSeverity::Minor => "minor",
}
}
fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"major" => Some(FindingSeverity::Major),
"minor" => Some(FindingSeverity::Minor),
_ => None,
}
}
}
impl std::fmt::Display for FindingSeverity {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FindingFileExpectation {
pub file: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryFinding {
pub id: String,
pub severity: FindingSeverity,
pub summary: String,
pub evidence: Vec<String>,
pub required_changes: Vec<FindingFileExpectation>,
pub verification: Vec<FindingFileExpectation>,
}
impl RepositoryFinding {
pub fn required_files(&self) -> Vec<String> {
self.required_changes
.iter()
.map(|change| change.file.clone())
.collect()
}
pub fn verification_files(&self) -> Vec<String> {
self.verification
.iter()
.map(|change| change.file.clone())
.collect()
}
pub fn to_json(&self) -> serde_json::Value {
let expectations = |items: &[FindingFileExpectation]| {
items
.iter()
.map(|item| serde_json::json!({"file": item.file, "description": item.description}))
.collect::<Vec<_>>()
};
serde_json::json!({
"id": self.id,
"severity": self.severity.as_str(),
"summary": self.summary,
"evidence": self.evidence,
"required_changes": expectations(&self.required_changes),
"verification": expectations(&self.verification),
})
}
pub fn to_single_line(&self) -> String {
let render = |items: &[FindingFileExpectation]| {
items
.iter()
.map(|item| format!("{} — {}", item.file, item.description))
.collect::<Vec<_>>()
.join("; ")
};
format!(
"[{}] ({}) {} | evidence: {} | required_changes: {} | verification: {}",
self.id,
self.severity,
self.summary,
self.evidence.join("; "),
render(&self.required_changes),
render(&self.verification),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FindingRejection {
NotStringOrObject,
MissingId,
DuplicateId(String),
UnsupportedSeverity(String),
MissingSummary,
EmptyEvidence,
EmptyRequiredChanges,
EmptyVerification,
InvalidPath { field: &'static str, path: String },
MissingDescription { field: &'static str, path: String },
}
impl FindingRejection {
pub fn reason(&self) -> String {
match self {
FindingRejection::NotStringOrObject => {
"finding entry is neither a legacy string nor a structured object".to_string()
}
FindingRejection::MissingId => {
"structured finding has no stable non-empty id".to_string()
}
FindingRejection::DuplicateId(id) => {
format!("structured finding id '{id}' appears more than once in one verdict")
}
FindingRejection::UnsupportedSeverity(severity) => {
format!("structured finding severity '{severity}' is not 'major' or 'minor'")
}
FindingRejection::MissingSummary => {
"structured finding has no non-empty summary".to_string()
}
FindingRejection::EmptyEvidence => {
"structured finding has no concrete evidence entries".to_string()
}
FindingRejection::EmptyRequiredChanges => {
"structured finding declares no required_changes entry".to_string()
}
FindingRejection::EmptyVerification => {
"structured finding declares no verification entry".to_string()
}
FindingRejection::InvalidPath { field, path } => format!(
"structured finding {field} path '{path}' is not a repository-relative path inside \
the workspace"
),
FindingRejection::MissingDescription { field, path } => format!(
"structured finding {field} entry for '{path}' has no description of the expected \
behavior or proof"
),
}
}
}
pub fn normalize_repository_path(raw: &str) -> Option<String> {
let candidate = raw.trim().replace('\\', "/");
if candidate.is_empty() || candidate.starts_with('/') || candidate.starts_with('~') {
return None;
}
if candidate.len() >= 2 && candidate.as_bytes()[1] == b':' {
return None;
}
let mut components = Vec::new();
for component in candidate.split('/') {
match component {
"" | "." => continue,
".." => return None,
other => components.push(other),
}
}
(!components.is_empty()).then(|| components.join("/"))
}
fn parse_expectations(
value: Option<&serde_json::Value>,
field: &'static str,
) -> std::result::Result<Vec<FindingFileExpectation>, FindingRejection> {
let entries = value
.and_then(|value| value.as_array())
.ok_or(match field {
"required_changes" => FindingRejection::EmptyRequiredChanges,
_ => FindingRejection::EmptyVerification,
})?;
let mut expectations = Vec::new();
for entry in entries {
let object = entry.as_object().ok_or(FindingRejection::InvalidPath {
field,
path: entry.to_string(),
})?;
let raw_path = object
.get("file")
.and_then(|value| value.as_str())
.unwrap_or_default();
let file =
normalize_repository_path(raw_path).ok_or_else(|| FindingRejection::InvalidPath {
field,
path: raw_path.to_string(),
})?;
let description = object
.get("description")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| FindingRejection::MissingDescription {
field,
path: file.clone(),
})?;
expectations.push(FindingFileExpectation {
file,
description: description.to_string(),
});
}
if expectations.is_empty() {
return Err(match field {
"required_changes" => FindingRejection::EmptyRequiredChanges,
_ => FindingRejection::EmptyVerification,
});
}
Ok(expectations)
}
pub fn validate_repository_finding(
value: &serde_json::Value,
) -> std::result::Result<RepositoryFinding, FindingRejection> {
let object = value
.as_object()
.ok_or(FindingRejection::NotStringOrObject)?;
let id = object
.get("id")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(FindingRejection::MissingId)?;
let raw_severity = object
.get("severity")
.and_then(|value| value.as_str())
.map(str::trim)
.unwrap_or_default();
let severity = FindingSeverity::parse(raw_severity)
.ok_or_else(|| FindingRejection::UnsupportedSeverity(raw_severity.to_string()))?;
let summary = object
.get("summary")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or(FindingRejection::MissingSummary)?;
let evidence = string_list(object.get("evidence"));
if evidence.is_empty() {
return Err(FindingRejection::EmptyEvidence);
}
Ok(RepositoryFinding {
id: id.to_string(),
severity,
summary: summary.to_string(),
evidence,
required_changes: parse_expectations(object.get("required_changes"), "required_changes")?,
verification: parse_expectations(object.get("verification"), "verification")?,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcceptanceFinding {
text: String,
structured: Option<RepositoryFinding>,
}
impl AcceptanceFinding {
pub fn legacy(text: impl Into<String>) -> Self {
Self {
text: text.into(),
structured: None,
}
}
pub fn structured(finding: RepositoryFinding) -> Self {
Self {
text: finding.to_single_line(),
structured: Some(finding),
}
}
pub fn text(&self) -> &str {
&self.text
}
pub fn structured_payload(&self) -> Option<&RepositoryFinding> {
self.structured.as_ref()
}
pub fn id(&self) -> Option<&str> {
self.structured.as_ref().map(|finding| finding.id.as_str())
}
pub fn declares_paths(&self) -> bool {
self.structured.is_some()
}
pub fn to_json(&self) -> serde_json::Value {
match &self.structured {
Some(finding) => finding.to_json(),
None => serde_json::json!({"finding": self.text}),
}
}
}
impl From<String> for AcceptanceFinding {
fn from(value: String) -> Self {
Self::legacy(value)
}
}
impl From<&str> for AcceptanceFinding {
fn from(value: &str) -> Self {
Self::legacy(value)
}
}
impl std::ops::Deref for AcceptanceFinding {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.text
}
}
impl AsRef<str> for AcceptanceFinding {
fn as_ref(&self) -> &str {
&self.text
}
}
impl std::fmt::Display for AcceptanceFinding {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.text)
}
}
impl PartialEq<str> for AcceptanceFinding {
fn eq(&self, other: &str) -> bool {
self.text == other
}
}
impl PartialEq<&str> for AcceptanceFinding {
fn eq(&self, other: &&str) -> bool {
self.text == *other
}
}
impl PartialEq<String> for AcceptanceFinding {
fn eq(&self, other: &String) -> bool {
&self.text == other
}
}
pub fn finding_texts(findings: &[AcceptanceFinding]) -> Vec<String> {
findings
.iter()
.map(|finding| finding.text().to_string())
.collect()
}
pub fn legacy_findings<I, S>(findings: I) -> Vec<AcceptanceFinding>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
findings
.into_iter()
.map(|finding| AcceptanceFinding::legacy(finding))
.collect()
}
pub fn parse_finding_entries(
entries: &[serde_json::Value],
) -> std::result::Result<Vec<AcceptanceFinding>, FindingRejection> {
let mut findings = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
for entry in entries {
if let Some(text) = entry.as_str() {
let trimmed = text.trim();
if trimmed.is_empty() {
continue;
}
findings.push(AcceptanceFinding::legacy(trimmed));
continue;
}
let structured = validate_repository_finding(entry)?;
if !seen_ids.insert(structured.id.clone()) {
return Err(FindingRejection::DuplicateId(structured.id));
}
findings.push(AcceptanceFinding::structured(structured));
}
Ok(findings)
}
fn string_list(value: Option<&serde_json::Value>) -> Vec<String> {
value
.and_then(|value| value.as_array())
.map(|entries| {
entries
.iter()
.filter_map(|entry| entry.as_str())
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptanceResult {
Pass,
Fail { findings: Vec<AcceptanceFinding> },
Continue,
MalformedFinding { rejection: FindingRejection },
BareBlocker { rejection: BlockerRejection },
Stalled { blocker: AcceptanceBlocker },
MissingVerdict,
}
pub(crate) const CANONICAL_VERDICTS: &[(&str, &str)] = &[
("ACCEPTANCE: PASS", "pass"),
("ACCEPTANCE: FAIL", "fail"),
("ACCEPTANCE: CONTINUE", "continue"),
("ACCEPTANCE: GATED", "gated"),
("ACCEPTANCE: BLOCKED", "gated"),
];
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct JsonVerdict {
pub kind: &'static str,
pub findings: Vec<serde_json::Value>,
pub blocker: Option<serde_json::Value>,
}
pub(crate) fn parse_json_verdict(text: &str) -> Option<JsonVerdict> {
let trimmed = text.trim();
if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
return None;
}
let value: serde_json::Value = serde_json::from_str(trimmed).ok()?;
let obj = value.as_object()?;
let raw_kind = obj.get("acceptance")?.as_str()?;
let kind = match raw_kind.trim().to_ascii_lowercase().as_str() {
"pass" => "pass",
"fail" => "fail",
"continue" => "continue",
"gated" => "gated",
"blocked" => "gated",
_ => return None,
};
let findings = obj
.get("findings")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
Some(JsonVerdict {
kind,
findings,
blocker: obj.get("blocker").cloned(),
})
}
pub(crate) fn canonical_verdict_kind(line: &str) -> Option<&'static str> {
let normalized = strip_markdown_decorations(line.trim());
CANONICAL_VERDICTS
.iter()
.find(|(marker, _)| normalized == *marker)
.map(|(_, kind)| *kind)
}
#[cfg(test)]
fn detect_verdict_in_line(line: &str) -> Option<&'static str> {
VerdictStreamDetector::default().detect(line)
}
#[derive(Default)]
pub(crate) struct VerdictStreamDetector {
code_fence: Option<(char, usize)>,
}
impl VerdictStreamDetector {
pub(crate) fn detect(&mut self, line: &str) -> Option<&'static str> {
if let Some(text) = crate::stream_json_textifier::extract_text_from_stream_json(line.trim())
{
for inner in text.lines() {
if let Some(kind) = self.detect_unwrapped(inner) {
return Some(kind);
}
}
return None;
}
self.detect_unwrapped(line)
}
fn detect_unwrapped(&mut self, line: &str) -> Option<&'static str> {
let trimmed = line.trim();
if let Some((marker, length, closing)) = verdict_markdown_fence(trimmed, self.code_fence) {
if closing {
self.code_fence = None;
} else if self.code_fence.is_none() {
self.code_fence = Some((marker, length));
}
return None;
}
if self.code_fence.is_some() {
return None;
}
parse_json_verdict(trimmed)
.map(|verdict| verdict.kind)
.or_else(|| canonical_verdict_kind(trimmed))
}
}
fn verdict_markdown_fence(line: &str, open: Option<(char, usize)>) -> Option<(char, usize, bool)> {
let marker = line.chars().next()?;
if marker != '`' && marker != '~' {
return None;
}
let length = line
.chars()
.take_while(|character| *character == marker)
.count();
if length < 3 {
return None;
}
let remainder = &line[length..];
let closing = open.is_some_and(|(open_marker, open_length)| {
marker == open_marker && length >= open_length && remainder.trim().is_empty()
});
Some((marker, length, closing))
}
pub fn parse_acceptance_output(output: &str) -> AcceptanceResult {
let mut fallback_kind: Option<&'static str> = None;
let mut fallback_findings = Vec::new();
let mut collecting_findings = false;
let mut detector = VerdictStreamDetector::default();
for raw_line in output.lines() {
let unwrapped = crate::stream_json_textifier::extract_text_from_stream_json(raw_line)
.unwrap_or_else(|| raw_line.to_string());
for line in unwrapped.lines() {
let candidate = line.trim();
let fence = verdict_markdown_fence(candidate, detector.code_fence);
let detected = detector.detect_unwrapped(candidate);
if fence.is_some() || detector.code_fence.is_some() {
continue;
}
if let Some(verdict) = parse_json_verdict(candidate) {
return acceptance_result_from_json(verdict);
}
if fallback_kind.is_none() {
if let Some(kind) = detected {
fallback_kind = Some(kind);
collecting_findings = false;
}
continue;
}
if fallback_kind == Some("fail") {
if candidate == "FINDINGS:" {
collecting_findings = true;
} else if collecting_findings {
if let Some(finding) = candidate.strip_prefix("- ") {
fallback_findings.push(finding.to_string());
} else if !candidate.is_empty() {
collecting_findings = false;
}
}
}
}
}
match fallback_kind {
Some("pass") => AcceptanceResult::Pass,
Some("continue") => AcceptanceResult::Continue,
Some("gated") => AcceptanceResult::BareBlocker {
rejection: BlockerRejection::Missing,
},
Some("fail") => AcceptanceResult::Fail {
findings: legacy_findings(fallback_findings),
},
_ => AcceptanceResult::MissingVerdict,
}
}
fn acceptance_result_from_json(verdict: JsonVerdict) -> AcceptanceResult {
match verdict.kind {
"pass" => AcceptanceResult::Pass,
"fail" => match parse_finding_entries(&verdict.findings) {
Ok(findings) => AcceptanceResult::Fail { findings },
Err(rejection) => AcceptanceResult::MalformedFinding { rejection },
},
"continue" => AcceptanceResult::Continue,
"gated" => match validate_acceptance_blocker(verdict.blocker.as_ref()) {
Ok(blocker) => AcceptanceResult::Stalled { blocker },
Err(rejection) => AcceptanceResult::BareBlocker { rejection },
},
_ => AcceptanceResult::MissingVerdict,
}
}
pub(crate) fn strip_markdown_decorations(text: &str) -> String {
let mut s = text.to_string();
s = s.replace("**", "");
s = s.replace(['*', '_'], "");
let trimmed = s.trim();
let trimmed = trimmed.trim_start_matches('#');
let trimmed = trimmed.trim_start_matches('>');
let trimmed = trimmed.trim_start_matches('-');
trimmed.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_pass() {
let output = "ACCEPTANCE: PASS\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn acceptance_append_prompt_text_is_not_parsed_as_output_verdict() {
let prompt = crate::agent::append_optional_prompt(
"generated acceptance prompt".to_string(),
Some("optional guidance mentioning ACCEPTANCE: FAIL and {change_id}"),
);
assert!(prompt.ends_with("optional guidance mentioning ACCEPTANCE: FAIL and {change_id}"));
assert_eq!(
parse_acceptance_output("ACCEPTANCE: PASS\n"),
AcceptanceResult::Pass
);
}
#[test]
fn test_parse_pass_with_extra_output() {
let output = "Some debug output\nACCEPTANCE: PASS\nMore output\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_fail_with_findings() {
let output = "ACCEPTANCE: FAIL\nFINDINGS:\n- Issue 1\n- Issue 2\n";
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 2);
assert_eq!(findings[0], "Issue 1");
assert_eq!(findings[1], "Issue 2");
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_fail_with_no_findings() {
let output = "ACCEPTANCE: FAIL\n";
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 0);
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_fail_with_multiline_findings() {
let output = r#"ACCEPTANCE: FAIL
FINDINGS:
- Task 1.3 is not completed
- Missing unit tests for new feature
- Code does not handle error case X
"#;
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 3);
assert_eq!(findings[0], "Task 1.3 is not completed");
assert_eq!(findings[1], "Missing unit tests for new feature");
assert_eq!(findings[2], "Code does not handle error case X");
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_no_status() {
let output = "Some random output\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_no_marker_is_missing_verdict() {
assert_eq!(
parse_acceptance_output(""),
AcceptanceResult::MissingVerdict
);
let output = "Some debug output\nNo marker here\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
let output = "FINDINGS:\n- Issue 1\n- Issue 2\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_status_only_waiting_output_is_missing_verdict_not_continue() {
let output = "Started long-running verification job.\n\
Monitoring verification; I will evaluate the evidence and \
emit the final verdict once the completion notification arrives.\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
assert_ne!(
parse_acceptance_output(output),
AcceptanceResult::Continue,
"status-only exit must remain distinguishable from explicit CONTINUE"
);
}
#[test]
fn acceptance_parser_routing_matrix_is_stable() {
let cases: [(&str, AcceptanceResult); 10] = [
("{\"acceptance\":\"pass\"}\n", AcceptanceResult::Pass),
("ACCEPTANCE: PASS\n", AcceptanceResult::Pass),
(
"{\"acceptance\":\"fail\",\"findings\":[\"src/a.rs:1 issue\"]}\n",
AcceptanceResult::Fail {
findings: vec!["src/a.rs:1 issue".to_string().into()],
},
),
(
"ACCEPTANCE: FAIL\nFINDINGS:\n- src/a.rs:1 issue\n",
AcceptanceResult::Fail {
findings: vec!["src/a.rs:1 issue".to_string().into()],
},
),
(
"{\"acceptance\":\"continue\"}\n",
AcceptanceResult::Continue,
),
("ACCEPTANCE: CONTINUE\n", AcceptanceResult::Continue),
(
"{\"acceptance\":\"gated\"}\n",
AcceptanceResult::BareBlocker {
rejection: BlockerRejection::Missing,
},
),
(
"ACCEPTANCE: BLOCKED\n",
AcceptanceResult::BareBlocker {
rejection: BlockerRejection::Missing,
},
),
(
"Monitoring verification; waiting for the job to finish\n",
AcceptanceResult::MissingVerdict,
),
(
"ACCEPTANCE: PASSAll criteria verified\n",
AcceptanceResult::MissingVerdict,
),
];
for (output, expected) in cases {
assert_eq!(
parse_acceptance_output(output),
expected,
"routing drifted for output: {output:?}"
);
}
}
#[test]
fn test_explicit_continue_remains_distinct_from_missing_verdict() {
assert_eq!(
parse_acceptance_output("{\"acceptance\":\"continue\"}\n"),
AcceptanceResult::Continue
);
assert_eq!(
parse_acceptance_output("ACCEPTANCE: CONTINUE\n"),
AcceptanceResult::Continue
);
assert_eq!(
parse_acceptance_output("waiting for checks to finish\n"),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_findings_with_trailing_content() {
let output = r#"ACCEPTANCE: FAIL
FINDINGS:
- Issue 1
- Issue 2
Additional output here
"#;
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 2);
assert_eq!(findings[0], "Issue 1");
assert_eq!(findings[1], "Issue 2");
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_continue() {
let output = "ACCEPTANCE: CONTINUE\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Continue);
}
#[test]
fn test_parse_continue_with_extra_output() {
let output = "Some debug output\nACCEPTANCE: CONTINUE\nMore output\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Continue);
}
#[test]
fn test_parse_pass_with_bold_decoration() {
let output = "**ACCEPTANCE: PASS**\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_pass_with_bold_decoration_and_extra_output() {
let output = "Some debug output\n**ACCEPTANCE: PASS**\nMore output\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_fail_with_bold_decoration() {
let output = "**ACCEPTANCE: FAIL**\nFINDINGS:\n- Issue 1\n- Issue 2\n";
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 2);
assert_eq!(findings[0], "Issue 1");
assert_eq!(findings[1], "Issue 2");
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_continue_with_bold_decoration() {
let output = "**ACCEPTANCE: CONTINUE**\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Continue);
}
#[test]
fn test_parse_pass_with_italic_decoration() {
let output = "*ACCEPTANCE: PASS*\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_pass_with_mixed_decorations() {
let output = "**_ACCEPTANCE: PASS_**\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_strip_markdown_decorations() {
assert_eq!(
strip_markdown_decorations("**ACCEPTANCE: PASS**"),
"ACCEPTANCE: PASS"
);
assert_eq!(
strip_markdown_decorations("*ACCEPTANCE: PASS*"),
"ACCEPTANCE: PASS"
);
assert_eq!(
strip_markdown_decorations("_ACCEPTANCE: PASS_"),
"ACCEPTANCE: PASS"
);
assert_eq!(
strip_markdown_decorations("**_ACCEPTANCE: PASS_**"),
"ACCEPTANCE: PASS"
);
assert_eq!(
strip_markdown_decorations("ACCEPTANCE: PASS"),
"ACCEPTANCE: PASS"
);
}
#[test]
fn test_parse_ignores_acceptance_in_code_blocks() {
let output = r#"
Example output:
```
ACCEPTANCE: FAIL
FINDINGS:
- Issue 1
```
Actual result:
ACCEPTANCE: PASS
"#;
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_ignores_multiple_code_blocks() {
let output = r#"
First example:
```
ACCEPTANCE: FAIL
```
Second example:
```
ACCEPTANCE: CONTINUE
```
Actual result:
ACCEPTANCE: PASS
"#;
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_code_block_with_language_specifier() {
let output = r#"
Example:
```bash
ACCEPTANCE: FAIL
```
Result:
ACCEPTANCE: PASS
"#;
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_ignores_tilde_fenced_verdicts() {
let output =
"~~~json\n{\"acceptance\":\"pass\"}\n~~~\nACCEPTANCE: FAIL\nFINDINGS:\n- real failure";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::Fail {
findings: vec!["real failure".to_string().into()]
}
);
}
#[test]
fn test_parse_does_not_close_fence_with_info_string() {
let output = "```text\n```json\n{\"acceptance\":\"pass\"}\nACCEPTANCE: FAIL";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn verdict_stream_detector_ignores_fenced_verdicts() {
let mut detector = VerdictStreamDetector::default();
assert_eq!(detector.detect("~~~json"), None);
assert_eq!(detector.detect(r#"{\"acceptance\":\"pass\"}"#), None);
assert_eq!(detector.detect("~~~"), None);
assert_eq!(detector.detect("ACCEPTANCE: FAIL"), Some("fail"));
let mut detector = VerdictStreamDetector::default();
assert_eq!(detector.detect("```json"), None);
assert_eq!(detector.detect(r#"{"acceptance":"pass"}"#), None);
assert_eq!(detector.detect("```"), None);
}
#[test]
fn test_parse_unclosed_code_block() {
let output = r#"
Example:
```
ACCEPTANCE: FAIL
ACCEPTANCE: PASS
"#;
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_pass_with_trailing_text_is_not_canonical() {
let output = "ACCEPTANCE: PASSAll acceptance criteria verified:\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_pass_with_trailing_heading_is_not_canonical() {
let output = "ACCEPTANCE: PASS## Acceptance Review Summary\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_pass_with_trailing_text_in_context_is_not_canonical() {
let output = r#"Some prior output
ACCEPTANCE: PASSAll acceptance criteria verified:
1. Git working tree is clean
"#;
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_pass_with_trailing_text_falls_through_to_canonical_pass() {
let output = "ACCEPTANCE: PASSAll bad form\nACCEPTANCE: PASS\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_fail_with_trailing_text_is_not_canonical() {
let output = "ACCEPTANCE: FAILSome additional context\nFINDINGS:\n- Issue 1\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_continue_with_trailing_text_is_not_canonical() {
let output = "ACCEPTANCE: CONTINUENeeds further investigation\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_blocked_with_trailing_text_is_not_canonical() {
let output = "ACCEPTANCE: BLOCKEDWaiting for dependency\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_parse_passed_word_boundary_is_not_canonical() {
let output = "ACCEPTANCE: PASSED\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict
);
}
#[test]
fn test_canonical_verdict_kind_strict_match() {
assert_eq!(canonical_verdict_kind("ACCEPTANCE: PASS"), Some("pass"));
assert_eq!(canonical_verdict_kind("**ACCEPTANCE: PASS**"), Some("pass"));
assert_eq!(canonical_verdict_kind("## ACCEPTANCE: PASS"), Some("pass"));
assert_eq!(canonical_verdict_kind("> ACCEPTANCE: FAIL"), Some("fail"));
assert_eq!(canonical_verdict_kind("ACCEPTANCE: PASSAll bad"), None);
assert_eq!(canonical_verdict_kind("ACCEPTANCE: PASS## heading"), None);
assert_eq!(canonical_verdict_kind("ACCEPTANCE: PASSED"), None);
assert_eq!(canonical_verdict_kind("not a verdict"), None);
}
#[test]
fn test_parse_blocked() {
let output = "ACCEPTANCE: BLOCKED\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::BareBlocker {
rejection: BlockerRejection::Missing
}
);
}
#[test]
fn test_parse_blocked_with_extra_output() {
let output = "Some debug output\nACCEPTANCE: BLOCKED\nMore output\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::BareBlocker {
rejection: BlockerRejection::Missing
}
);
}
#[test]
fn test_parse_blocked_with_bold_decoration() {
let output = "**ACCEPTANCE: BLOCKED**\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::BareBlocker {
rejection: BlockerRejection::Missing
}
);
}
#[test]
fn test_parse_fail_findings_excludes_preamble() {
let output =
"preamble line\nACCEPTANCE: FAIL\nFINDINGS:\n- Finding 1\n- Finding 2\npostamble";
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings, vec!["Finding 1", "Finding 2"]);
assert!(!findings.iter().any(|f| f.contains("preamble")));
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_fail_findings_from_findings_section_only() {
let output =
"ACCEPTANCE: FAIL\nFINDINGS:\n- src/foo.rs:10 missing test\n- src/bar.rs:5 dead code\n";
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 2);
assert_eq!(findings[0], "src/foo.rs:10 missing test");
assert_eq!(findings[1], "src/bar.rs:5 dead code");
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_pass_with_heading_prefix() {
let output = "## ACCEPTANCE: PASS\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_pass_with_heading_h3_prefix() {
let output = "### ACCEPTANCE: PASS\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_fail_with_heading_prefix() {
let output = "## ACCEPTANCE: FAIL\nFINDINGS:\n- Issue 1\n";
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 1);
assert_eq!(findings[0], "Issue 1");
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_pass_with_blockquote_prefix() {
let output = "> ACCEPTANCE: PASS\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_fail_with_blockquote_prefix() {
let output = "> ACCEPTANCE: FAIL\nFINDINGS:\n- Issue 1\n";
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings.len(), 1);
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_parse_pass_with_bullet_prefix() {
let output = "- ACCEPTANCE: PASS\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_continue_with_heading_prefix() {
let output = "## ACCEPTANCE: CONTINUE\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Continue);
}
#[test]
fn test_parse_blocked_with_heading_prefix() {
let output = "## ACCEPTANCE: BLOCKED\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::BareBlocker {
rejection: BlockerRejection::Missing
}
);
}
#[test]
fn test_parse_pass_with_heading_and_bold() {
let output = "## **ACCEPTANCE: PASS**\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_strip_markdown_decorations_heading() {
assert_eq!(
strip_markdown_decorations("## ACCEPTANCE: PASS"),
"ACCEPTANCE: PASS"
);
}
#[test]
fn test_strip_markdown_decorations_blockquote() {
assert_eq!(
strip_markdown_decorations("> ACCEPTANCE: PASS"),
"ACCEPTANCE: PASS"
);
}
#[test]
fn test_strip_markdown_decorations_bullet() {
assert_eq!(
strip_markdown_decorations("- ACCEPTANCE: PASS"),
"ACCEPTANCE: PASS"
);
}
#[test]
fn test_strip_markdown_decorations_heading_and_bold() {
assert_eq!(
strip_markdown_decorations("## **ACCEPTANCE: PASS**"),
"ACCEPTANCE: PASS"
);
}
#[test]
fn test_parse_fail_empty_findings_when_no_section() {
let output = "ACCEPTANCE: FAIL\nSome explanation without a FINDINGS: header\n";
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert!(findings.is_empty());
}
_ => panic!("Expected Fail"),
}
}
#[test]
fn test_marker_detection_consistency_with_parser() {
let drift_cases: &[(&str, &str)] = &[
("ACCEPTANCE: PASS", "pass"),
("**ACCEPTANCE: PASS**", "pass"),
("## ACCEPTANCE: PASS", "pass"),
("> ACCEPTANCE: PASS", "pass"),
("- ACCEPTANCE: PASS", "pass"),
("### **ACCEPTANCE: FAIL**", "fail"),
("ACCEPTANCE: CONTINUE", "continue"),
("ACCEPTANCE: GATED", "gated"),
("ACCEPTANCE: BLOCKED", "gated"),
("## ACCEPTANCE: BLOCKED", "gated"),
("> ACCEPTANCE: FAIL", "fail"),
];
for (case, expected_kind) in drift_cases {
assert_eq!(
canonical_verdict_kind(case),
Some(*expected_kind),
"canonical_verdict_kind must detect '{}' as kind '{}'",
case,
expected_kind
);
let full_output = format!("{}\n", case);
let result = parse_acceptance_output(&full_output);
let result_kind = match &result {
AcceptanceResult::Pass => "pass",
AcceptanceResult::Fail { .. } => "fail",
AcceptanceResult::Continue => "continue",
AcceptanceResult::BareBlocker { .. } => "gated",
AcceptanceResult::Stalled { .. } => "stalled",
AcceptanceResult::MalformedFinding { .. } => "malformed-finding",
AcceptanceResult::MissingVerdict => "missing-verdict",
};
assert_eq!(
result_kind, *expected_kind,
"parse_acceptance_output returned '{}' but expected '{}' for input '{}'",
result_kind, expected_kind, case
);
}
}
#[test]
fn test_marker_detection_rejects_trailing_text_uniformly() {
let malformed: &[&str] = &[
"ACCEPTANCE: PASSAll checks completed",
"ACCEPTANCE: PASS## Acceptance Review Summary",
"ACCEPTANCE: PASSED",
"ACCEPTANCE: FAILSome explanation",
"ACCEPTANCE: CONTINUEMore work",
"ACCEPTANCE: BLOCKEDWaiting",
];
for case in malformed {
assert!(
canonical_verdict_kind(case).is_none(),
"canonical_verdict_kind must NOT detect malformed verdict '{}'",
case
);
}
}
#[test]
fn test_code_fence_markers_rejected_by_both_parser_and_detection() {
let output = "```\nACCEPTANCE: PASS\n```\n";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::MissingVerdict,
"Parser must not match markers inside code fences"
);
}
fn verdict_kind(line: &str) -> Option<&'static str> {
parse_json_verdict(line).map(|verdict| verdict.kind)
}
#[test]
fn test_parse_json_verdict_pass() {
let verdict = parse_json_verdict(r#"{"acceptance":"pass"}"#).expect("strict JSON verdict");
assert_eq!(verdict.kind, "pass");
assert!(verdict.findings.is_empty());
assert_eq!(verdict.blocker, None);
}
#[test]
fn test_parse_json_verdict_fail_with_findings() {
let line = r#"{"acceptance":"fail","findings":["src/a.rs:1 bad","src/b.rs:2 worse"]}"#;
let verdict = parse_json_verdict(line).expect("strict JSON verdict");
assert_eq!(verdict.kind, "fail");
assert_eq!(verdict.findings, vec!["src/a.rs:1 bad", "src/b.rs:2 worse"]);
}
#[test]
fn test_parse_json_verdict_continue_gated_and_legacy_blocked() {
assert_eq!(
verdict_kind(r#"{"acceptance":"continue"}"#),
Some("continue")
);
assert_eq!(verdict_kind(r#"{"acceptance":"gated"}"#), Some("gated"));
assert_eq!(verdict_kind(r#"{"acceptance":"blocked"}"#), Some("gated"));
}
#[test]
fn test_parse_json_verdict_case_insensitive_value() {
assert_eq!(verdict_kind(r#"{"acceptance":"PASS"}"#), Some("pass"));
assert_eq!(verdict_kind(r#"{"acceptance":"Fail"}"#), Some("fail"));
}
#[test]
fn test_parse_json_verdict_rejects_non_object_and_unknown_kind() {
assert_eq!(verdict_kind("pass"), None);
assert_eq!(verdict_kind(r#"["pass"]"#), None);
assert_eq!(verdict_kind(r#"{"acceptance":"maybe"}"#), None);
assert_eq!(verdict_kind(r#"{"other":"pass"}"#), None);
assert_eq!(verdict_kind("not json"), None);
assert_eq!(verdict_kind(""), None);
}
#[test]
fn test_parse_acceptance_output_json_pass_single_line() {
let output = r#"{"acceptance":"pass"}
"#;
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_acceptance_output_json_fail_findings_preferred_over_text_section() {
let output = r#"preamble
{"acceptance":"fail","findings":["x","y"]}
"#;
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings, vec!["x", "y"]);
}
other => panic!("expected Fail, got {:?}", other),
}
}
#[test]
fn test_parse_acceptance_output_json_beats_text_fallback_regardless_of_order() {
let output = "ACCEPTANCE: CONTINUE\n{\"acceptance\":\"pass\"}\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_acceptance_output_text_fallback_when_no_json() {
let output = "ACCEPTANCE: PASS\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_acceptance_output_json_inside_agent_assistant_event() {
let event = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"{\"acceptance\":\"pass\"}"}]}}"#;
let output = format!("{}\n", event);
assert_eq!(parse_acceptance_output(&output), AcceptanceResult::Pass);
}
#[test]
fn test_parse_acceptance_output_ignores_fenced_verdict_inside_agent_event() {
let event = serde_json::json!({
"type": "assistant",
"message": {
"role": "assistant",
"content": [{
"type": "text",
"text": "~~~json\n{\"acceptance\":\"pass\"}\n~~~\nACCEPTANCE: FAIL\nFINDINGS:\n- real"
}]
}
})
.to_string();
assert_eq!(
parse_acceptance_output(&event),
AcceptanceResult::Fail {
findings: vec!["real".to_string().into()]
}
);
}
#[test]
fn legacy_fail_ignores_findings_before_verdict_and_inside_fences() {
let output = "FINDINGS:\n- stale\n```text\nFINDINGS:\n- injected\n```\nACCEPTANCE: FAIL\nFINDINGS:\n- canonical";
assert_eq!(
parse_acceptance_output(output),
AcceptanceResult::Fail {
findings: vec!["canonical".to_string().into()]
}
);
}
#[test]
fn test_parse_acceptance_output_json_inside_agent_result_event() {
let event = r#"{"type":"result","subtype":"success","result":"{\"acceptance\":\"fail\",\"findings\":[\"a\"]}","is_error":false}"#;
let output = format!("{}\n", event);
match parse_acceptance_output(&output) {
AcceptanceResult::Fail { findings } => {
assert_eq!(findings, vec!["a".to_string()]);
}
other => panic!("expected Fail, got {:?}", other),
}
}
#[test]
fn test_parse_acceptance_output_json_inside_codex_item_completed_event() {
let event = r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"{\"acceptance\":\"pass\"}"}}"#;
let output = format!("{}\n", event);
assert_eq!(parse_acceptance_output(&output), AcceptanceResult::Pass);
}
#[test]
fn test_detect_verdict_in_line_json_direct() {
assert_eq!(
detect_verdict_in_line(r#"{"acceptance":"pass"}"#),
Some("pass")
);
assert_eq!(
detect_verdict_in_line(r#"{"acceptance":"gated"}"#),
Some("gated")
);
assert_eq!(
detect_verdict_in_line(r#"{"acceptance":"blocked"}"#),
Some("gated")
);
}
#[test]
fn test_detect_verdict_in_line_json_inside_assistant_event() {
let event = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"{\"acceptance\":\"pass\"}"}]}}"#;
assert_eq!(detect_verdict_in_line(event), Some("pass"));
}
#[test]
fn test_detect_verdict_in_line_text_fallback() {
assert_eq!(detect_verdict_in_line("ACCEPTANCE: PASS"), Some("pass"));
assert_eq!(detect_verdict_in_line("**ACCEPTANCE: FAIL**"), Some("fail"));
assert_eq!(
detect_verdict_in_line("ACCEPTANCE: PASSAll checks done"),
None,
"trailing-text PASS must not satisfy even the text fallback"
);
}
#[test]
fn test_detect_verdict_in_line_text_inside_assistant_event() {
let event = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"ACCEPTANCE: PASS"}]}}"#;
assert_eq!(detect_verdict_in_line(event), Some("pass"));
}
#[test]
fn test_detect_verdict_in_line_unrelated_events_return_none() {
assert_eq!(
detect_verdict_in_line(r#"{"type":"system","subtype":"init"}"#),
None
);
assert_eq!(detect_verdict_in_line("plain log line"), None);
assert_eq!(detect_verdict_in_line(""), None);
}
fn structured_gated(blocker: &str) -> String {
format!("{{\"acceptance\":\"gated\",\"blocker\":{blocker}}}\n")
}
const VALID_BLOCKER: &str = r#"{"category":"credential","evidence":["STAGING_API_KEY unset in the verification environment"],"unblock_condition":"STAGING_API_KEY is present in the verification environment","next_action":"provision STAGING_API_KEY then retry acceptance","resumable":true}"#;
#[test]
fn structured_blocker_becomes_validated_stall_with_explicit_category() {
match parse_acceptance_output(&structured_gated(VALID_BLOCKER)) {
AcceptanceResult::Stalled { blocker } => {
assert_eq!(blocker.category, "credential");
assert_eq!(
blocker.evidence,
["STAGING_API_KEY unset in the verification environment"]
);
assert_eq!(
blocker.unblock_condition,
"STAGING_API_KEY is present in the verification environment"
);
assert_eq!(
blocker.next_action,
"provision STAGING_API_KEY then retry acceptance"
);
assert!(blocker.resumable);
assert_eq!(blocker.prerequisite_owner, None);
assert!(blocker.evidence_ids.is_empty());
}
other => panic!("expected validated stall, got {other:?}"),
}
}
#[test]
fn structured_blocker_preserves_optional_owner_and_evidence_ids() {
let blocker = r#"{"category":"external_approval","evidence":["change board ticket CB-42 is awaiting sign-off"],"unblock_condition":"CB-42 is approved","next_action":"await CB-42 approval then retry acceptance","resumable":true,"prerequisite_owner":"release-management","evidence_ids":["CB-42",""," "]}"#;
match parse_acceptance_output(&structured_gated(blocker)) {
AcceptanceResult::Stalled { blocker } => {
assert_eq!(blocker.category, "external_approval");
assert_eq!(
blocker.prerequisite_owner.as_deref(),
Some("release-management")
);
assert_eq!(blocker.evidence_ids, ["CB-42"]);
}
other => panic!("expected validated stall, got {other:?}"),
}
}
#[test]
fn every_supported_category_is_accepted_verbatim() {
for category in SUPPORTED_BLOCKER_CATEGORIES {
let blocker = format!(
r#"{{"category":"{category}","evidence":["concrete evidence"],"unblock_condition":"the prerequisite is satisfied","next_action":"resolve then retry","resumable":false}}"#
);
match parse_acceptance_output(&structured_gated(&blocker)) {
AcceptanceResult::Stalled { blocker } => {
assert_eq!(&blocker.category, category);
assert!(!blocker.resumable);
}
other => panic!("category {category} must validate, got {other:?}"),
}
}
}
#[test]
fn invalid_blocker_payloads_fall_back_to_bare_protocol_error() {
let cases: &[(&str, BlockerRejection)] = &[
(r#"null"#, BlockerRejection::Missing),
(r#""credential""#, BlockerRejection::NotAnObject),
(r#"["credential"]"#, BlockerRejection::NotAnObject),
(
r#"{"evidence":["e"],"unblock_condition":"c","next_action":"a","resumable":true}"#,
BlockerRejection::MissingCategory,
),
(
r#"{"category":" ","evidence":["e"],"unblock_condition":"c","next_action":"a","resumable":true}"#,
BlockerRejection::MissingCategory,
),
(
r#"{"category":"flaky_test","evidence":["e"],"unblock_condition":"c","next_action":"a","resumable":true}"#,
BlockerRejection::UnsupportedCategory("flaky_test".to_string()),
),
(
r#"{"category":"credential","evidence":[],"unblock_condition":"c","next_action":"a","resumable":true}"#,
BlockerRejection::EmptyEvidence,
),
(
r#"{"category":"credential","evidence":[" "],"unblock_condition":"c","next_action":"a","resumable":true}"#,
BlockerRejection::EmptyEvidence,
),
(
r#"{"category":"credential","unblock_condition":"c","next_action":"a","resumable":true}"#,
BlockerRejection::EmptyEvidence,
),
(
r#"{"category":"credential","evidence":["e"],"next_action":"a","resumable":true}"#,
BlockerRejection::MissingUnblockCondition,
),
(
r#"{"category":"credential","evidence":["e"],"unblock_condition":" ","next_action":"a","resumable":true}"#,
BlockerRejection::MissingUnblockCondition,
),
(
r#"{"category":"credential","evidence":["e"],"unblock_condition":"c","resumable":true}"#,
BlockerRejection::MissingNextAction,
),
(
r#"{"category":"credential","evidence":["e"],"unblock_condition":"c","next_action":" ","resumable":true}"#,
BlockerRejection::MissingNextAction,
),
(
r#"{"category":"credential","evidence":["e"],"unblock_condition":"c","next_action":"a"}"#,
BlockerRejection::MissingResumable,
),
(
r#"{"category":"credential","evidence":["e"],"unblock_condition":"c","next_action":"a","resumable":"yes"}"#,
BlockerRejection::MissingResumable,
),
];
for (payload, expected) in cases {
assert_eq!(
parse_acceptance_output(&structured_gated(payload)),
AcceptanceResult::BareBlocker {
rejection: expected.clone()
},
"payload must not become a stall: {payload}"
);
}
}
#[test]
fn credential_prose_never_infers_a_blocker_category() {
let outputs = [
"{\"acceptance\":\"gated\",\"findings\":[\"missing credential token for auth\"]}\n"
.to_string(),
structured_gated(
r#"{"evidence":["missing credential token for auth"],"next_action":"provision it","resumable":true}"#,
),
"ACCEPTANCE: GATED\nThe deploy credential token could not be read (auth failure)\n"
.to_string(),
];
for output in outputs {
match parse_acceptance_output(&output) {
AcceptanceResult::BareBlocker { .. } => {}
other => panic!("prose must not create a category, got {other:?}"),
}
}
}
#[test]
fn bare_and_legacy_blocker_inputs_share_the_protocol_error_path() {
let bare = AcceptanceResult::BareBlocker {
rejection: BlockerRejection::Missing,
};
for output in [
"{\"acceptance\":\"gated\"}\n",
"{\"acceptance\":\"blocked\"}\n",
"ACCEPTANCE: GATED\n",
"ACCEPTANCE: BLOCKED\n",
"**ACCEPTANCE: GATED**\n",
] {
assert_eq!(
parse_acceptance_output(output),
bare,
"drifted for {output:?}"
);
}
}
#[test]
fn blocker_rejection_reasons_are_operator_readable() {
assert!(BlockerRejection::Missing
.reason()
.contains("no structured blocker"));
assert!(BlockerRejection::UnsupportedCategory("nope".to_string())
.reason()
.contains("credential"));
assert!(BlockerRejection::EmptyEvidence
.reason()
.contains("evidence"));
assert!(BlockerRejection::MissingNextAction
.reason()
.contains("next_action"));
assert!(BlockerRejection::MissingResumable
.reason()
.contains("resumable"));
assert!(BlockerRejection::NotAnObject
.reason()
.contains("JSON object"));
assert!(BlockerRejection::MissingCategory
.reason()
.contains("category"));
}
#[test]
fn structured_blocker_survives_agent_event_wrapping() {
let text = format!("{{\"acceptance\":\"gated\",\"blocker\":{VALID_BLOCKER}}}");
let event = serde_json::json!({
"type": "assistant",
"message": {"role": "assistant", "content": [{"type": "text", "text": text}]}
})
.to_string();
assert!(matches!(
parse_acceptance_output(&event),
AcceptanceResult::Stalled { .. }
));
}
#[test]
fn test_regression_malformed_text_then_json_pass() {
let output = "ACCEPTANCE: PASS# Acceptance Review Summary\n{\"acceptance\":\"pass\"}\n";
assert_eq!(parse_acceptance_output(output), AcceptanceResult::Pass);
}
fn secret_value_finding_json() -> String {
serde_json::json!({
"acceptance": "fail",
"findings": [{
"id": "acceptance-secret-value-scan",
"severity": "minor",
"summary": "Challenge and proof leakage is not tested by value",
"evidence": ["tests/support/relay.ts exposes counts but not issued values"],
"required_changes": [{
"file": "tests/support/relay.ts",
"description": "Expose issued challenge and presented proof values to tests"
}],
"verification": [{
"file": "runtime/recovery.integration.test.ts",
"description": "Assert recorded values are absent from serialized audit output"
}]
}]
})
.to_string()
}
fn fail_findings(output: &str) -> Vec<AcceptanceFinding> {
match parse_acceptance_output(output) {
AcceptanceResult::Fail { findings } => findings,
other => panic!("expected Fail, got {other:?}"),
}
}
fn rejection(output: &str) -> FindingRejection {
match parse_acceptance_output(output) {
AcceptanceResult::MalformedFinding { rejection } => rejection,
other => panic!("expected MalformedFinding, got {other:?}"),
}
}
#[test]
fn acceptance_structured_finding_reaches_callers_without_lossy_conversion() {
let findings = fail_findings(&secret_value_finding_json());
assert_eq!(findings.len(), 1);
let structured = findings[0]
.structured_payload()
.expect("structured payload must survive parsing");
assert_eq!(structured.id, "acceptance-secret-value-scan");
assert_eq!(structured.severity, FindingSeverity::Minor);
assert_eq!(
structured.summary,
"Challenge and proof leakage is not tested by value"
);
assert_eq!(
structured.evidence,
["tests/support/relay.ts exposes counts but not issued values"]
);
assert_eq!(structured.required_files(), ["tests/support/relay.ts"]);
assert_eq!(
structured.verification_files(),
["runtime/recovery.integration.test.ts"]
);
let text = findings[0].text();
assert!(text.contains("acceptance-secret-value-scan"), "{text}");
assert!(
text.contains("exposes counts but not issued values"),
"{text}"
);
assert!(text.contains("tests/support/relay.ts"), "{text}");
assert!(
text.contains("runtime/recovery.integration.test.ts"),
"{text}"
);
}
#[test]
fn acceptance_structured_finding_accepts_both_severities_and_both_block_pass() {
for severity in ["major", "minor"] {
let output = serde_json::json!({
"acceptance": "fail",
"findings": [{
"id": format!("id-{severity}"),
"severity": severity,
"summary": "s",
"evidence": ["e"],
"required_changes": [{"file": "src/a.rs", "description": "d"}],
"verification": [{"file": "tests/a.rs", "description": "d"}]
}]
})
.to_string();
let result = parse_acceptance_output(&output);
assert!(
matches!(result, AcceptanceResult::Fail { .. }),
"{severity} must block PASS, got {result:?}"
);
}
}
#[test]
fn acceptance_structured_finding_rejection_matrix_is_bounded_protocol_handling() {
let base = serde_json::json!({
"id": "stable-id",
"severity": "major",
"summary": "s",
"evidence": ["e"],
"required_changes": [{"file": "src/a.rs", "description": "d"}],
"verification": [{"file": "tests/a.rs", "description": "d"}]
});
let mutate = |key: &str, value: serde_json::Value| {
let mut object = base.as_object().unwrap().clone();
if value.is_null() {
object.remove(key);
} else {
object.insert(key.to_string(), value);
}
serde_json::json!({"acceptance": "fail", "findings": [object]}).to_string()
};
let cases: Vec<(String, FindingRejection)> = vec![
(
mutate("id", serde_json::Value::Null),
FindingRejection::MissingId,
),
(
mutate("id", serde_json::json!(" ")),
FindingRejection::MissingId,
),
(
mutate("severity", serde_json::json!("blocker")),
FindingRejection::UnsupportedSeverity("blocker".to_string()),
),
(
mutate("summary", serde_json::Value::Null),
FindingRejection::MissingSummary,
),
(
mutate("evidence", serde_json::json!([])),
FindingRejection::EmptyEvidence,
),
(
mutate("required_changes", serde_json::Value::Null),
FindingRejection::EmptyRequiredChanges,
),
(
mutate("verification", serde_json::json!([])),
FindingRejection::EmptyVerification,
),
(
mutate(
"required_changes",
serde_json::json!([{"file": "../../etc/passwd", "description": "d"}]),
),
FindingRejection::InvalidPath {
field: "required_changes",
path: "../../etc/passwd".to_string(),
},
),
(
mutate(
"verification",
serde_json::json!([{"file": "/abs/path.rs", "description": "d"}]),
),
FindingRejection::InvalidPath {
field: "verification",
path: "/abs/path.rs".to_string(),
},
),
(
mutate(
"verification",
serde_json::json!([{"file": "tests/a.rs", "description": " "}]),
),
FindingRejection::MissingDescription {
field: "verification",
path: "tests/a.rs".to_string(),
},
),
];
for (output, expected) in cases {
assert_eq!(rejection(&output), expected, "routing drifted for {output}");
}
}
#[test]
fn acceptance_duplicate_structured_ids_are_a_protocol_error() {
let finding = serde_json::json!({
"id": "same-id",
"severity": "major",
"summary": "s",
"evidence": ["e"],
"required_changes": [{"file": "src/a.rs", "description": "d"}],
"verification": [{"file": "tests/a.rs", "description": "d"}]
});
let output =
serde_json::json!({"acceptance": "fail", "findings": [finding.clone(), finding]})
.to_string();
assert_eq!(
rejection(&output),
FindingRejection::DuplicateId("same-id".to_string())
);
}
#[test]
fn acceptance_non_object_non_string_finding_entry_is_a_protocol_error() {
let output = r#"{"acceptance":"fail","findings":[42]}"#;
assert_eq!(rejection(output), FindingRejection::NotStringOrObject);
}
#[test]
fn acceptance_malformed_structured_finding_never_becomes_path_only_work() {
let output = serde_json::json!({
"acceptance": "fail",
"findings": [{
"id": "partial",
"severity": "major",
"summary": "s",
"evidence": ["e"],
"required_changes": [{"file": "src/a.rs", "description": "d"}]
}]
})
.to_string();
let result = parse_acceptance_output(&output);
assert!(
matches!(result, AcceptanceResult::MalformedFinding { .. }),
"got {result:?}"
);
if let AcceptanceResult::Fail { findings } = result {
panic!("malformed structured finding degraded into {findings:?}");
}
}
#[test]
fn acceptance_mixed_legacy_and_structured_findings_both_survive() {
let output = serde_json::json!({
"acceptance": "fail",
"findings": [
"src/legacy.rs:10 missing regression coverage",
{
"id": "structured-id",
"severity": "major",
"summary": "s",
"evidence": ["e"],
"required_changes": [{"file": "src/a.rs", "description": "d"}],
"verification": [{"file": "tests/a.rs", "description": "d"}]
}
]
})
.to_string();
let findings = fail_findings(&output);
assert_eq!(findings.len(), 2);
assert_eq!(findings[0].id(), None);
assert_eq!(
findings[0].text(),
"src/legacy.rs:10 missing regression coverage"
);
assert_eq!(findings[1].id(), Some("structured-id"));
}
#[test]
fn acceptance_legacy_verdict_syntax_is_unchanged() {
for output in [
"ACCEPTANCE: FAIL\nFINDINGS:\n- src/a.rs:1 issue\n",
r#"{"acceptance":"fail","findings":["src/a.rs:1 issue"]}"#,
] {
let findings = fail_findings(output);
assert_eq!(findings.len(), 1, "{output}");
assert_eq!(findings[0].text(), "src/a.rs:1 issue", "{output}");
assert!(findings[0].structured_payload().is_none(), "{output}");
assert!(!findings[0].declares_paths(), "{output}");
}
}
#[test]
fn acceptance_repository_path_normalization_rejects_escapes() {
assert_eq!(
normalize_repository_path("./src//a.rs"),
Some("src/a.rs".to_string())
);
assert_eq!(
normalize_repository_path("src\\a.rs"),
Some("src/a.rs".to_string())
);
for escape in [
"",
" ",
"/etc/passwd",
"~/secrets",
"C:/x",
"../up",
"a/../../b",
] {
assert_eq!(normalize_repository_path(escape), None, "{escape}");
}
}
}