use std::borrow::Cow;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::LazyLock;
use regex::Regex;
use super::code_review::{Finding, parse_findings, partition_findings};
use super::message::{LoopMessage, UserMessage};
use super::verifier::VerificationStatus;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Verdict {
Complete,
Incomplete(String),
Abstain(String),
}
pub(crate) fn truncate_rules<'a>(rules: &'a str, max: usize, note: &str) -> Cow<'a, str> {
crate::text::truncate_head(rules, max, |_| note.to_string())
}
pub(crate) const JUDGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
macro_rules! run_judge {
($judge:expr, $prompt:expr, $target:literal, $msg:literal, $default:expr) => {
match ::tokio::time::timeout(
$crate::agent::agent_loop::critic::JUDGE_TIMEOUT,
$judge($prompt),
)
.await
{
Ok(Ok(r)) => r,
Ok(Err(e)) => {
tracing::warn!(target: $target, error = %e, $msg);
return $default;
}
Err(_) => {
tracing::warn!(
target: $target,
timeout_secs = $crate::agent::agent_loop::critic::JUDGE_TIMEOUT.as_secs(),
"judge call timed out; failing open"
);
return $default;
}
}
};
}
pub(crate) use run_judge;
pub type CriticFn = Arc<
dyn Fn(String) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send>> + Send + Sync,
>;
pub type ClassifyFn = Arc<
dyn Fn(
String,
&'static [&'static str],
) -> Pin<Box<dyn Future<Output = anyhow::Result<usize>> + Send>>
+ Send
+ Sync,
>;
pub fn parse_choice(response: &str, options: &[&str]) -> Option<usize> {
debug_assert!(
!options_overlap(options),
"parse_choice: answer-set members must not be substrings of one another (dirge-5mtx.3)"
);
let mut found: Option<usize> = None;
for (i, opt) in options.iter().enumerate() {
if whole_word_present(response, opt) {
if found.is_some() {
return None; }
found = Some(i);
}
}
found
}
fn whole_word_present(haystack: &str, needle: &str) -> bool {
let pattern = format!(r"(?i)\b{}\b", regex::escape(needle));
Regex::new(&pattern)
.expect("escaped word-boundary pattern is always valid")
.is_match(haystack)
}
fn options_overlap(options: &[&str]) -> bool {
for (i, a) in options.iter().enumerate() {
for (j, b) in options.iter().enumerate() {
if i != j && a.to_lowercase().contains(&b.to_lowercase()) {
return true;
}
}
}
false
}
pub const CLASSIFY_PREAMBLE: &str = "\
You answer a single question by choosing exactly one option from a fixed set. Reply with that one \
option word and nothing else — no reasoning, no punctuation, no extra text.";
pub fn classify_prompt(question: &str, options: &[&str]) -> String {
let list = options
.iter()
.map(|o| format!("`{o}`"))
.collect::<Vec<_>>()
.join(", ");
format!(
"{question}\n\nAnswer with EXACTLY ONE of these words and nothing else: {list}.\n\
Do not add explanation, punctuation, or any other text."
)
}
pub(crate) fn classify_retry_prompt(options: &[&str]) -> String {
let list = options
.iter()
.map(|o| format!("`{o}`"))
.collect::<Vec<_>>()
.join(" / ");
format!("Reply with a single word — one of: {list}. Nothing else.")
}
pub const CRITIC_TAG: &str = "[critic]";
pub const CRITIC_PREAMBLE: &str = "\
You are a code-review critic for an autonomous coding agent. You are given the instructions and \
constraints the assistant operates under, plus a transcript of what it just did to satisfy the \
user's request. Judge ONLY whether the task is actually complete and correct within those \
constraints — not style.\n\
\n\
Hard rules:\n\
- RESPECT the assistant's instructions. NEVER flag the absence of an action the instructions \
forbid or defer (e.g. if it was told not to push/commit/deploy, do NOT ask it to). Treat anything \
the instructions place out of scope as correctly omitted.\n\
- Block only on CONCRETE, in-scope incompleteness with evidence (e.g. the user asked for X and X \
is missing; a change was made but never built/tested when verification was expected).\n\
- A tool result tagged `[DENIED]` (or whose text begins `Permission denied` / `Auto-approval \
denied`) is a PERMISSION block, not a failure to fix. Treat that capability as out of scope: \
never demand the assistant retry it, route around it, or accomplish the blocked action some \
other way. Judge the rest of the work as if that action were correctly deferred to the user.\n\
- A block marked `[CONTEXT COMPACTION — REFERENCE ONLY]` (or a `## Active Task` lifted from one) \
describes ALREADY-COMPLETED prior work — never treat it as an outstanding requirement. Judge only \
the latest request and the transcript.\n\
- If the assistant ended by asking the user a question or presenting options and is waiting on their \
decision, that is a CORRECT stopping point, not incompleteness — never tell it to proceed anyway, \
pick a default, or guess. Judge only the work done up to the question.\n\
- Do NOT invent new requirements, scope, or \"nice to haves\". If you cannot determine correctness from \
the spec and evidence available, ABSTAIN — say what's missing (e.g. no test covering this change, \
unclear acceptance criteria). An abstention is safer than a false pass. If you are unsure whether \
there's a real gap, PASS — a false block wastes a whole turn.";
const MAX_RULES_CHARS: usize = 16_000;
pub(crate) fn strip_compaction_summary(rules: &str) -> &str {
match rules.find(crate::agent::compression::COMPACTION_MARKER) {
Some(idx) => rules[..idx].trim_end(),
None => rules,
}
}
fn verification_block(verification: Option<VerificationStatus>) -> &'static str {
match verification {
Some(VerificationStatus::Unverified) => {
"\n\n--- verification status ---\n\
Code was edited this run but no build/test/lint was detected. If one is runnable \
here and not forbidden, flag the unverified change as a concrete gap and name the \
command to run. This is a NUDGE, not a hard rule: if there is nothing to run, the \
change isn't testable (docs, config, scaffolding), or the assistant already verified \
another way and said so, treat it as COMPLETE — never force a test that can't be \
run.\n--- end verification status ---"
}
Some(VerificationStatus::VerifiedRed) => {
"\n\n--- verification status ---\n\
Code was edited and the most recent build/test FAILED. Don't pass a red build — this \
is INCOMPLETE — UNLESS the assistant explicitly said the failure is pre-existing, \
expected, or unrelated to the change.\n--- end verification status ---"
}
Some(VerificationStatus::VerifiedGreen) => {
"\n\n--- verification status ---\n\
Code was edited and a build/test passed. Sanity-check only that the verification was \
RELEVANT to the change (e.g. tests covering the edited area, not just an unrelated \
build); don't manufacture extra requirements.\n--- end verification status ---"
}
Some(VerificationStatus::FastGreenOnly) => {
"\n\n--- verification status ---\n\
Code was edited and the fast checks (typecheck/lint/a targeted test) passed, but the \
full test suite never ran this run. If a broader suite is runnable here and not \
forbidden, flag that as a concrete gap and name the command. This is a NUDGE, not a \
hard rule: if there is no broader suite, it can't run here, or the assistant verified \
end-to-end another way and said so, treat it as COMPLETE.\n\
--- end verification status ---"
}
Some(VerificationStatus::NoCodeEdited) | None => "",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VerdictSignal {
Negative,
Abstain,
Positive,
None,
}
static NEGATED_POSITIVE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\bNOT\s+(?:COMPLETE|DONE|MET|SATISFIED|FINISHED)\b").unwrap());
static NEGATIVE_TOKEN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(?:INCOMPLETE|GAPS|SHORT|UNMET)\b").unwrap());
static ABSTAIN_TOKEN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(?:ABSTAIN|INSUFFICIENT|UNSURE)\b").unwrap());
static POSITIVE_TOKEN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b(?:COMPLETE|DONE|MET|SATISFIED)\b").unwrap());
fn line_bears_verdict(line_upper: &str) -> bool {
NEGATED_POSITIVE.is_match(line_upper)
|| NEGATIVE_TOKEN.is_match(line_upper)
|| ABSTAIN_TOKEN.is_match(line_upper)
|| POSITIVE_TOKEN.is_match(line_upper)
}
pub(crate) fn classify_verdict_head(trimmed: &str) -> VerdictSignal {
for line in trimmed.split('\n') {
let upper = line.to_ascii_uppercase();
if !line_bears_verdict(&upper) {
continue;
}
if NEGATED_POSITIVE.is_match(&upper) || NEGATIVE_TOKEN.is_match(&upper) {
return VerdictSignal::Negative;
}
if ABSTAIN_TOKEN.is_match(&upper) {
return VerdictSignal::Abstain;
}
return VerdictSignal::Positive;
}
VerdictSignal::None
}
pub(crate) fn detail_after_verdict(trimmed: &str) -> Option<&str> {
let mut after_verdict_byte = None;
let mut cursor = 0usize;
for line in trimmed.split('\n') {
if after_verdict_byte.is_none() && line_bears_verdict(&line.to_ascii_uppercase()) {
after_verdict_byte = Some(cursor + line.len());
break;
}
cursor += line.len() + 1; }
let off = after_verdict_byte?;
let rest = trimmed[off..].trim();
(!rest.is_empty()).then_some(rest)
}
pub fn parse_verdict(response: &str) -> Verdict {
let trimmed = response.trim();
if trimmed.is_empty() {
return Verdict::Complete;
}
match classify_verdict_head(trimmed) {
VerdictSignal::Negative => {
Verdict::Incomplete(detail_after_verdict(trimmed).unwrap_or(trimmed).to_string())
}
VerdictSignal::Abstain => {
Verdict::Abstain(detail_after_verdict(trimmed).unwrap_or(trimmed).to_string())
}
VerdictSignal::Positive | VerdictSignal::None => Verdict::Complete,
}
}
const UNIFIED_FORMAT: &str = "\
Respond in EXACTLY this structure and nothing else.\n\
\n\
First line — a verdict, one of `VERDICT: COMPLETE`, `VERDICT: INCOMPLETE`, or `VERDICT: ABSTAIN`:\n\
- COMPLETE: the work is done and correct.\n\
- INCOMPLETE: concrete, in-scope gaps remain. Follow with a short bullet list of the gaps.\n\
- ABSTAIN: the spec or evidence available is insufficient to judge correctness. Say what test or \
spec detail is missing. An ABSTAIN still blocks, but is resolved by adding evidence, not fixes.\n\
\n\
Then a line reading exactly `FINDINGS:` followed by any defects in the diff below — each on its \
own bullet leading with a severity word (critical/high/medium/low), then the narrowest file/line \
location, the concrete harm if left unfixed, and a suggested fix. Separate multiple findings with \
`---` on its own line. If the diff is clean or none is shown, write `FINDINGS: none`.";
const FINDINGS_MARKER: &str = "FINDINGS:";
pub fn build_unified_prompt(
rules: &str,
transcript: &str,
diff: Option<&str>,
verification: Option<VerificationStatus>,
prior_findings: Option<&str>,
) -> String {
let rules = strip_compaction_summary(rules).trim();
let rules_block = if rules.is_empty() {
"(no special constraints provided)".to_string()
} else {
truncate_rules(rules, MAX_RULES_CHARS, "\n…(instructions truncated)").into_owned()
};
let diff_block = match diff {
Some(d) if !d.trim().is_empty() => format!(
"\n\n--- diff under review (review for defects; report them in FINDINGS) ---\n{}\n--- end diff ---",
d.trim()
),
_ => String::new(),
};
let prior_findings_block = match prior_findings {
Some(p) if !p.trim().is_empty() => format!(
"\n\n--- findings raised on an earlier review (the assistant's response is in the \
transcript above) ---\n{}\n--- end prior findings ---\n\
For each: if the assistant fixed it, or gave a sound reason for leaving it, do NOT \
re-raise it. If it is still present and the assistant neither fixed nor justified \
it, DO re-raise it. Only re-raise a justified finding when the justification is \
factually wrong — then explain why it fails.",
p.trim()
),
_ => String::new(),
};
format!(
"{UNIFIED_FORMAT}\n\n\
--- assistant instructions & constraints (judge within these; never demand a \
forbidden/out-of-scope action) ---\n{rules_block}\n--- end instructions ---\n\n\
--- transcript ---\n{transcript}\n--- end transcript ---{diff_block}{prior_findings_block}{}",
verification_block(verification)
)
}
pub fn parse_unified(response: &str) -> (Verdict, Vec<Finding>) {
let (head, tail) = split_on_findings_marker(response);
let verdict = parse_verdict(head);
let mut findings = parse_findings(tail);
findings.sort_by_key(|f| std::cmp::Reverse(f.severity));
(verdict, findings)
}
fn split_on_findings_marker(response: &str) -> (&str, &str) {
let lower = response.to_ascii_lowercase();
match lower.find(&FINDINGS_MARKER.to_ascii_lowercase()) {
Some(idx) => (&response[..idx], &response[idx + FINDINGS_MARKER.len()..]),
None => (response, ""),
}
}
fn render_findings(findings: &[Finding]) -> String {
findings
.iter()
.map(|f| format!("[{}] {}", f.severity.label(), f.body.trim()))
.collect::<Vec<_>>()
.join("\n\n---\n\n")
}
pub fn build_unified_followup(verdict: Verdict, findings: Vec<Finding>) -> Option<LoopMessage> {
let gaps = match &verdict {
Verdict::Complete => None,
Verdict::Incomplete(issues) => Some(("the task may not be done yet", issues.clone())),
Verdict::Abstain(missing) => Some((
"correctness couldn't be confirmed — add a focused test or state the missing spec detail",
missing.clone(),
)),
};
let (blocking, advisory) = partition_findings(findings);
if gaps.is_none() && blocking.is_empty() && advisory.is_empty() {
return None;
}
let mut sections: Vec<String> = Vec::new();
if let Some((label, body)) = gaps {
sections.push(format!("Completeness — {label}:\n{}", body.trim()));
}
if !blocking.is_empty() {
sections.push(format!(
"Bugs to fix (high severity):\n{}",
render_findings(&blocking)
));
}
if !advisory.is_empty() {
sections.push(format!(
"Lower-priority notes (optional; address if quick, else say why you're leaving them):\n{}",
render_findings(&advisory)
));
}
let body = sections.join("\n\n");
Some(LoopMessage::User(UserMessage::text(format!(
"{CRITIC_TAG} A review of your work found things to address before you report complete. \
Fix each, or explain why it doesn't apply (out of scope, intended, or something you were \
told not to do):\n\n{body}"
))))
}
pub struct ReviewOutcome {
pub messages: Vec<LoopMessage>,
pub raised_findings: Option<String>,
pub judged: bool,
}
pub async fn run_unified_review(
judge: &CriticFn,
rules: &str,
transcript: &str,
diff: Option<&str>,
verification: Option<VerificationStatus>,
prior_findings: Option<&str>,
) -> ReviewOutcome {
let prompt = build_unified_prompt(rules, transcript, diff, verification, prior_findings);
let response = run_judge!(
judge,
prompt,
"dirge::critic",
"unified review call failed; finalizing without it",
ReviewOutcome {
messages: Vec::new(),
raised_findings: None,
judged: false,
}
);
let (verdict, findings) = parse_unified(&response);
let raised = if findings.is_empty() {
None
} else {
Some(render_findings(&findings))
};
let messages = build_unified_followup(verdict, findings)
.into_iter()
.collect();
ReviewOutcome {
messages,
raised_findings: raised,
judged: true,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn critic_verification_block_fast_green_only() {
let block = verification_block(Some(VerificationStatus::FastGreenOnly));
assert!(!block.is_empty());
assert!(block.contains("full test suite"), "{block}");
assert!(block.contains("NUDGE, not a hard rule"), "{block}");
assert!(
block.contains("COMPLETE"),
"escape hatch preserved: {block}"
);
assert_eq!(
verification_block(Some(VerificationStatus::NoCodeEdited)),
""
);
assert_eq!(verification_block(None), "");
}
fn build_prompt(
rules: &str,
transcript: &str,
verification: Option<VerificationStatus>,
) -> String {
build_unified_prompt(rules, transcript, None, verification, None)
}
#[test]
fn truncate_rules_counts_chars_not_bytes() {
use std::borrow::Cow;
assert!(matches!(
truncate_rules("abc", 10, "…(instructions truncated)"),
Cow::Borrowed(_)
));
assert_eq!(
truncate_rules("abcdefghij", 4, "|NOTE").into_owned(),
"abcd|NOTE"
);
let mb = "🦀🦀🦀🦀🦀🦀";
assert_eq!(mb.len(), 24);
assert!(matches!(truncate_rules(mb, 10, "|NOTE"), Cow::Borrowed(_)));
let over = "🦀🦀🦀🦀"; assert_eq!(truncate_rules(over, 2, "|NOTE").into_owned(), "🦀🦀|NOTE");
}
#[test]
fn parse_complete_returns_complete() {
assert_eq!(parse_verdict("VERDICT: COMPLETE"), Verdict::Complete);
assert_eq!(
parse_verdict("verdict: complete\n(looks good)"),
Verdict::Complete
);
}
#[test]
fn parse_incomplete_returns_incomplete_with_issues() {
let v = parse_verdict("VERDICT: INCOMPLETE\n- missing test\n- error path unhandled");
match v {
Verdict::Incomplete(issues) => {
assert!(issues.contains("missing test"));
assert!(issues.contains("error path"));
}
other => panic!("expected Incomplete, got {other:?}"),
}
}
#[test]
fn parse_empty_or_ambiguous_returns_complete() {
assert_eq!(parse_verdict(""), Verdict::Complete);
assert_eq!(parse_verdict(" \n "), Verdict::Complete);
assert_eq!(
parse_verdict("I think it's probably fine?"),
Verdict::Complete
);
}
#[test]
fn parse_abstain_returns_abstain_with_detail() {
let v = parse_verdict("VERDICT: ABSTAIN\nNo test covers the retry-on-timeout path.");
match v {
Verdict::Abstain(detail) => {
assert!(detail.contains("retry-on-timeout"));
}
other => panic!("expected Abstain, got {other:?}"),
}
let v2 = parse_verdict("VERDICT: INSUFFICIENT\nSpec unclear on error format.");
match v2 {
Verdict::Abstain(detail) => {
assert!(detail.contains("error format"));
}
other => panic!("expected Abstain, got {other:?}"),
}
}
#[test]
fn parse_incomplete_before_abstain_priority() {
let v = parse_verdict("VERDICT: INCOMPLETE, or perhaps ABSTAIN\nmissing tests");
match v {
Verdict::Incomplete(issues) => {
assert!(issues.contains("missing tests"));
}
other => panic!("expected Incomplete (priority over ABSTAIN), got {other:?}"),
}
}
#[test]
fn parse_verdict_corpus() {
let rows: &[(&str, &str)] = &[
("COMPLETE", "complete"),
("DONE", "complete"),
("INCOMPLETE", "incomplete"),
("GAPS", "incomplete"),
("SHORT", "incomplete"),
("VERDICT: COMPLETE", "complete"),
("VERDICT: INCOMPLETE", "incomplete"),
("VERDICT: DONE", "complete"),
("VERDICT: GAPS", "incomplete"),
("VERDICT: UNSURE", "abstain"),
("VERDICT: ABSTAIN", "abstain"),
("NOT COMPLETE", "incomplete"),
("NOT DONE", "incomplete"),
("NOT FINISHED", "incomplete"),
("NOT SATISFIED", "incomplete"),
(
"I reviewed the work.\nINCOMPLETE\n- tests still failing",
"incomplete",
),
("After checking, VERDICT: DONE", "complete"),
("verdict: incomplete\n- x", "incomplete"),
("Verdict: UNSURE", "abstain"),
("The task is NOT COMPLETE yet", "incomplete"),
("Overall the work is COMPLETE", "complete"),
("COMPLETE, not INCOMPLETE", "incomplete"),
("INCOMPLETE then COMPLETE", "incomplete"),
(
"DONE\nThe diff is clean; a few short comments, no gaps in logic.",
"complete",
),
(
"COMPLETE\naddressing the remaining gaps next sprint is out of scope.",
"complete",
),
("", "complete"),
("probably looks fine", "complete"),
];
for &(input, want) in rows {
let got = match parse_verdict(input) {
Verdict::Complete => "complete",
Verdict::Incomplete(_) => "incomplete",
Verdict::Abstain(_) => "abstain",
};
assert_eq!(got, want, "parse_verdict({input:?})");
}
match parse_verdict("I reviewed the work.\nINCOMPLETE\n- tests still failing") {
Verdict::Incomplete(issues) => assert!(
issues.contains("tests still failing"),
"detail lost: {issues:?}"
),
other => panic!("expected Incomplete with detail, got {other:?}"),
}
}
#[test]
fn prompt_embeds_transcript_format_and_rules() {
let p = build_prompt(
"RULE: never push to remote.",
"user asked X; assistant edited foo.rs",
None,
);
assert!(p.contains("VERDICT: COMPLETE"));
assert!(p.contains("VERDICT: INCOMPLETE"));
assert!(p.contains("VERDICT: ABSTAIN"));
assert!(p.contains("edited foo.rs"));
assert!(p.contains("never push to remote"), "rules must be embedded");
assert!(
p.to_lowercase().contains("forbidden") || p.to_lowercase().contains("out-of-scope"),
"prompt must instruct the critic to respect constraints",
);
}
#[test]
fn empty_rules_render_a_placeholder_not_blank() {
let p = build_prompt("", "did stuff", None);
assert!(p.contains("no special constraints"));
}
#[test]
fn build_prompt_drops_the_compaction_summary_from_rules() {
let rules = format!(
"RULE: never push to remote.\n\n{} \
## Active Task\nFinish Phase 3: wire the Janet loader and add tests.",
crate::agent::compression::COMPACTION_MARKER,
);
let p = build_prompt(&rules, "user asked X; assistant edited foo.rs", None);
assert!(
p.contains("never push to remote"),
"real rules must survive"
);
assert!(
!p.contains("Active Task") && !p.contains("Phase 3") && !p.contains("Janet"),
"the compaction summary must be stripped from the critic's rules",
);
assert!(
!p.contains(crate::agent::compression::COMPACTION_MARKER),
"the compaction marker itself must be stripped",
);
}
#[test]
fn preamble_discounts_reference_only_blocks() {
let lower = CRITIC_PREAMBLE.to_ascii_lowercase();
assert!(
lower.contains("reference") || lower.contains("compaction"),
"preamble must tell the critic to ignore reference-only/compaction blocks",
);
}
#[test]
fn build_prompt_caps_large_rules() {
let huge = "x".repeat(MAX_RULES_CHARS + 5_000);
let p = build_prompt(&huge, "t", None);
assert!(p.contains("instructions truncated"));
assert!(p.len() < MAX_RULES_CHARS + 4_000);
}
#[test]
fn preamble_is_calibrated_and_constraint_aware() {
let lower = CRITIC_PREAMBLE.to_ascii_lowercase();
assert!(lower.contains("critic"), "preamble must name the role");
assert!(!lower.contains("summarizer"));
assert!(!CRITIC_PREAMBLE.contains("VERDICT:"));
assert!(build_prompt("", "t", None).contains("VERDICT:"));
assert!(
lower.contains("respect"),
"must say to respect instructions"
);
assert!(
lower.contains("never flag the absence") || lower.contains("forbid"),
"must forbid demanding disallowed actions",
);
assert!(lower.contains("unsure"), "must keep the fail-open guidance");
}
#[test]
fn preamble_treats_awaiting_user_question_as_correct_stop() {
let lower = CRITIC_PREAMBLE.to_ascii_lowercase();
assert!(
lower.contains("asking the user a question") || lower.contains("presenting options"),
"must name the awaiting-question / options scenario"
);
assert!(
lower.contains("correct stopping point"),
"must mark a pending question a correct stop"
);
assert!(
lower.contains("guess"),
"must forbid pushing the assistant to guess"
);
}
#[test]
fn no_verification_status_adds_no_block() {
let p = build_prompt("rules", "did stuff", None);
assert!(!p.contains("verification status"));
}
#[test]
fn no_code_edited_adds_no_block() {
let p = build_prompt("rules", "did stuff", Some(VerificationStatus::NoCodeEdited));
assert!(!p.contains("verification status"));
}
#[test]
fn unverified_block_pushes_to_run_a_check() {
let p = build_prompt(
"rules",
"edited foo.rs",
Some(VerificationStatus::Unverified),
);
assert!(p.contains("verification status"));
let lower = p.to_lowercase();
assert!(lower.contains("no build/test/lint was detected"));
assert!(lower.contains("concrete"), "must frame it as a real gap");
}
#[test]
fn unverified_block_is_a_soft_nudge() {
let p = build_prompt(
"rules",
"edited foo.rs",
Some(VerificationStatus::Unverified),
);
let lower = p.to_lowercase();
assert!(lower.contains("nudge"), "must call itself a nudge");
assert!(
lower.contains("isn't testable") || lower.contains("nothing to run"),
"must offer a not-testable escape",
);
assert!(
lower.contains("never force a test that can't be run"),
"must forbid fabricating an unrunnable test",
);
}
#[test]
fn red_block_forbids_passing_a_red_build() {
let p = build_prompt(
"rules",
"edited foo.rs",
Some(VerificationStatus::VerifiedRed),
);
let lower = p.to_lowercase();
assert!(lower.contains("failed"));
assert!(lower.contains("incomplete"));
}
#[test]
fn green_block_stays_calibrated() {
let p = build_prompt(
"rules",
"edited foo.rs",
Some(VerificationStatus::VerifiedGreen),
);
let lower = p.to_lowercase();
assert!(lower.contains("passed"));
assert!(lower.contains("relevant"));
}
#[tokio::test]
async fn unified_review_threads_verification_and_rules_into_prompt() {
use std::sync::Mutex;
let seen: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
let seen2 = seen.clone();
let judge: CriticFn = Arc::new(move |prompt: String| {
*seen2.lock().unwrap() = prompt;
Box::pin(async { Ok("VERDICT: COMPLETE\nFINDINGS: none".to_string()) })
});
let _ = run_unified_review(
&judge,
"RULE: do not deploy",
"edited foo.rs",
None,
Some(VerificationStatus::Unverified),
None,
)
.await;
let prompt = seen.lock().unwrap().clone();
assert!(
prompt.contains("verification status"),
"the verification signal must reach the judge prompt"
);
assert!(
prompt.contains("do not deploy"),
"the agent's constraints must reach the judge prompt"
);
}
#[tokio::test]
async fn unified_review_silent_when_complete_and_clean() {
let judge: CriticFn =
Arc::new(|_p| Box::pin(async { Ok("VERDICT: COMPLETE\nFINDINGS: none".to_string()) }));
assert!(
run_unified_review(&judge, "rules", "did stuff", None, None, None)
.await
.messages
.is_empty()
);
}
use crate::agent::agent_loop::code_review::Severity;
fn msg_text(m: &LoopMessage) -> String {
match m {
LoopMessage::User(u) => u.text_joined(),
_ => panic!("expected a user follow-up message"),
}
}
#[test]
fn parse_unified_complete_and_clean() {
let (v, f) = parse_unified("VERDICT: COMPLETE\n\nFINDINGS: none");
assert!(matches!(v, Verdict::Complete));
assert!(f.is_empty());
}
#[test]
fn parse_unified_incomplete_with_findings_severity_sorted() {
let resp = "VERDICT: INCOMPLETE\n- the --skill arg is never parsed\n\n\
FINDINGS:\n- low: inconsistent spacing in warnings.\n---\n\
- high: line 834 missing closing paren -> SyntaxError. Fix: add ).";
let (v, f) = parse_unified(resp);
match v {
Verdict::Incomplete(gaps) => assert!(gaps.contains("--skill"), "gaps: {gaps}"),
other => panic!("expected incomplete, got {other:?}"),
}
assert_eq!(f.len(), 2);
assert_eq!(f[0].severity, Severity::High, "sorted highest-first");
assert_eq!(f[1].severity, Severity::Low);
}
#[test]
fn parse_unified_marker_case_insensitive_and_absent() {
let (v, f) = parse_unified("VERDICT: COMPLETE");
assert!(matches!(v, Verdict::Complete));
assert!(f.is_empty());
let (v2, f2) =
parse_unified("VERDICT: INCOMPLETE\n- gap\n\nfindings:\n- critical: rce in exec path.");
assert!(matches!(v2, Verdict::Incomplete(_)));
assert_eq!(f2.len(), 1);
assert_eq!(f2[0].severity, Severity::Critical);
}
#[test]
fn unified_followup_none_when_complete_and_clean() {
assert!(build_unified_followup(Verdict::Complete, Vec::new()).is_none());
}
#[test]
fn unified_followup_reenters_for_high_finding_even_when_complete() {
let findings = parse_findings("- high: missing closing paren -> SyntaxError.");
let msg = build_unified_followup(Verdict::Complete, findings).expect("some");
let text = msg_text(&msg);
assert!(text.starts_with(CRITIC_TAG));
assert!(text.contains("Bugs to fix"));
assert!(text.to_lowercase().contains("syntaxerror"));
assert!(
!text.contains("Completeness"),
"no completeness section when verdict was COMPLETE"
);
}
#[test]
fn unified_followup_reenters_for_low_only_finding() {
let findings = parse_findings("- low: inconsistent spacing in warnings.");
let msg = build_unified_followup(Verdict::Complete, findings).expect("some");
let text = msg_text(&msg);
assert!(text.contains("Lower-priority notes"));
assert!(!text.contains("Bugs to fix"));
}
#[test]
fn unified_followup_combines_completeness_and_findings() {
let findings = parse_findings("- critical: auth bypass.\n---\n- medium: dup logic.");
let msg =
build_unified_followup(Verdict::Incomplete("- X is missing".to_string()), findings)
.expect("some");
let text = msg_text(&msg);
assert!(text.contains("Completeness"));
assert!(text.contains("X is missing"));
assert!(text.contains("Bugs to fix"));
assert!(text.contains("Lower-priority notes"));
}
#[test]
fn unified_prompt_includes_diff_only_when_present() {
let with = build_unified_prompt(
"rules",
"did stuff",
Some("@@ -1 +1 @@\n-a\n+b"),
None,
None,
);
assert!(with.contains("diff under review"));
assert!(with.contains("+b"));
let without = build_unified_prompt("rules", "did stuff", None, None, None);
assert!(!without.contains("diff under review"));
assert!(with.contains("FINDINGS:"));
assert!(without.contains("FINDINGS:"));
}
#[test]
fn unified_prompt_omits_prior_findings_section_when_none() {
let p = build_unified_prompt(
"rules",
"did stuff",
Some("@@ -1 +1 @@\n-a\n+b"),
None,
None,
);
assert!(!p.contains("earlier review"));
assert!(p.contains("diff under review"));
}
#[test]
fn unified_prompt_omits_prior_findings_section_when_blank() {
let p = build_unified_prompt("rules", "did stuff", Some("diff"), None, Some(" \n "));
assert!(!p.contains("earlier review"));
}
#[test]
fn unified_prompt_includes_prior_findings_section_when_present() {
let p = build_unified_prompt(
"rules",
"did stuff",
Some("diff"),
None,
Some("- High — sql injection"),
);
assert!(
p.contains("earlier review"),
"prior-findings section must appear"
);
assert!(
p.contains("- High — sql injection"),
"the prior findings must be inlined"
);
assert!(
p.contains("do NOT re-raise"),
"the suppress-if-addressed instruction must be present"
);
assert!(
p.contains("neither fixed nor justified"),
"the re-raise-if-unaddressed instruction must be present"
);
}
#[tokio::test]
async fn run_unified_review_fails_open_on_error() {
let judge: CriticFn = Arc::new(|_p| Box::pin(async { anyhow::bail!("provider down") }));
assert!(
run_unified_review(&judge, "rules", "did stuff", Some("diff"), None, None)
.await
.messages
.is_empty(),
"a judge error must not block finalization"
);
}
#[test]
fn negative_always_beats_the_positive_it_embeds() {
let traps: &[(&str, &str, bool)] = &[
("INCOMPLETE", "COMPLETE", true),
("NOT COMPLETE", "COMPLETE", true),
("NOT DONE", "DONE", true),
("NOT MET", "MET", true),
("NOT SATISFIED", "SATISFIED", true),
("NOT FINISHED", "FINISHED", false),
];
for &(negative, positive, positive_is_standalone) in traps {
assert!(
negative.contains(positive),
"test data error: {negative} should embed {positive}"
);
assert_eq!(
classify_verdict_head(negative),
VerdictSignal::Negative,
"{negative} embeds {positive} and must still classify negative"
);
if positive_is_standalone {
assert_eq!(
classify_verdict_head(positive),
VerdictSignal::Positive,
"bare {positive} must still classify positive"
);
} else {
assert_eq!(
classify_verdict_head(positive),
VerdictSignal::None,
"{positive} is negation-only; bare use falls through to fail-open"
);
}
}
}
#[test]
fn parse_choice_single_match_each_index() {
let opts = ["BLOCKED", "NEXT", "DONE"];
assert_eq!(parse_choice("BLOCKED", &opts), Some(0));
assert_eq!(
parse_choice("the run is BLOCKED on the user", &opts),
Some(0)
);
assert_eq!(parse_choice("NEXT", &opts), Some(1));
assert_eq!(parse_choice("offering a NEXT step", &opts), Some(1));
assert_eq!(parse_choice("DONE", &opts), Some(2));
}
#[test]
fn parse_choice_case_insensitive_and_whole_word() {
let opts = ["MET", "SHORT"];
assert_eq!(parse_choice("met", &opts), Some(0));
assert_eq!(parse_choice("Met.", &opts), Some(0));
assert_eq!(parse_choice("goal is met", &opts), Some(0));
assert_eq!(parse_choice("SHORT", &opts), Some(1));
assert_eq!(parse_choice("short by a lot", &opts), Some(1));
assert_eq!(parse_choice("the goal is not satisfied", &opts), None);
let bad = ["MET", "UNMET"];
assert!(options_overlap(&bad));
}
#[test]
fn parse_choice_two_distinct_options_is_ambiguous() {
let opts = ["BLOCKED", "NEXT", "DONE"];
assert_eq!(parse_choice("BLOCKED and then DONE", &opts), None);
assert_eq!(parse_choice("NEXT not DONE", &opts), None);
}
#[test]
fn parse_choice_no_match() {
let opts = ["BLOCKED", "NEXT"];
assert_eq!(parse_choice("", &opts), None);
assert_eq!(parse_choice("the assistant is waiting", &opts), None);
assert_eq!(parse_choice("COMPLETELY unrelated prose", &opts), None);
}
#[test]
fn parse_choice_repeated_option_not_ambiguous() {
let opts = ["BLOCKED", "NEXT"];
assert_eq!(parse_choice("BLOCKED, yes BLOCKED", &opts), Some(0));
assert_eq!(parse_choice("NEXT NEXT NEXT", &opts), Some(1));
}
#[test]
fn classify_prompt_names_every_option() {
let prompt = classify_prompt("Is the agent blocked?", &["BLOCKED", "NEXT"]);
assert!(prompt.contains("BLOCKED"));
assert!(prompt.contains("NEXT"));
assert!(prompt.contains("Is the agent blocked?"));
assert!(prompt.contains("EXACTLY ONE"));
}
#[test]
fn classify_retry_prompt_names_every_option() {
let prompt = classify_retry_prompt(&["BLOCKED", "NEXT"]);
assert!(prompt.contains("BLOCKED"));
assert!(prompt.contains("NEXT"));
}
#[tokio::test]
async fn judge_error_is_not_reported_as_judged() {
let judge: CriticFn = Arc::new(|_p| Box::pin(async { anyhow::bail!("provider down") }));
let out = run_unified_review(&judge, "", "did stuff", Some("diff"), None, None).await;
assert!(!out.judged, "a failed call must not claim to have judged");
assert!(
out.messages.is_empty(),
"fail-open still yields no follow-up"
);
assert!(out.raised_findings.is_none());
}
#[tokio::test]
async fn clean_review_is_reported_as_judged() {
let judge: CriticFn =
Arc::new(|_p| Box::pin(async { Ok("VERDICT: COMPLETE".to_string()) }));
let out = run_unified_review(&judge, "", "did stuff", Some("diff"), None, None).await;
assert!(out.judged, "a real response must count as judged");
assert!(
out.messages.is_empty(),
"COMPLETE with no findings still yields no follow-up"
);
}
#[tokio::test]
async fn error_and_clean_review_differ_only_in_judged() {
let err: CriticFn = Arc::new(|_p| Box::pin(async { anyhow::bail!("down") }));
let ok: CriticFn = Arc::new(|_p| Box::pin(async { Ok("VERDICT: COMPLETE".to_string()) }));
let a = run_unified_review(&err, "", "t", Some("d"), None, None).await;
let b = run_unified_review(&ok, "", "t", Some("d"), None, None).await;
assert_eq!(a.messages.len(), b.messages.len());
assert_eq!(a.raised_findings, b.raised_findings);
assert_ne!(a.judged, b.judged, "only `judged` separates them");
}
}