1use serde_json::Value;
5use speccoder_core::scope::ScopeFinding;
6use speccoder_core::verify::Verdict;
7use speccoder_core::{CommandStatus, RunReport};
8
9use crate::progress;
10
11use super::{add_usage, monitor_summary};
12
13pub(crate) fn scope_of(report: &RunReport, title: &str) -> ScopeFinding {
16 let exempt: Vec<String> = report
17 .acceptance
18 .as_ref()
19 .and_then(|a| a["files"].as_array())
20 .into_iter()
21 .flatten()
22 .filter_map(|f| f.as_str().map(str::to_owned))
23 .collect();
24 let changed: Vec<String> = report.changes.iter().map(|(p, _)| p.clone()).collect();
25 let named = speccoder_core::scope::named_paths(title);
26 speccoder_core::scope::finding(&named, &changed, &exempt)
27}
28
29pub(crate) fn saved_outcome(
31 report: &RunReport,
32 attempt_status: &str,
33 suspected: bool,
34) -> (String, String) {
35 let outcome = match attempt_status {
36 _ if suspected => "suspected_gaming".to_owned(),
37 "completed" | "integrity" => "verification_failed".to_owned(),
38 other => other.to_owned(),
39 };
40 let reason = match &report.verdict {
41 _ if suspected => monitor_summary(report.monitor.as_ref()),
42 Some(Verdict::Failed { reasons }) => failed_because(report, reasons),
43 _ => report
44 .reason
45 .clone()
46 .unwrap_or_else(|| attempt_status.to_owned()),
47 };
48 (outcome, reason)
49}
50
51pub(crate) fn status_and_message(
53 report: &RunReport,
54 attempt_status: &str,
55 suspected: bool,
56) -> (CommandStatus, String) {
57 match (&report.verdict, attempt_status) {
58 (_, "over_strict") => (
59 CommandStatus::NeedsHuman,
60 format!(
61 "the checks passed, but {}; the attempt is held, not ticked — rule on it (`speccoder task rule {task} --tests-right --reason …`), regenerate the tests (`speccoder run --task {task} --fresh-acceptance`) or accept it (`speccoder task accept {task}`)",
62 report.reason.clone().unwrap_or_default(),
63 task = report.task
64 ),
65 ),
66 (_, "acceptance_weak") => (
67 CommandStatus::NeedsHuman,
68 format!(
69 "the checks passed, but the {}; the attempt is saved, not ticked",
70 report.reason.clone().unwrap_or_default()
71 ),
72 ),
73 _ if suspected => (
74 CommandStatus::NeedsHuman,
75 format!(
76 "the checks passed, but the gaming monitor suspects: {}; the attempt is saved, not ticked — review it, then `speccoder task accept` or run again",
77 monitor_summary(report.monitor.as_ref())
78 ),
79 ),
80 (_, "incomplete") => (
81 CommandStatus::NeedsHuman,
82 format!(
83 "attempt incomplete — {}{}",
84 incomplete_because(report.model_report.as_ref()),
85 if report.changes.is_empty() {
86 "; no files changed"
87 } else {
88 ""
89 }
90 ),
91 ),
92 (None, "collection_unknown") => (
93 CommandStatus::NeedsHuman,
94 format!(
95 "{}; the attempt is saved, not ticked",
96 report.reason.clone().unwrap_or_default()
97 ),
98 ),
99 (Some(Verdict::Failed { reasons }), _) => (
100 CommandStatus::VerificationFailed,
101 format!("verdict: failed — {}", failed_because(report, reasons)),
102 ),
103 (Some(v), _) => (CommandStatus::Ok, format!("verdict: {}", verdict_word(v))),
104 (None, "needs_approval") if report.pending.as_ref().is_some_and(Value::is_object) => (
106 CommandStatus::NeedsHuman,
107 format!(
108 "attempt needs_approval: {}",
109 crate::tools::policy::unasked(
110 report.pending.as_ref().unwrap_or(&Value::Null),
111 &format!(
112 "approve it (`speccoder task approve {}`), or run with prompts (without `--no-prompt`) to be asked",
113 report.task
114 ),
115 )
116 ),
117 ),
118 (
119 None,
120 "blocked" | "needs_approval" | "budget_exhausted" | "denied" | "interrupted"
121 | "held_out" | "intent_changed" | "judge_changed" | "checkout_changed" | "git_changed",
122 ) => (
123 CommandStatus::NeedsHuman,
124 format!(
125 "attempt {attempt_status}: {}",
126 report
127 .reason
128 .clone()
129 .or_else(|| report
130 .pending
131 .as_ref()
132 .map(|p| p["reason"].as_str().unwrap_or_default().to_owned()))
133 .unwrap_or_default()
134 ),
135 ),
136 (None, _) => (
137 CommandStatus::RunFailed,
138 format!(
139 "attempt failed: {}",
140 report.reason.clone().unwrap_or_default()
141 ),
142 ),
143 }
144}
145
146fn failed_because(report: &RunReport, reasons: &[String]) -> String {
149 let checks = report
150 .verification
151 .as_ref()
152 .map(|v| v.checks.as_slice())
153 .unwrap_or_default();
154 reasons
155 .iter()
156 .map(|r| {
157 let named = r
158 .strip_prefix("check `")
159 .and_then(|n| n.split_once('`'))
160 .map(|(n, _)| n);
161 match named
162 .and_then(|n| checks.iter().find(|c| c.name == n))
163 .and_then(speccoder_core::retry::first_cause)
164 {
165 Some(cause) => format!("{r}: {cause}"),
166 None => r.clone(),
167 }
168 })
169 .collect::<Vec<_>>()
170 .join("; ")
171}
172
173fn incomplete_because(model_report: Option<&Value>) -> String {
176 let report = model_report.unwrap_or(&Value::Null);
177 let notes: Vec<&str> = report["notes"]
178 .as_array()
179 .into_iter()
180 .flatten()
181 .filter_map(Value::as_str)
182 .map(str::trim)
183 .filter(|n| !n.is_empty())
184 .collect();
185 let said = if notes.is_empty() {
186 report["summary"]
187 .as_str()
188 .unwrap_or_default()
189 .trim()
190 .to_owned()
191 } else {
192 notes.join(" ")
193 };
194 if said.is_empty() {
195 return "the model says it did not finish".to_owned();
196 }
197 let said = crate::approvals::present::escape(&said.replace('\n', " "));
198 let cut: String = said.chars().take(400).collect();
199 let more = if cut.len() < said.len() { "…" } else { "" };
200 format!("the model says: “{cut}{more}”")
201}
202
203pub(crate) fn verdict_word(v: &Verdict) -> String {
205 serde_json::to_value(v)
206 .ok()
207 .and_then(|v| v["verdict"].as_str().map(str::to_owned))
208 .unwrap_or_default()
209}
210
211pub(crate) fn finished_event(
213 task: &str,
214 report: &RunReport,
215 attempt_status: &str,
216 suspected: bool,
217 message: String,
218) -> progress::Event {
219 let total = add_usage(
220 report.usage.as_ref(),
221 report.acceptance.as_ref().and_then(|a| a.get("usage")),
222 );
223 let tokens = |k: &str| total.as_ref().and_then(|u| u[k].as_u64()).unwrap_or(0);
224 progress::Event::TaskFinished {
225 task: task.to_owned(),
226 outcome: if suspected {
229 "suspected_gaming".to_owned()
230 } else if attempt_status == "completed" {
231 report
232 .verdict
233 .as_ref()
234 .map_or_else(|| attempt_status.to_owned(), verdict_word)
235 } else {
236 attempt_status.to_owned()
237 },
238 message,
239 run: (!report.run_id.is_empty()).then(|| report.run_id.clone()),
240 input_tokens: tokens("input_tokens"),
241 output_tokens: tokens("output_tokens"),
242 cache_read_tokens: tokens("cache_read_tokens"),
243 cache_write_tokens: tokens("cache_write_tokens"),
244 usd: None,
245 regression: report.regression.clone(),
246 }
247}
248
249pub(crate) fn bare_report(
251 feature: &str,
252 task: &str,
253 worktree: &str,
254 attempt_status: &str,
255) -> RunReport {
256 RunReport {
257 run_id: String::new(),
258 feature: feature.to_owned(),
259 task: task.to_owned(),
260 worktree: worktree.to_owned(),
261 attempt_status: attempt_status.to_owned(),
262 model_report: None,
263 reason: None,
264 pending: None,
265 tool_calls: Vec::new(),
266 changes: Vec::new(),
267 verification: None,
268 verdict: None,
269 usage: None,
270 integrity: Vec::new(),
271 ticked: false,
272 commit: None,
273 acceptance: None,
274 earlier: Vec::new(),
275 retry_stop: None,
276 saved_attempt: None,
277 set_aside: None,
278 notices: Vec::new(),
279 agent: None,
280 monitor: None,
281 mutation: None,
282 samples: Vec::new(),
283 scope: None,
284 attestation: None,
285 instructions: Vec::new(),
286 strength: None,
287 bug: None,
288 collection: None,
289 regression: None,
290 share: None,
291 context: None,
292 }
293}
294
295pub(crate) fn undemonstrated_why(acceptance: Option<&super::PreparedAcceptance>) -> String {
298 match acceptance {
299 None => "the spec has no acceptance examples to test it against".to_owned(),
300 Some(a) => match (a.report["skipped"].as_str(), a.report["note"].as_str()) {
301 (Some(s), _) => format!("no acceptance tests ({s})"),
302 (None, Some(note)) => match a.report["excerpt"].as_str().and_then(|e| e.lines().next())
303 {
304 Some(first) => format!("{note}: {first}"),
305 None => note.to_owned(),
306 },
307 (None, None) => "its acceptance tests already pass before any change".to_owned(),
308 },
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use serde_json::json;
316
317 #[test]
318 fn a_failed_check_says_what_it_said_first() {
319 use speccoder_core::verify::{CheckResult, CheckStatus, VerifyReport};
320 let mut report = bare_report("f", "T007", "w", "completed");
321 report.verification = Some(VerifyReport {
322 root: String::new(),
323 tier: "fast".to_owned(),
324 checks: vec![CheckResult {
325 name: "acceptance".to_owned(),
326 argv: vec!["python3".to_owned()],
327 status: CheckStatus::Failed,
328 exit_code: Some(1),
329 duration_ms: 0,
330 junit: false,
331 tests: None,
332 stdout_tail: String::new(),
333 stderr_tail: "/usr/bin/python3: No module named pytest\n".to_owned(),
334 truncated: false,
335 note: None,
336 flaky: false,
337 }],
338 comparison: None,
339 passed: false,
340 });
341 let reasons = [
342 "check `acceptance` failed".to_owned(),
343 "regression: t is failed".to_owned(),
344 ];
345 assert_eq!(
346 failed_because(&report, &reasons),
347 "check `acceptance` failed: /usr/bin/python3: No module named pytest; regression: t is failed"
348 );
349 }
350
351 #[test]
352 fn an_incomplete_attempt_quotes_what_the_model_says_is_missing() {
353 let notes =
354 json!({ "summary": "s", "notes": ["", "pytest is \u{1b}[31mmissing", "so I stopped"] });
355 assert_eq!(
356 incomplete_because(Some(¬es)),
357 "the model says: “pytest is ␛[31mmissing so I stopped”"
358 );
359 let summary = json!({ "summary": "only the plumbing\nso far", "notes": [] });
360 assert_eq!(
361 incomplete_because(Some(&summary)),
362 "the model says: “only the plumbing so far”"
363 );
364 assert_eq!(incomplete_because(None), "the model says it did not finish");
365 let long = json!({ "notes": ["é".repeat(500)] });
366 assert!(incomplete_because(Some(&long)).ends_with("é…”"));
367 }
368}