Skip to main content

skilltest_core/
report.rs

1//! Run results and the JSON report. The serialized shape here is the **stable
2//! contract** the language SDKs parse. These types are the source of truth:
3//! their JSON Schemas (via `skilltest schema`, goldens in `schemas/`) are what
4//! the SDK contract tests compare their Pydantic/Zod models against.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::conversation::Transcript;
10use crate::error::{Error, ProviderErrorKind};
11use crate::eval::EvalOutcome;
12use crate::mock::MockCall;
13use crate::provider::Usage;
14use crate::skill::Finding;
15
16/// The result of running one test case on one (platform, model) pair.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
18pub struct CaseRun {
19    /// The test case name.
20    pub case: String,
21    /// Absolute-ish path to the skill that was exercised.
22    pub skill: String,
23    /// The harness platform this run used.
24    pub platform: String,
25    /// The model this run used.
26    pub model: String,
27    /// True iff every eval in this run passed.
28    pub passed: bool,
29    /// Number of assistant turns produced.
30    pub turns: usize,
31    /// Per-eval outcomes, in declaration order.
32    pub evals: Vec<EvalOutcome>,
33    /// The full conversation, for debugging and deterministic mix-in checks.
34    pub transcript: Transcript,
35    /// Aggregated token/cost usage across every provider call in this run
36    /// (skill turns + simulated-user turns + judge calls). Omitted when no
37    /// usage was reported (e.g. the fake provider or a harness that doesn't
38    /// surface usage).
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub usage: Option<Usage>,
41    /// Every tool call the mock/spy channel observed, in order, with the
42    /// original (pre-rewrite) input and the verdict applied. `null` when the
43    /// channel was off for this run (no `mocks`, no `spy`); an empty array
44    /// means the channel was on and the skill made no tool calls — SDKs use
45    /// that distinction so a spy on a channel-less run errs instead of reading
46    /// as "zero calls".
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub mock_calls: Option<Vec<MockCall>>,
49    /// A ready-to-run command that replays this run's recorded transcript — e.g.
50    /// `oneharness history show <name> --history-dir <dir>` — so a past run can
51    /// be reviewed after the fact. Present only when the run was recorded (the
52    /// oneharness provider with history enabled); `null` for providers/configs
53    /// that record no history.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub history_command: Option<String>,
56}
57
58/// Aggregate pass/fail counts for a report.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
60pub struct Summary {
61    /// Distinct test cases represented.
62    pub cases: usize,
63    /// Total (case × platform × model) runs.
64    pub runs: usize,
65    /// Runs that passed.
66    pub passed: usize,
67    /// Runs that failed.
68    pub failed: usize,
69    /// Aggregated token/cost usage across every run in the report. Omitted
70    /// when no run reported usage.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub usage: Option<Usage>,
73}
74
75/// The top-level report for a `skilltest run` invocation.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
77pub struct Report {
78    /// True iff every run passed.
79    pub passed: bool,
80    /// Aggregate counts.
81    pub summary: Summary,
82    /// Every individual run.
83    pub runs: Vec<CaseRun>,
84}
85
86impl Report {
87    /// Build a report from runs, computing the summary and overall pass.
88    #[must_use]
89    pub fn new(runs: Vec<CaseRun>) -> Self {
90        let mut case_names: Vec<&str> = runs.iter().map(|r| r.case.as_str()).collect();
91        case_names.sort_unstable();
92        case_names.dedup();
93        let passed_runs = runs.iter().filter(|r| r.passed).count();
94        let mut total_usage = Usage::default();
95        for run in &runs {
96            if let Some(u) = &run.usage {
97                total_usage.add(u);
98            }
99        }
100        let usage = (!total_usage.is_empty()).then_some(total_usage);
101        let summary = Summary {
102            cases: case_names.len(),
103            runs: runs.len(),
104            passed: passed_runs,
105            failed: runs.len() - passed_runs,
106            usage,
107        };
108        Report {
109            passed: summary.failed == 0 && !runs.is_empty(),
110            summary,
111            runs,
112        }
113    }
114
115    /// Serialize to pretty JSON (the `--format json` output).
116    ///
117    /// # Errors
118    /// [`serde_json::Error`] only if a contained value cannot serialize, which
119    /// should not happen for these types.
120    pub fn to_json(&self) -> Result<String, serde_json::Error> {
121        serde_json::to_string_pretty(self)
122    }
123
124    /// A compact, human-readable summary line per run plus a total. Quiet by
125    /// design: this is context the next reader has to parse.
126    #[must_use]
127    pub fn to_human(&self) -> String {
128        let mut out = String::new();
129        for run in &self.runs {
130            let mark = if run.passed { "PASS" } else { "FAIL" };
131            out.push_str(&format!(
132                "{mark}  {} [{}/{}]\n",
133                run.case, run.platform, run.model
134            ));
135            for eval in &run.evals {
136                if !eval.passed {
137                    out.push_str(&format!(
138                        "      - {}: {} ({})\n",
139                        eval.label,
140                        eval.detail.summary(),
141                        eval.reason
142                    ));
143                }
144            }
145            // Surface the review command so a past run can be replayed straight
146            // from the terminal (oneharness provider with history enabled).
147            if let Some(cmd) = &run.history_command {
148                out.push_str(&format!("      history: {cmd}\n"));
149            }
150        }
151        out.push_str(&format!(
152            "{}/{} runs passed\n",
153            self.summary.passed, self.summary.runs
154        ));
155        if let Some(usage) = &self.summary.usage {
156            let mut parts = Vec::new();
157            if let Some(cost) = usage.cost_usd {
158                parts.push(format!("${cost:.4}"));
159            }
160            if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
161                parts.push(format!("{} in / {} out tokens", i, o));
162            } else {
163                if let Some(i) = usage.input_tokens {
164                    parts.push(format!("{i} input tokens"));
165                }
166                if let Some(o) = usage.output_tokens {
167                    parts.push(format!("{o} output tokens"));
168                }
169            }
170            if !parts.is_empty() {
171                out.push_str(&format!("usage: {}\n", parts.join(", ")));
172            }
173        }
174        out
175    }
176}
177
178/// One problem found while validating a skill, as serialized in the
179/// `skilltest validate --format json` output.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
181pub struct ValidationFinding {
182    /// The skill directory the finding is about.
183    pub skill: String,
184    /// What is wrong and how to fix it.
185    pub message: String,
186}
187
188/// The top-level report for a `skilltest validate` invocation.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190pub struct ValidationReport {
191    /// True iff no findings were produced.
192    pub valid: bool,
193    /// Every finding, in discovery order.
194    pub findings: Vec<ValidationFinding>,
195}
196
197impl ValidationReport {
198    /// Build a validation report from raw findings.
199    #[must_use]
200    pub fn new(findings: &[Finding]) -> Self {
201        ValidationReport {
202            valid: findings.is_empty(),
203            findings: findings
204                .iter()
205                .map(|f| ValidationFinding {
206                    skill: f.skill.to_string_lossy().into_owned(),
207                    message: f.message.clone(),
208                })
209                .collect(),
210        }
211    }
212
213    /// Serialize to pretty JSON (the `--format json` output).
214    ///
215    /// # Errors
216    /// [`serde_json::Error`] only if a contained value cannot serialize, which
217    /// should not happen for these types.
218    pub fn to_json(&self) -> Result<String, serde_json::Error> {
219        serde_json::to_string_pretty(self)
220    }
221}
222
223/// Which class of failure a [`ReportError`] describes. Mirrors the process exit
224/// code so a JSON consumer gets the same coarse classification as a shell script
225/// branching on `$?`.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
227#[serde(rename_all = "snake_case")]
228pub enum ErrorCode {
229    /// Bad usage or input (exit 2): malformed config/case YAML, a missing file,
230    /// a semantically invalid test definition.
231    Usage,
232    /// A provider/environment failure (exit 3): the harness or judge could not
233    /// be reached or misbehaved.
234    Provider,
235}
236
237/// A structured error, emitted as the `--format json` / `json-stream` output
238/// when a `skilltest run` cannot produce a [`Report`].
239///
240/// This is the machine-readable counterpart to the human hint the CLI prints on
241/// stderr: it rides on stdout so SDK/plugin consumers get the [`ProviderErrorKind`]
242/// (and the `code`/`context`) for targeted handling — retry on
243/// [`ProviderErrorKind::Timeout`], fail fast on [`ProviderErrorKind::Auth`] —
244/// instead of matching substrings in the message. For `json` the object is
245/// emitted bare; for `json-stream` it is the terminal
246/// `{"type":"error","error":{…}}` line.
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
248pub struct ReportError {
249    /// The coarse failure class, matching the process exit code.
250    pub code: ErrorCode,
251    /// The structured provider-failure category, when skilltest could classify
252    /// it. Absent for usage errors and for unclassified provider failures.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub kind: Option<ProviderErrorKind>,
255    /// The provider context the failure came from (e.g. `oneharness:claude-code`,
256    /// `api-judge`). Absent for usage errors.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub context: Option<String>,
259    /// A human-readable description of what went wrong (the same text printed on
260    /// stderr, minus the suggested-action hint).
261    pub message: String,
262}
263
264impl ReportError {
265    /// Build the structured error for a core [`Error`]. The mapping mirrors the
266    /// CLI's error→exit-code mapping (`report_error`): [`Error::Provider`] is a
267    /// provider failure carrying its `kind`/`context`; everything else is a
268    /// usage error.
269    #[must_use]
270    pub fn from_error(err: &Error) -> Self {
271        match err {
272            Error::Provider {
273                context,
274                message,
275                kind,
276            } => ReportError {
277                code: ErrorCode::Provider,
278                kind: *kind,
279                context: Some(context.clone()),
280                message: message.clone(),
281            },
282            other => ReportError {
283                code: ErrorCode::Usage,
284                kind: None,
285                context: None,
286                message: other.to_string(),
287            },
288        }
289    }
290
291    /// Serialize to pretty JSON (the bare `--format json` error output).
292    ///
293    /// # Errors
294    /// [`serde_json::Error`] only if a contained value cannot serialize, which
295    /// should not happen for these types.
296    pub fn to_json(&self) -> Result<String, serde_json::Error> {
297        serde_json::to_string_pretty(self)
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::conversation::Transcript;
305    use crate::eval::{Comparator, EvalDetail, EvalOutcome};
306
307    fn run(case: &str, passed: bool, evals: Vec<EvalOutcome>, usage: Option<Usage>) -> CaseRun {
308        CaseRun {
309            case: case.to_string(),
310            skill: "/tmp/skill".to_string(),
311            platform: "claude-code".to_string(),
312            model: "sonnet".to_string(),
313            passed,
314            turns: 1,
315            evals,
316            transcript: Transcript::from_input("hi"),
317            usage,
318            mock_calls: None,
319            history_command: None,
320        }
321    }
322
323    fn bool_eval(label: &str, passed: bool) -> EvalOutcome {
324        EvalOutcome {
325            label: label.to_string(),
326            passed,
327            detail: EvalDetail::Boolean {
328                value: passed,
329                expected: true,
330            },
331            reason: "because".to_string(),
332        }
333    }
334
335    #[test]
336    fn new_computes_summary_and_dedups_cases() {
337        let report = Report::new(vec![
338            run("a", true, vec![bool_eval("x", true)], None),
339            run("a", false, vec![bool_eval("y", false)], None),
340            run("b", true, vec![bool_eval("z", true)], None),
341        ]);
342        // Two distinct cases, three runs, one failure -> overall fail.
343        assert_eq!(report.summary.cases, 2);
344        assert_eq!(report.summary.runs, 3);
345        assert_eq!(report.summary.passed, 2);
346        assert_eq!(report.summary.failed, 1);
347        assert!(!report.passed);
348        // No run reported usage, so the summary omits it.
349        assert!(report.summary.usage.is_none());
350    }
351
352    #[test]
353    fn empty_report_is_not_passed() {
354        let report = Report::new(vec![]);
355        assert!(!report.passed, "an empty run set is not a pass");
356        assert_eq!(report.summary.runs, 0);
357    }
358
359    #[test]
360    fn new_aggregates_usage_across_runs() {
361        let report = Report::new(vec![
362            run(
363                "a",
364                true,
365                vec![bool_eval("x", true)],
366                Some(Usage {
367                    input_tokens: Some(10),
368                    output_tokens: Some(2),
369                    cost_usd: Some(0.01),
370                }),
371            ),
372            run(
373                "b",
374                true,
375                vec![bool_eval("y", true)],
376                Some(Usage {
377                    input_tokens: Some(5),
378                    output_tokens: None,
379                    cost_usd: Some(0.02),
380                }),
381            ),
382        ]);
383        let usage = report.summary.usage.unwrap();
384        assert_eq!(usage.input_tokens, Some(15));
385        assert_eq!(usage.output_tokens, Some(2));
386        assert!((usage.cost_usd.unwrap() - 0.03).abs() < 1e-9);
387    }
388
389    #[test]
390    fn to_json_round_trips() {
391        let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
392        let json = report.to_json().unwrap();
393        let parsed: Report = serde_json::from_str(&json).unwrap();
394        assert_eq!(parsed, report);
395    }
396
397    #[test]
398    fn to_human_lists_runs_and_failed_evals() {
399        let numeric = EvalOutcome {
400            label: "warmth".to_string(),
401            passed: false,
402            detail: EvalDetail::Numeric {
403                value: 4.0,
404                threshold: 7.0,
405                comparator: Comparator::Gte,
406            },
407            reason: "too cold".to_string(),
408        };
409        let report = Report::new(vec![
410            run("greets", true, vec![bool_eval("names", true)], None),
411            run("warm", false, vec![numeric], None),
412        ]);
413        let human = report.to_human();
414        assert!(human.contains("PASS  greets [claude-code/sonnet]"));
415        assert!(human.contains("FAIL  warm"));
416        // Only the failing eval is itemized, with its summary and reason.
417        assert!(human.contains("warmth: 4 >= 7 (too cold)"), "got:\n{human}");
418        assert!(human.contains("1/2 runs passed"));
419    }
420
421    #[test]
422    fn to_human_renders_usage_line_variants() {
423        // Cost + both token counts.
424        let full = Report::new(vec![run(
425            "a",
426            true,
427            vec![bool_eval("x", true)],
428            Some(Usage {
429                input_tokens: Some(100),
430                output_tokens: Some(50),
431                cost_usd: Some(0.1234),
432            }),
433        )]);
434        let human = full.to_human();
435        assert!(
436            human.contains("usage: $0.1234, 100 in / 50 out tokens"),
437            "got:\n{human}"
438        );
439
440        // Only an input-token count (no cost, no output) hits the singular branch.
441        let partial = Report::new(vec![run(
442            "a",
443            true,
444            vec![bool_eval("x", true)],
445            Some(Usage {
446                input_tokens: Some(7),
447                output_tokens: None,
448                cost_usd: None,
449            }),
450        )]);
451        assert!(partial.to_human().contains("usage: 7 input tokens"));
452
453        // Only an output-token count.
454        let out_only = Report::new(vec![run(
455            "a",
456            true,
457            vec![bool_eval("x", true)],
458            Some(Usage {
459                input_tokens: None,
460                output_tokens: Some(9),
461                cost_usd: None,
462            }),
463        )]);
464        assert!(out_only.to_human().contains("usage: 9 output tokens"));
465    }
466
467    #[test]
468    fn to_human_without_usage_has_no_usage_line() {
469        let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
470        assert!(!report.to_human().contains("usage:"));
471    }
472
473    #[test]
474    fn to_human_shows_history_command_when_present() {
475        let mut with = run("a", true, vec![bool_eval("x", true)], None);
476        with.history_command = Some("oneharness history show sess --history-dir /h".into());
477        let report = Report::new(vec![with]);
478        let human = report.to_human();
479        assert!(
480            human.contains("history: oneharness history show sess --history-dir /h"),
481            "got:\n{human}"
482        );
483        // Runs that recorded nothing show no history line.
484        let without = Report::new(vec![run("b", true, vec![bool_eval("x", true)], None)]);
485        assert!(!without.to_human().contains("history:"));
486    }
487
488    #[test]
489    fn validation_report_new_and_json() {
490        use crate::skill::Finding;
491        let empty = ValidationReport::new(&[]);
492        assert!(empty.valid);
493        assert!(empty.findings.is_empty());
494
495        let findings = vec![
496            Finding {
497                skill: std::path::PathBuf::from("/tmp/a"),
498                message: "missing name".to_string(),
499            },
500            Finding {
501                skill: std::path::PathBuf::from("/tmp/b"),
502                message: "no body".to_string(),
503            },
504        ];
505        let report = ValidationReport::new(&findings);
506        assert!(!report.valid);
507        assert_eq!(report.findings.len(), 2);
508        assert_eq!(report.findings[0].skill, "/tmp/a");
509        let json = report.to_json().unwrap();
510        let parsed: ValidationReport = serde_json::from_str(&json).unwrap();
511        assert_eq!(parsed, report);
512    }
513
514    #[test]
515    fn report_error_from_classified_provider_error() {
516        let err = Error::provider_classified(
517            "oneharness:claude-code",
518            "harness run failed: deadline",
519            ProviderErrorKind::Timeout,
520        );
521        let structured = ReportError::from_error(&err);
522        assert_eq!(structured.code, ErrorCode::Provider);
523        assert_eq!(structured.kind, Some(ProviderErrorKind::Timeout));
524        assert_eq!(
525            structured.context.as_deref(),
526            Some("oneharness:claude-code")
527        );
528        assert!(structured.message.contains("deadline"));
529        // Round-trips through the JSON contract, kind as a snake_case string.
530        let json = structured.to_json().unwrap();
531        assert!(json.contains("\"kind\": \"timeout\""));
532        let parsed: ReportError = serde_json::from_str(&json).unwrap();
533        assert_eq!(parsed, structured);
534    }
535
536    #[test]
537    fn report_error_from_unclassified_provider_error() {
538        let err = Error::provider("oneharness", "boom");
539        let structured = ReportError::from_error(&err);
540        assert_eq!(structured.code, ErrorCode::Provider);
541        assert_eq!(structured.kind, None);
542        assert_eq!(structured.context.as_deref(), Some("oneharness"));
543    }
544
545    #[test]
546    fn report_error_from_usage_error() {
547        let structured = ReportError::from_error(&Error::Invalid("bad case".into()));
548        assert_eq!(structured.code, ErrorCode::Usage);
549        assert_eq!(structured.kind, None);
550        assert!(structured.context.is_none());
551        assert!(structured.message.contains("bad case"));
552    }
553}