Skip to main content

skilltest_core/
provider.rs

1//! The provider boundary. `skilltest` never talks to a model directly; a
2//! [`Provider`] runs the skill, plays the simulated user, and judges the
3//! transcript.
4//!
5//! There are two real implementations. [`OneharnessProvider`] (the default) runs
6//! each prompt on a harness through the
7//! [`oneharness`](https://github.com/nickderobertis/oneharness) CLI and parses
8//! its JSON. [`CommandProvider`] speaks a small JSON-lines protocol (see
9//! `docs/protocol.md`) and backs both the deterministic `skilltest-fake-provider`
10//! used by the gate and any custom provider you write. The [`Provider`] trait
11//! also lets the runner be unit-tested against an in-memory fake.
12
13use std::io::{BufRead as _, BufReader, Write as _};
14use std::ops::ControlFlow;
15use std::process::{Command, Stdio};
16
17use serde::{Deserialize, Serialize};
18
19use crate::config::{ApiJudgeConfig, ApiVendor, OneharnessConfig};
20use crate::conversation::{Message, Role, ToolEvent};
21use crate::error::{Error, Result};
22use crate::eval::JudgeValue;
23
24/// A borrowed view of the skill under test, as sent to the provider.
25pub struct SkillRef<'a> {
26    pub name: &'a str,
27    pub dir: &'a str,
28    pub instructions: &'a str,
29}
30
31/// The kind of judgement requested.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum JudgeKind {
34    Boolean,
35    Numeric,
36}
37
38impl JudgeKind {
39    fn as_str(self) -> &'static str {
40        match self {
41            JudgeKind::Boolean => "boolean",
42            JudgeKind::Numeric => "numeric",
43        }
44    }
45}
46
47/// A judge query: the criterion, its kind, and (for numeric) the scale.
48pub struct JudgeQuery<'a> {
49    pub kind: JudgeKind,
50    pub criterion: &'a str,
51    pub scale: Option<(f64, f64)>,
52}
53
54/// Token / cost usage for one provider call.
55///
56/// Each field is independently optional because not every harness reports every
57/// signal (cost is commonly absent on subscription auth; some harnesses report
58/// no usage at all). The whole struct is `Option<Usage>` on a turn — `None`
59/// means "no signal," not "zero."
60#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
61pub struct Usage {
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub input_tokens: Option<u64>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub output_tokens: Option<u64>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub cost_usd: Option<f64>,
68}
69
70impl Usage {
71    /// True iff every field is `None`.
72    #[must_use]
73    pub fn is_empty(&self) -> bool {
74        self.input_tokens.is_none() && self.output_tokens.is_none() && self.cost_usd.is_none()
75    }
76
77    /// Add another sample into this total. `None` values stay `None` until
78    /// something reports a real number, at which point they accumulate.
79    pub fn add(&mut self, other: &Usage) {
80        if let Some(v) = other.input_tokens {
81            self.input_tokens = Some(self.input_tokens.unwrap_or(0) + v);
82        }
83        if let Some(v) = other.output_tokens {
84            self.output_tokens = Some(self.output_tokens.unwrap_or(0) + v);
85        }
86        if let Some(v) = other.cost_usd {
87            self.cost_usd = Some(self.cost_usd.unwrap_or(0.0) + v);
88        }
89    }
90}
91
92/// An assistant/skill turn produced by the provider.
93#[derive(Debug, Clone, Default)]
94pub struct AssistantTurn {
95    pub message: String,
96    /// The skill signalled it considers the task complete.
97    pub done: bool,
98    /// Cost/token usage for this call, if the provider reported it.
99    pub usage: Option<Usage>,
100    /// A session handle the runner can pass back on the next `respond` call to
101    /// continue the same conversation against the real harness (only some
102    /// harnesses expose this — see `OneharnessProvider::supports_resume`).
103    pub session_id: Option<String>,
104    /// Normalized tool events the skill took this turn (shell commands, file
105    /// edits, tool uses), from oneharness `--events`. Empty when the harness
106    /// exposed no tool transcript. Attached to the assistant message so consumers
107    /// can analyze — and stream — what the skill *did*.
108    pub events: Vec<ToolEvent>,
109}
110
111/// A simulated-user turn produced by the provider.
112#[derive(Debug, Clone, Default)]
113pub struct UserTurn {
114    pub message: String,
115    /// The simulated user chose to end the conversation.
116    pub stop: bool,
117    pub usage: Option<Usage>,
118}
119
120/// A judge verdict: the raw value (bool or number) plus the stated reason.
121#[derive(Debug, Clone)]
122pub struct JudgeVerdict {
123    pub value: JudgeValue,
124    pub reason: String,
125    pub usage: Option<Usage>,
126}
127
128/// The provider boundary.
129pub trait Provider {
130    /// Run one assistant/skill turn given the conversation so far. `session`,
131    /// when `Some`, is a handle returned by a previous `respond` call on this
132    /// run that the provider may use to continue the same harness session
133    /// (e.g. via `oneharness run --resume`); providers that don't support
134    /// continuation should ignore it.
135    ///
136    /// # Errors
137    /// [`Error::Provider`] if the command fails or returns malformed output.
138    fn respond(
139        &self,
140        platform: &str,
141        model: &str,
142        skill: &SkillRef<'_>,
143        messages: &[Message],
144        session: Option<&str>,
145    ) -> Result<AssistantTurn>;
146
147    /// Like [`Provider::respond`], but delivers each normalized tool event to
148    /// `on_event` as it is observed, so a caller can stream events live and
149    /// short-circuit. `on_event` returns [`ControlFlow::Break`] to abort the
150    /// turn — the provider tears down the harness and returns the partial turn.
151    ///
152    /// The default implementation runs the buffered [`Provider::respond`] and
153    /// replays the finished turn's events once; providers that can stream (like
154    /// [`OneharnessProvider`], via `oneharness --stream`) override it so events
155    /// arrive — and an abort takes effect — mid-turn.
156    ///
157    /// # Errors
158    /// [`Error::Provider`] if the command fails or returns malformed output.
159    fn respond_streaming(
160        &self,
161        platform: &str,
162        model: &str,
163        skill: &SkillRef<'_>,
164        messages: &[Message],
165        session: Option<&str>,
166        on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
167    ) -> Result<AssistantTurn> {
168        let turn = self.respond(platform, model, skill, messages, session)?;
169        for event in &turn.events {
170            if on_event(event).is_break() {
171                break;
172            }
173        }
174        Ok(turn)
175    }
176
177    /// Produce one simulated-user turn.
178    ///
179    /// # Errors
180    /// [`Error::Provider`] if the command fails or returns malformed output.
181    fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn>;
182
183    /// Score a criterion against the conversation.
184    ///
185    /// # Errors
186    /// [`Error::Provider`] if the command fails or returns malformed output.
187    fn judge(
188        &self,
189        model: &str,
190        query: &JudgeQuery<'_>,
191        messages: &[Message],
192    ) -> Result<JudgeVerdict>;
193
194    /// True iff `respond` on `platform` will faithfully continue a prior
195    /// session when given its `session_id`. The default is `false`; providers
196    /// that support resume override this so the runner knows to thread the
197    /// session id through.
198    fn supports_resume(&self, _platform: &str) -> bool {
199        false
200    }
201}
202
203// ---------------------------------------------------------------------------
204// Wire types (CommandProvider JSON-lines protocol)
205// ---------------------------------------------------------------------------
206
207#[derive(Serialize)]
208struct SkillPayload<'a> {
209    name: &'a str,
210    path: &'a str,
211    instructions: &'a str,
212}
213
214#[derive(Serialize)]
215#[serde(tag = "op", rename_all = "lowercase")]
216enum Request<'a> {
217    Respond {
218        platform: &'a str,
219        model: &'a str,
220        skill: SkillPayload<'a>,
221        messages: &'a [Message],
222        #[serde(skip_serializing_if = "Option::is_none")]
223        session: Option<&'a str>,
224    },
225    User {
226        model: &'a str,
227        persona: &'a str,
228        messages: &'a [Message],
229    },
230    Judge {
231        model: &'a str,
232        kind: &'a str,
233        criterion: &'a str,
234        #[serde(skip_serializing_if = "Option::is_none")]
235        min: Option<f64>,
236        #[serde(skip_serializing_if = "Option::is_none")]
237        max: Option<f64>,
238        messages: &'a [Message],
239    },
240}
241
242#[derive(Deserialize)]
243struct RespondPayload {
244    message: String,
245    #[serde(default)]
246    done: bool,
247    #[serde(default)]
248    usage: Option<Usage>,
249    #[serde(default)]
250    session_id: Option<String>,
251    /// Optional normalized tool events a custom provider may report (parallel to
252    /// oneharness's `events`); absent/`null` when the provider surfaces none.
253    #[serde(default)]
254    events: Option<Vec<ToolEvent>>,
255}
256
257#[derive(Deserialize)]
258struct UserPayload {
259    message: String,
260    #[serde(default)]
261    stop: bool,
262    #[serde(default)]
263    usage: Option<Usage>,
264}
265
266#[derive(Deserialize)]
267struct JudgePayload {
268    value: JudgeValue,
269    #[serde(default)]
270    reason: String,
271    #[serde(default)]
272    usage: Option<Usage>,
273}
274
275// ---------------------------------------------------------------------------
276// CommandProvider
277// ---------------------------------------------------------------------------
278
279/// A [`Provider`] backed by an external command speaking the JSON protocol.
280pub struct CommandProvider {
281    argv: Vec<String>,
282}
283
284impl CommandProvider {
285    /// Build a provider from an argv vector (program + args). The program is
286    /// resolved on `PATH`.
287    ///
288    /// # Errors
289    /// [`Error::Invalid`] if `argv` is empty.
290    pub fn new(argv: Vec<String>) -> Result<Self> {
291        if argv.is_empty() {
292            return Err(Error::Invalid("provider command is empty".into()));
293        }
294        Ok(Self { argv })
295    }
296
297    /// Send one request and parse the single response object from stdout.
298    fn call<T: for<'de> Deserialize<'de>>(&self, request: &Request<'_>, op: &str) -> Result<T> {
299        let payload = serde_json::to_vec(request).map_err(|e| {
300            Error::provider(op.to_string(), format!("could not encode request: {e}"))
301        })?;
302
303        let mut child = Command::new(&self.argv[0])
304            .args(&self.argv[1..])
305            .stdin(Stdio::piped())
306            .stdout(Stdio::piped())
307            .stderr(Stdio::piped())
308            .spawn()
309            .map_err(|e| {
310                Error::provider(
311                    op.to_string(),
312                    format!(
313                        "could not run provider `{}`: {e}. Is it installed and on PATH?",
314                        self.argv[0]
315                    ),
316                )
317            })?;
318
319        // Write the request, then close stdin so the child can finish. Writing
320        // before reading stdout is safe here because responses are small.
321        {
322            let stdin = child
323                .stdin
324                .as_mut()
325                .ok_or_else(|| Error::provider(op.to_string(), "could not open provider stdin"))?;
326            stdin
327                .write_all(&payload)
328                .and_then(|()| stdin.write_all(b"\n"))
329                .map_err(|e| {
330                    Error::provider(op.to_string(), format!("could not write request: {e}"))
331                })?;
332        }
333
334        let output = child.wait_with_output().map_err(|e| {
335            Error::provider(op.to_string(), format!("provider did not complete: {e}"))
336        })?;
337
338        if !output.status.success() {
339            let stderr = String::from_utf8_lossy(&output.stderr);
340            return Err(Error::provider(
341                op.to_string(),
342                format!("provider exited with {}: {}", output.status, stderr.trim()),
343            ));
344        }
345
346        let stdout = String::from_utf8_lossy(&output.stdout);
347        let line = stdout.trim();
348        if line.is_empty() {
349            return Err(Error::provider(
350                op.to_string(),
351                "provider produced no output (expected one JSON response object)",
352            ));
353        }
354        serde_json::from_str(line).map_err(|e| {
355            Error::provider(
356                op.to_string(),
357                format!("provider response was not valid JSON for `{op}`: {e}; got: {line}"),
358            )
359        })
360    }
361}
362
363impl Provider for CommandProvider {
364    fn respond(
365        &self,
366        platform: &str,
367        model: &str,
368        skill: &SkillRef<'_>,
369        messages: &[Message],
370        session: Option<&str>,
371    ) -> Result<AssistantTurn> {
372        let request = Request::Respond {
373            platform,
374            model,
375            skill: SkillPayload {
376                name: skill.name,
377                path: skill.dir,
378                instructions: skill.instructions,
379            },
380            messages,
381            session,
382        };
383        let payload: RespondPayload = self.call(&request, "respond")?;
384        Ok(AssistantTurn {
385            message: payload.message,
386            done: payload.done,
387            usage: payload.usage,
388            session_id: payload.session_id,
389            events: payload.events.unwrap_or_default(),
390        })
391    }
392
393    fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
394        let request = Request::User {
395            model,
396            persona,
397            messages,
398        };
399        let payload: UserPayload = self.call(&request, "user")?;
400        Ok(UserTurn {
401            message: payload.message,
402            stop: payload.stop,
403            usage: payload.usage,
404        })
405    }
406
407    fn judge(
408        &self,
409        model: &str,
410        query: &JudgeQuery<'_>,
411        messages: &[Message],
412    ) -> Result<JudgeVerdict> {
413        let (min, max) = match query.scale {
414            Some((lo, hi)) => (Some(lo), Some(hi)),
415            None => (None, None),
416        };
417        let request = Request::Judge {
418            model,
419            kind: query.kind.as_str(),
420            criterion: query.criterion,
421            min,
422            max,
423            messages,
424        };
425        let payload: JudgePayload = self.call(&request, "judge")?;
426        Ok(JudgeVerdict {
427            value: payload.value,
428            reason: payload.reason,
429            usage: payload.usage,
430        })
431    }
432}
433
434// ---------------------------------------------------------------------------
435// OneharnessProvider
436// ---------------------------------------------------------------------------
437
438/// The default [`Provider`]: runs each prompt on a harness through the
439/// `oneharness` CLI (targets **v0.3.6+**).
440///
441/// Wires five real oneharness features:
442///
443/// * `--system <skill instructions>` — the skill becomes a *real* system prompt
444///   on the underlying harness (e.g. `--append-system-prompt` for claude-code),
445///   instead of being inlined into the user message.
446/// * `--resume <session>` — multi-turn `respond` calls thread the previous
447///   `session_id` so the harness sees a continuing conversation (and keeps its
448///   tool state, files, etc.) instead of being re-prompted with a stringified
449///   transcript. Used only for harnesses that report `supports_resume` in the
450///   registry (claude-code, opencode, cursor today); other harnesses fall back
451///   to the inline-transcript path.
452/// * `--events` — normalized tool events (`{kind, name, input, output, index}`)
453///   lifted from each harness's transcript, so consumers can analyze *what the
454///   skill did*, not just its final text. Attached to the assistant turn.
455/// * Normalized `usage` (`input_tokens`, `output_tokens`, `cost_usd`) — surfaced
456///   on every turn so cross-model cost reporting is portable.
457/// * Normalized `failure_kind` (`auth`, `rate_limit`, `model_not_found`, …) —
458///   classified provider errors so the CLI can distinguish a broken environment
459///   from a broken skill.
460///
461/// Note on approval mode: skilltest deliberately passes **no `--mode`** flag, so
462/// oneharness applies its own default (v0.3.0+ normalized approval modes). Users
463/// who need a different mode — e.g. `bypass` to let the skill take every action
464/// without prompting — configure it through oneharness's own config
465/// (`ONEHARNESS_MODE` / its config file), keeping approval policy in one place.
466///
467/// Evals and the simulated user always run on the configured `judge_harness`,
468/// independent of the harness under test, so the evaluator does not drift with
469/// the matrix.
470pub struct OneharnessProvider {
471    bin: String,
472    judge_harness: String,
473    timeout_secs: u64,
474}
475
476/// The subset of the `oneharness run` JSON envelope we consume.
477#[derive(Deserialize)]
478struct OhEnvelope {
479    results: Vec<OhResult>,
480}
481
482#[derive(Deserialize)]
483struct OhResult {
484    status: String,
485    #[serde(default)]
486    text: Option<String>,
487    /// Raw harness stdout. oneharness's `text` extraction is best-effort and may
488    /// be null when a harness's output shape defeats it, with stdout as the
489    /// documented fallback; we honor that rather than hard-failing. No harness in
490    /// the live matrix relies on it today (OpenCode's JSONL — the case that
491    /// motivated this — is extracted natively as of oneharness v0.2.37), but the
492    /// contract holds for any harness, so the fallback stays as defense-in-depth.
493    #[serde(default)]
494    stdout: String,
495    #[serde(default)]
496    stderr: String,
497    #[serde(default)]
498    error: Option<String>,
499    #[serde(default)]
500    session_id: Option<String>,
501    #[serde(default)]
502    usage: Option<Usage>,
503    /// Normalized tool events oneharness lifted from the harness transcript (its
504    /// `--events` output); `null`/absent when the harness exposes none.
505    #[serde(default)]
506    events: Option<Vec<ToolEvent>>,
507    #[serde(default)]
508    failure_kind: Option<String>,
509}
510
511/// Parameters for one `oneharness run` invocation.
512struct RunArgs<'a> {
513    harness: &'a str,
514    model: &'a str,
515    prompt: &'a str,
516    /// Becomes `--system <text>`; only set on `respond` so the skill is the
517    /// system prompt rather than inlined into the user turn.
518    system: Option<&'a str>,
519    /// Becomes `--resume <id>`; only set when the runner wants to continue a
520    /// prior harness session.
521    resume: Option<&'a str>,
522}
523
524/// What we get back from one `oneharness run`.
525struct RunOutcome {
526    text: String,
527    session_id: Option<String>,
528    usage: Option<Usage>,
529    events: Vec<ToolEvent>,
530}
531
532/// Choose the harness's reply text: oneharness's extracted `text` when non-empty,
533/// otherwise its raw stdout. oneharness extracts `text` on a best-effort basis
534/// and, per its contract, may leave it null when a harness's output shape defeats
535/// extraction — the reply still survives in stdout. (OpenCode's JSONL once hit
536/// this; oneharness v0.2.37 extracts it natively, so the fallback is now
537/// defense-in-depth.) Returns `None` only when both are empty, the one case that
538/// is a genuine "the harness said nothing" error.
539fn select_reply_text(text: Option<String>, stdout: &str) -> Option<String> {
540    text.filter(|t| !t.trim().is_empty())
541        .or_else(|| (!stdout.trim().is_empty()).then(|| stdout.to_string()))
542}
543
544impl OneharnessProvider {
545    /// Build a provider from its configuration.
546    #[must_use]
547    pub fn new(config: &OneharnessConfig) -> Self {
548        Self {
549            bin: config.bin.clone(),
550            judge_harness: config.judge_harness.clone(),
551            timeout_secs: config.timeout_secs,
552        }
553    }
554
555    /// Run one prompt on `harness` and return the normalized text plus the
556    /// session id and usage (when oneharness lifted them from the harness's
557    /// output).
558    fn run(&self, args: &RunArgs<'_>) -> Result<RunOutcome> {
559        let timeout = self.timeout_secs.to_string();
560        let mut cmd = Command::new(&self.bin);
561        // Intentionally no `--output-format` override: oneharness already requests
562        // each harness's *default* format (json for claude-code/opencode,
563        // stream-json for cursor, text for codex/goose/qwen/crush/copilot) and
564        // extracts the reply accordingly. Forcing `json` everywhere broke the
565        // text-native harnesses — oneharness would json-extract their plain-text
566        // reply and find nothing ("harness produced no extractable text").
567        //
568        // `--events` asks oneharness to surface normalized tool events. It is safe
569        // for text extraction: oneharness only upgrades a harness whose default
570        // format carries no tool transcript to its events-capable format
571        // (claude→stream-json, codex→exec --json, qwen→stream-json) and still
572        // extracts the reply from it; harnesses whose default already carries a
573        // transcript (opencode, cursor) or expose none (goose/crush/copilot) are
574        // left on their default. So the reply keeps working everywhere and
575        // `events` is populated wherever the harness can express it.
576        //
577        // No `--mode`: oneharness applies its own default approval mode; users
578        // tune it (e.g. `bypass`) via oneharness config, not from here.
579        cmd.args([
580            "run",
581            "--harness",
582            args.harness,
583            "--compact",
584            "--events",
585            "--timeout",
586            &timeout,
587            "--prompt-file",
588            "-",
589        ]);
590        // An empty model means "unspecified" — omit `--model` so the harness uses
591        // its own default (cursor/crush/copilot) or an env-selected model (qwen
592        // via OPENAI_MODEL, goose via GOOSE_MODEL), exactly as oneharness's own
593        // smoke scripts do. Forwarding `--model ""` would push a broken empty
594        // model flag to the harness CLI.
595        if !args.model.is_empty() {
596            cmd.args(["--model", args.model]);
597        }
598        if let Some(system) = args.system {
599            cmd.args(["--system", system]);
600        }
601        if let Some(resume) = args.resume {
602            cmd.args(["--resume", resume]);
603        }
604
605        let mut child = cmd
606            .stdin(Stdio::piped())
607            .stdout(Stdio::piped())
608            .stderr(Stdio::piped())
609            .spawn()
610            .map_err(|e| {
611                Error::provider(
612                    "oneharness",
613                    format!(
614                        "could not run `{}`: {e}. Is oneharness installed and on PATH?",
615                        self.bin
616                    ),
617                )
618            })?;
619
620        child
621            .stdin
622            .as_mut()
623            .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?
624            .write_all(args.prompt.as_bytes())
625            .map_err(|e| Error::provider("oneharness", format!("could not write prompt: {e}")))?;
626
627        let output = child.wait_with_output().map_err(|e| {
628            Error::provider("oneharness", format!("oneharness did not complete: {e}"))
629        })?;
630
631        let stdout = String::from_utf8_lossy(&output.stdout);
632        let envelope: OhEnvelope = serde_json::from_str(stdout.trim()).map_err(|e| {
633            Error::provider(
634                "oneharness",
635                format!(
636                    "could not parse oneharness output: {e}; stderr: {}",
637                    String::from_utf8_lossy(&output.stderr).trim()
638                ),
639            )
640        })?;
641
642        let result = envelope
643            .results
644            .into_iter()
645            .next()
646            .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
647
648        if result.status != "ok" {
649            let detail = result
650                .error
651                .filter(|e| !e.is_empty())
652                .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
653                .unwrap_or_else(|| format!("status `{}`", result.status));
654            let context = format!("oneharness:{}", args.harness);
655            let message = format!("harness run failed: {detail}");
656            return Err(match result.failure_kind {
657                Some(kind) if !kind.is_empty() => {
658                    Error::provider_classified(context, message, kind)
659                }
660                _ => Error::provider(context, message),
661            });
662        }
663
664        // Prefer oneharness's extracted `text`; fall back to raw stdout when a
665        // harness's output shape defeats extraction (oneharness's documented
666        // contract — see OhResult::stdout). Only a run that produced *neither* is
667        // a real error.
668        let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
669            Error::provider(
670                format!("oneharness:{}", args.harness),
671                "harness produced neither extractable text nor stdout",
672            )
673        })?;
674        Ok(RunOutcome {
675            text,
676            session_id: result.session_id,
677            usage: result.usage,
678            events: result.events.unwrap_or_default(),
679        })
680    }
681
682    /// Like [`OneharnessProvider::run`], but drives `oneharness run --stream`,
683    /// forwarding each normalized tool event to `on_event` the instant it is
684    /// observed. When `on_event` returns [`ControlFlow::Break`], the oneharness
685    /// child is killed — closing its stream tears the harness down, so a bad turn
686    /// is cut off instead of paid for in full — and the partial outcome (the
687    /// events seen so far) is returned.
688    fn run_streaming(
689        &self,
690        args: &RunArgs<'_>,
691        on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
692    ) -> Result<RunOutcome> {
693        let timeout = self.timeout_secs.to_string();
694        let mut cmd = Command::new(&self.bin);
695        // `--stream` emits NDJSON: one `{"type":"event",…}` line per tool event
696        // as observed, then a terminal `{"type":"result","report":{…}}`. It
697        // implies `--events`; no `--compact` (the stream is line-oriented) and no
698        // `--mode` (oneharness's default applies — see `run`).
699        cmd.args([
700            "run",
701            "--harness",
702            args.harness,
703            "--stream",
704            "--events",
705            "--timeout",
706            &timeout,
707            "--prompt-file",
708            "-",
709        ]);
710        if !args.model.is_empty() {
711            cmd.args(["--model", args.model]);
712        }
713        if let Some(system) = args.system {
714            cmd.args(["--system", system]);
715        }
716        if let Some(resume) = args.resume {
717            cmd.args(["--resume", resume]);
718        }
719
720        let mut child = cmd
721            .stdin(Stdio::piped())
722            .stdout(Stdio::piped())
723            .stderr(Stdio::piped())
724            .spawn()
725            .map_err(|e| {
726                Error::provider(
727                    "oneharness",
728                    format!(
729                        "could not run `{}`: {e}. Is oneharness installed and on PATH?",
730                        self.bin
731                    ),
732                )
733            })?;
734
735        // Write the prompt and close stdin so oneharness starts, then read its
736        // NDJSON incrementally — events arrive live and we never deadlock on a
737        // full stdout pipe.
738        {
739            let mut stdin = child
740                .stdin
741                .take()
742                .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?;
743            stdin.write_all(args.prompt.as_bytes()).map_err(|e| {
744                Error::provider("oneharness", format!("could not write prompt: {e}"))
745            })?;
746        }
747
748        let stdout = child
749            .stdout
750            .take()
751            .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdout"))?;
752        let reader = BufReader::new(stdout);
753
754        let mut events: Vec<ToolEvent> = Vec::new();
755        let mut result_env: Option<OhEnvelope> = None;
756        let mut aborted = false;
757
758        for line in reader.lines() {
759            let line = line.map_err(|e| {
760                Error::provider("oneharness", format!("could not read stream: {e}"))
761            })?;
762            let trimmed = line.trim();
763            if trimmed.is_empty() {
764                continue;
765            }
766            // Tolerate non-JSON log lines interleaved on the stream.
767            let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
768                continue;
769            };
770            match value.get("type").and_then(serde_json::Value::as_str) {
771                Some("event") => {
772                    if let Ok(event) = serde_json::from_value::<ToolEvent>(value["event"].clone()) {
773                        let flow = on_event(&event);
774                        events.push(event);
775                        if flow.is_break() {
776                            aborted = true;
777                            let _ = child.kill();
778                            break;
779                        }
780                    }
781                }
782                Some("result") => {
783                    if let Ok(env) = serde_json::from_value::<OhEnvelope>(value["report"].clone()) {
784                        result_env = Some(env);
785                    }
786                }
787                _ => {}
788            }
789        }
790
791        let output = child.wait_with_output().map_err(|e| {
792            Error::provider("oneharness", format!("oneharness did not complete: {e}"))
793        })?;
794
795        if aborted {
796            // Torn down on purpose; return the partial turn (events seen so far).
797            return Ok(RunOutcome {
798                text: String::new(),
799                session_id: None,
800                usage: None,
801                events,
802            });
803        }
804
805        let envelope = result_env.ok_or_else(|| {
806            Error::provider(
807                "oneharness",
808                format!(
809                    "oneharness stream produced no result; stderr: {}",
810                    String::from_utf8_lossy(&output.stderr).trim()
811                ),
812            )
813        })?;
814        let result = envelope
815            .results
816            .into_iter()
817            .next()
818            .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
819        if result.status != "ok" {
820            let detail = result
821                .error
822                .filter(|e| !e.is_empty())
823                .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
824                .unwrap_or_else(|| format!("status `{}`", result.status));
825            let context = format!("oneharness:{}", args.harness);
826            let message = format!("harness run failed: {detail}");
827            return Err(match result.failure_kind {
828                Some(kind) if !kind.is_empty() => {
829                    Error::provider_classified(context, message, kind)
830                }
831                _ => Error::provider(context, message),
832            });
833        }
834        let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
835            Error::provider(
836                format!("oneharness:{}", args.harness),
837                "harness produced neither extractable text nor stdout",
838            )
839        })?;
840        // Prefer the events we streamed; fall back to the result's events only if
841        // the stream carried none.
842        let events = if events.is_empty() {
843            result.events.unwrap_or_default()
844        } else {
845            events
846        };
847        Ok(RunOutcome {
848            text,
849            session_id: result.session_id,
850            usage: result.usage,
851            events,
852        })
853    }
854}
855
856impl Provider for OneharnessProvider {
857    fn respond(
858        &self,
859        platform: &str,
860        model: &str,
861        skill: &SkillRef<'_>,
862        messages: &[Message],
863        session: Option<&str>,
864    ) -> Result<AssistantTurn> {
865        // If we have a real session to continue on a supporting harness, only
866        // send the last user message — the harness still has its prior state.
867        // Otherwise inline the whole transcript so harnesses without resume
868        // still see the conversation.
869        let prompt = if session.is_some() {
870            latest_user_message(messages).unwrap_or_default()
871        } else {
872            render_transcript_for_respond(messages)
873        };
874        let outcome = self.run(&RunArgs {
875            harness: platform,
876            model,
877            prompt: &prompt,
878            system: Some(skill.instructions),
879            resume: session,
880        })?;
881        Ok(AssistantTurn {
882            message: outcome.text.trim().to_string(),
883            done: false,
884            usage: outcome.usage,
885            session_id: outcome.session_id,
886            events: outcome.events,
887        })
888    }
889
890    fn respond_streaming(
891        &self,
892        platform: &str,
893        model: &str,
894        skill: &SkillRef<'_>,
895        messages: &[Message],
896        session: Option<&str>,
897        on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
898    ) -> Result<AssistantTurn> {
899        let prompt = if session.is_some() {
900            latest_user_message(messages).unwrap_or_default()
901        } else {
902            render_transcript_for_respond(messages)
903        };
904        let outcome = self.run_streaming(
905            &RunArgs {
906                harness: platform,
907                model,
908                prompt: &prompt,
909                system: Some(skill.instructions),
910                resume: session,
911            },
912            on_event,
913        )?;
914        Ok(AssistantTurn {
915            message: outcome.text.trim().to_string(),
916            done: false,
917            usage: outcome.usage,
918            session_id: outcome.session_id,
919            events: outcome.events,
920        })
921    }
922
923    fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
924        let prompt = build_user_prompt(persona, messages);
925        let outcome = self.run(&RunArgs {
926            harness: &self.judge_harness,
927            model,
928            prompt: &prompt,
929            system: None,
930            resume: None,
931        })?;
932        Ok(UserTurn {
933            message: outcome.text.trim().to_string(),
934            stop: false,
935            usage: outcome.usage,
936        })
937    }
938
939    fn judge(
940        &self,
941        model: &str,
942        query: &JudgeQuery<'_>,
943        messages: &[Message],
944    ) -> Result<JudgeVerdict> {
945        let prompt = build_judge_prompt(query, messages);
946        let outcome = self.run(&RunArgs {
947            harness: &self.judge_harness,
948            model,
949            prompt: &prompt,
950            system: None,
951            resume: None,
952        })?;
953        let mut verdict = parse_verdict(query.kind, &outcome.text)?;
954        verdict.usage = outcome.usage;
955        Ok(verdict)
956    }
957
958    fn supports_resume(&self, platform: &str) -> bool {
959        supports_resume(platform)
960    }
961}
962
963/// The harnesses oneharness's adapter table marks `supports_resume = true`
964/// (claude-code's `--resume`, opencode's `--session`, cursor's `--resume`). Kept
965/// in sync with the `oneharness list` registry — when a new harness ships
966/// session continuation, add it here so the runner threads `session_id`.
967#[must_use]
968pub fn supports_resume(harness: &str) -> bool {
969    matches!(harness, "claude-code" | "opencode" | "cursor")
970}
971
972// ---------------------------------------------------------------------------
973// ApiJudgeProvider + SplitProvider
974// ---------------------------------------------------------------------------
975
976/// A judge-only [`Provider`] that scores evals and plays the simulated user with
977/// a *direct* model API call (Anthropic or OpenAI), rather than running them
978/// through a harness.
979///
980/// Why this exists: routing the judge through a full agentic harness pays an
981/// agent-loop cold start on every short verdict. A direct API call is one HTTP
982/// round trip — faster and cheaper on API-key auth — and still reuses the exact
983/// same judge/user prompts and tolerant verdict parsing as
984/// [`OneharnessProvider`], so the two are directly comparable.
985///
986/// It does not run skills: `respond` returns an error. Compose it with a
987/// skill-running provider via [`SplitProvider`] so the harness under test still
988/// drives `respond`, while the judge runs on the API.
989///
990/// The request is sent with `curl` (Rust has no official vendor SDK). The API
991/// key is read from an env var and passed through a private (`0600`) `curl`
992/// config file, so it never appears in `argv` / `ps`.
993pub struct ApiJudgeProvider {
994    vendor: ApiVendor,
995    api_key_env: String,
996    endpoint: String,
997    timeout_secs: u64,
998    curl_bin: String,
999    strict_json: bool,
1000}
1001
1002/// How many times a transient API failure (rate limit / overload) is retried
1003/// before giving up, with exponential backoff between attempts.
1004const MAX_RETRIES: u32 = 2;
1005
1006/// One model reply plus the usage the API reported for it.
1007#[derive(Debug)]
1008struct ChatOutcome {
1009    text: String,
1010    usage: Option<Usage>,
1011}
1012
1013/// A minimal system prompt; the full judge / user-simulation instructions live
1014/// in the shared prompt builders, so this stays identical across vendors.
1015const JUDGE_SYSTEM: &str =
1016    "Follow the user's instructions exactly and respond with only what they ask for.";
1017
1018impl ApiJudgeProvider {
1019    /// Build a provider from its configuration, resolving per-vendor defaults
1020    /// for the API-key env var and endpoint.
1021    #[must_use]
1022    pub fn new(config: &ApiJudgeConfig) -> Self {
1023        let api_key_env = config
1024            .api_key_env
1025            .clone()
1026            .unwrap_or_else(|| match config.vendor {
1027                ApiVendor::Anthropic => "ANTHROPIC_API_KEY".to_string(),
1028                ApiVendor::Openai => "OPENAI_API_KEY".to_string(),
1029            });
1030        let endpoint = config
1031            .base_url
1032            .clone()
1033            .unwrap_or_else(|| match config.vendor {
1034                ApiVendor::Anthropic => "https://api.anthropic.com/v1/messages".to_string(),
1035                ApiVendor::Openai => "https://api.openai.com/v1/chat/completions".to_string(),
1036            });
1037        Self {
1038            vendor: config.vendor,
1039            api_key_env,
1040            endpoint,
1041            timeout_secs: config.timeout_secs,
1042            curl_bin: config.curl_bin.clone(),
1043            strict_json: config.strict_json,
1044        }
1045    }
1046
1047    /// One chat round trip: build the vendor request, POST it, parse the reply.
1048    /// `schema`, when set, constrains the reply to that JSON schema via the
1049    /// vendor's structured-outputs feature. Transient failures (rate limit /
1050    /// overload) are retried with exponential backoff.
1051    fn chat(
1052        &self,
1053        model: &str,
1054        system: &str,
1055        user: &str,
1056        schema: Option<serde_json::Value>,
1057    ) -> Result<ChatOutcome> {
1058        let key = std::env::var(&self.api_key_env).map_err(|_| {
1059            Error::provider_classified(
1060                "api-judge",
1061                format!("API key env var `{}` is not set", self.api_key_env),
1062                "auth",
1063            )
1064        })?;
1065        let body = build_chat_body(self.vendor, model, system, user, schema);
1066        let payload = serde_json::to_vec(&body)
1067            .map_err(|e| Error::provider("api-judge", format!("could not encode request: {e}")))?;
1068
1069        let mut attempt = 0;
1070        loop {
1071            let result = self
1072                .run_curl(&key, &payload)
1073                .and_then(|raw| parse_chat_response(self.vendor, &raw));
1074            match result {
1075                Ok(outcome) => return Ok(outcome),
1076                Err(err) if attempt < MAX_RETRIES && is_retryable(&err) => {
1077                    attempt += 1;
1078                    std::thread::sleep(std::time::Duration::from_millis(500 * (1 << attempt)));
1079                }
1080                Err(err) => return Err(err),
1081            }
1082        }
1083    }
1084
1085    /// Per-vendor request headers.
1086    fn headers(&self, key: &str) -> Vec<(String, String)> {
1087        match self.vendor {
1088            ApiVendor::Anthropic => vec![
1089                ("x-api-key".to_string(), key.to_string()),
1090                ("anthropic-version".to_string(), "2023-06-01".to_string()),
1091                ("content-type".to_string(), "application/json".to_string()),
1092            ],
1093            ApiVendor::Openai => vec![
1094                ("authorization".to_string(), format!("Bearer {key}")),
1095                ("content-type".to_string(), "application/json".to_string()),
1096            ],
1097        }
1098    }
1099
1100    /// POST `body` via `curl`, with the URL + headers (including the API key) in
1101    /// a private config file so the key stays out of `argv`. Returns stdout.
1102    fn run_curl(&self, key: &str, body: &[u8]) -> Result<String> {
1103        let path = std::env::temp_dir().join(format!(
1104            "skilltest-judge-{}-{}.cfg",
1105            std::process::id(),
1106            curl_config_nonce()
1107        ));
1108        write_curl_config(&path, &self.endpoint, &self.headers(key), self.timeout_secs)?;
1109        let outcome = self.exec_curl(&path, body);
1110        // The key-bearing config is needed only for this one invocation.
1111        let _ = std::fs::remove_file(&path);
1112        outcome
1113    }
1114
1115    fn exec_curl(&self, config_path: &std::path::Path, body: &[u8]) -> Result<String> {
1116        let mut child = Command::new(&self.curl_bin)
1117            .arg("--config")
1118            .arg(config_path)
1119            .arg("--data-binary")
1120            .arg("@-")
1121            .stdin(Stdio::piped())
1122            .stdout(Stdio::piped())
1123            .stderr(Stdio::piped())
1124            .spawn()
1125            .map_err(|e| {
1126                Error::provider(
1127                    "api-judge",
1128                    format!(
1129                        "could not run `{}`: {e}. Is curl installed and on PATH?",
1130                        self.curl_bin
1131                    ),
1132                )
1133            })?;
1134
1135        child
1136            .stdin
1137            .as_mut()
1138            .ok_or_else(|| Error::provider("api-judge", "could not open curl stdin"))?
1139            .write_all(body)
1140            .map_err(|e| Error::provider("api-judge", format!("could not write request: {e}")))?;
1141
1142        let output = child
1143            .wait_with_output()
1144            .map_err(|e| Error::provider("api-judge", format!("curl did not complete: {e}")))?;
1145
1146        if !output.status.success() {
1147            let stderr = String::from_utf8_lossy(&output.stderr);
1148            return Err(Error::provider(
1149                "api-judge",
1150                format!("curl failed ({}): {}", output.status, stderr.trim()),
1151            ));
1152        }
1153        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1154    }
1155}
1156
1157impl Provider for ApiJudgeProvider {
1158    fn respond(
1159        &self,
1160        _platform: &str,
1161        _model: &str,
1162        _skill: &SkillRef<'_>,
1163        _messages: &[Message],
1164        _session: Option<&str>,
1165    ) -> Result<AssistantTurn> {
1166        Err(Error::provider(
1167            "api-judge",
1168            "the API judge does not run skills; use it as the judge in a SplitProvider",
1169        ))
1170    }
1171
1172    fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1173        let prompt = build_user_prompt(persona, messages);
1174        // Free-form text reply — never schema-constrained.
1175        let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, None)?;
1176        Ok(UserTurn {
1177            message: outcome.text.trim().to_string(),
1178            stop: false,
1179            usage: outcome.usage,
1180        })
1181    }
1182
1183    fn judge(
1184        &self,
1185        model: &str,
1186        query: &JudgeQuery<'_>,
1187        messages: &[Message],
1188    ) -> Result<JudgeVerdict> {
1189        let prompt = build_judge_prompt(query, messages);
1190        // Constrain the verdict to the `{value, reason}` schema when strict JSON
1191        // is on, so the reply is guaranteed parseable rather than scraped.
1192        let schema = self.strict_json.then(|| verdict_schema(query.kind));
1193        let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, schema)?;
1194        let mut verdict = parse_verdict(query.kind, &outcome.text)?;
1195        verdict.usage = outcome.usage;
1196        Ok(verdict)
1197    }
1198}
1199
1200/// A [`Provider`] that runs skills with one provider and judges with another:
1201/// `respond` (and `supports_resume`) go to the skill-running provider; `judge`
1202/// and `simulate_user` go to the judge. This keeps harness fidelity for the
1203/// thing under test while letting the judge run on a fast, cheap, deterministic
1204/// backend (typically [`ApiJudgeProvider`]).
1205pub struct SplitProvider {
1206    responder: Box<dyn Provider>,
1207    judge: ApiJudgeProvider,
1208}
1209
1210impl SplitProvider {
1211    /// Compose a skill-running `responder` with an API `judge`.
1212    #[must_use]
1213    pub fn new(responder: Box<dyn Provider>, judge: ApiJudgeProvider) -> Self {
1214        Self { responder, judge }
1215    }
1216}
1217
1218impl Provider for SplitProvider {
1219    fn respond(
1220        &self,
1221        platform: &str,
1222        model: &str,
1223        skill: &SkillRef<'_>,
1224        messages: &[Message],
1225        session: Option<&str>,
1226    ) -> Result<AssistantTurn> {
1227        self.responder
1228            .respond(platform, model, skill, messages, session)
1229    }
1230
1231    fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1232        self.judge.simulate_user(model, persona, messages)
1233    }
1234
1235    fn judge(
1236        &self,
1237        model: &str,
1238        query: &JudgeQuery<'_>,
1239        messages: &[Message],
1240    ) -> Result<JudgeVerdict> {
1241        self.judge.judge(model, query, messages)
1242    }
1243
1244    fn supports_resume(&self, platform: &str) -> bool {
1245        self.responder.supports_resume(platform)
1246    }
1247}
1248
1249/// A process-local monotonic counter, combined with the pid to make a unique
1250/// temp-file name for each concurrent `curl` config.
1251fn curl_config_nonce() -> u64 {
1252    use std::sync::atomic::{AtomicU64, Ordering};
1253    static COUNTER: AtomicU64 = AtomicU64::new(0);
1254    COUNTER.fetch_add(1, Ordering::Relaxed)
1255}
1256
1257/// Escape a value for a double-quoted `curl` config entry.
1258fn curl_escape(value: &str) -> String {
1259    value.replace('\\', "\\\\").replace('"', "\\\"")
1260}
1261
1262/// Write a `curl` config file (`0600` on Unix) carrying the URL, headers, and
1263/// timeout. The request body is streamed separately on stdin (`--data-binary
1264/// @-`), so it never needs escaping into this file.
1265fn write_curl_config(
1266    path: &std::path::Path,
1267    url: &str,
1268    headers: &[(String, String)],
1269    timeout_secs: u64,
1270) -> Result<()> {
1271    let mut config = String::new();
1272    config.push_str(&format!("url = \"{}\"\n", curl_escape(url)));
1273    config.push_str("request = \"POST\"\n");
1274    for (name, value) in headers {
1275        config.push_str(&format!("header = \"{}: {}\"\n", name, curl_escape(value)));
1276    }
1277    config.push_str(&format!("max-time = {timeout_secs}\n"));
1278    config.push_str("silent\nshow-error\n");
1279
1280    let mut options = std::fs::OpenOptions::new();
1281    options.write(true).create(true).truncate(true);
1282    #[cfg(unix)]
1283    {
1284        use std::os::unix::fs::OpenOptionsExt as _;
1285        options.mode(0o600);
1286    }
1287    let mut file = options
1288        .open(path)
1289        .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1290    file.write_all(config.as_bytes())
1291        .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1292    Ok(())
1293}
1294
1295/// The JSON schema a judge verdict must match: `{value, reason}` with `value`
1296/// typed by the eval kind. Numeric bounds are intentionally omitted — vendor
1297/// structured outputs don't enforce `minimum`/`maximum`, and the runner already
1298/// range-checks the parsed value.
1299fn verdict_schema(kind: JudgeKind) -> serde_json::Value {
1300    let value_type = match kind {
1301        JudgeKind::Boolean => "boolean",
1302        JudgeKind::Numeric => "number",
1303    };
1304    serde_json::json!({
1305        "type": "object",
1306        "properties": {
1307            "value": { "type": value_type },
1308            "reason": { "type": "string" },
1309        },
1310        "required": ["value", "reason"],
1311        "additionalProperties": false,
1312    })
1313}
1314
1315/// Build the JSON request body for one chat completion. Outgoing data, so it is
1316/// constructed directly; responses are parsed into typed models below. When
1317/// `schema` is set, the vendor's structured-outputs field is added so the reply
1318/// is guaranteed to match it.
1319fn build_chat_body(
1320    vendor: ApiVendor,
1321    model: &str,
1322    system: &str,
1323    user: &str,
1324    schema: Option<serde_json::Value>,
1325) -> serde_json::Value {
1326    match vendor {
1327        ApiVendor::Anthropic => {
1328            let mut body = serde_json::json!({
1329                "model": model,
1330                "max_tokens": 1024,
1331                "system": system,
1332                "messages": [{ "role": "user", "content": user }],
1333            });
1334            if let Some(schema) = schema {
1335                body["output_config"] =
1336                    serde_json::json!({ "format": { "type": "json_schema", "schema": schema } });
1337            }
1338            body
1339        }
1340        ApiVendor::Openai => {
1341            let mut body = serde_json::json!({
1342                "model": model,
1343                "max_tokens": 1024,
1344                "messages": [
1345                    { "role": "system", "content": system },
1346                    { "role": "user", "content": user },
1347                ],
1348            });
1349            if let Some(schema) = schema {
1350                body["response_format"] = serde_json::json!({
1351                    "type": "json_schema",
1352                    "json_schema": { "name": "verdict", "strict": true, "schema": schema },
1353                });
1354            }
1355            body
1356        }
1357    }
1358}
1359
1360/// True iff the error is a transient API condition worth retrying.
1361fn is_retryable(err: &Error) -> bool {
1362    matches!(
1363        err,
1364        Error::Provider { kind: Some(k), .. } if k == "rate_limit" || k == "overloaded"
1365    )
1366}
1367
1368// Typed views of the vendor responses (trust-boundary input — always parsed,
1369// never string-matched).
1370
1371#[derive(Deserialize)]
1372struct ApiErrorBody {
1373    #[serde(rename = "type", default)]
1374    kind: Option<String>,
1375    #[serde(default)]
1376    message: Option<String>,
1377}
1378
1379#[derive(Deserialize)]
1380struct AnthropicBlock {
1381    #[serde(rename = "type")]
1382    kind: String,
1383    #[serde(default)]
1384    text: Option<String>,
1385}
1386
1387#[derive(Deserialize)]
1388struct AnthropicUsage {
1389    #[serde(default)]
1390    input_tokens: Option<u64>,
1391    #[serde(default)]
1392    output_tokens: Option<u64>,
1393}
1394
1395#[derive(Deserialize)]
1396struct AnthropicResponse {
1397    #[serde(default)]
1398    content: Vec<AnthropicBlock>,
1399    #[serde(default)]
1400    usage: Option<AnthropicUsage>,
1401    #[serde(default)]
1402    stop_reason: Option<String>,
1403    #[serde(default)]
1404    error: Option<ApiErrorBody>,
1405}
1406
1407#[derive(Deserialize)]
1408struct OpenAiMessage {
1409    #[serde(default)]
1410    content: Option<String>,
1411}
1412
1413#[derive(Deserialize)]
1414struct OpenAiChoice {
1415    #[serde(default)]
1416    message: Option<OpenAiMessage>,
1417}
1418
1419#[derive(Deserialize)]
1420struct OpenAiUsage {
1421    #[serde(default)]
1422    prompt_tokens: Option<u64>,
1423    #[serde(default)]
1424    completion_tokens: Option<u64>,
1425}
1426
1427#[derive(Deserialize)]
1428struct OpenAiResponse {
1429    #[serde(default)]
1430    choices: Vec<OpenAiChoice>,
1431    #[serde(default)]
1432    usage: Option<OpenAiUsage>,
1433    #[serde(default)]
1434    error: Option<ApiErrorBody>,
1435}
1436
1437/// Map a vendor error `type` onto skilltest's classified provider-error kinds so
1438/// the CLI can give the same pointed hints it gives for harness failures.
1439fn classify_api_error(kind: Option<&str>) -> Option<String> {
1440    match kind? {
1441        "authentication_error" | "invalid_api_key" | "permission_error" => Some("auth".to_string()),
1442        "rate_limit_error" | "rate_limit_exceeded" => Some("rate_limit".to_string()),
1443        "insufficient_quota" | "billing_error" => Some("quota".to_string()),
1444        "not_found_error" => Some("model_not_found".to_string()),
1445        // Transient server-side conditions — surfaced as `overloaded` so the
1446        // runner retries them (see `is_retryable`).
1447        "overloaded_error" | "api_error" | "server_error" | "service_unavailable" => {
1448            Some("overloaded".to_string())
1449        }
1450        _ => None,
1451    }
1452}
1453
1454fn api_error(err: ApiErrorBody) -> Error {
1455    let message = err
1456        .message
1457        .unwrap_or_else(|| "API returned an error".to_string());
1458    match classify_api_error(err.kind.as_deref()) {
1459        Some(kind) => Error::provider_classified("api-judge", message, kind),
1460        None => Error::provider("api-judge", message),
1461    }
1462}
1463
1464/// Take the first chars of `raw` for an error message, on a UTF-8 boundary.
1465fn truncate_for_error(raw: &str) -> String {
1466    raw.chars().take(500).collect()
1467}
1468
1469/// Parse a vendor chat response into the reply text plus normalized usage.
1470fn parse_chat_response(vendor: ApiVendor, raw: &str) -> Result<ChatOutcome> {
1471    match vendor {
1472        ApiVendor::Anthropic => {
1473            let resp: AnthropicResponse = serde_json::from_str(raw.trim()).map_err(|e| {
1474                Error::provider(
1475                    "api-judge",
1476                    format!(
1477                        "could not parse API response: {e}; got: {}",
1478                        truncate_for_error(raw)
1479                    ),
1480                )
1481            })?;
1482            if let Some(err) = resp.error {
1483                return Err(api_error(err));
1484            }
1485            let text = resp
1486                .content
1487                .iter()
1488                .filter(|b| b.kind == "text")
1489                .filter_map(|b| b.text.as_deref())
1490                .collect::<String>();
1491            if text.trim().is_empty() {
1492                return Err(Error::provider(
1493                    "api-judge",
1494                    format!(
1495                        "judge returned no text (stop_reason: {:?})",
1496                        resp.stop_reason
1497                    ),
1498                ));
1499            }
1500            let usage = resp.usage.map(|u| Usage {
1501                input_tokens: u.input_tokens,
1502                output_tokens: u.output_tokens,
1503                cost_usd: None,
1504            });
1505            Ok(ChatOutcome { text, usage })
1506        }
1507        ApiVendor::Openai => {
1508            let resp: OpenAiResponse = serde_json::from_str(raw.trim()).map_err(|e| {
1509                Error::provider(
1510                    "api-judge",
1511                    format!(
1512                        "could not parse API response: {e}; got: {}",
1513                        truncate_for_error(raw)
1514                    ),
1515                )
1516            })?;
1517            if let Some(err) = resp.error {
1518                return Err(api_error(err));
1519            }
1520            let text = resp
1521                .choices
1522                .into_iter()
1523                .next()
1524                .and_then(|c| c.message)
1525                .and_then(|m| m.content)
1526                .unwrap_or_default();
1527            if text.trim().is_empty() {
1528                return Err(Error::provider("api-judge", "judge returned no text"));
1529            }
1530            let usage = resp.usage.map(|u| Usage {
1531                input_tokens: u.prompt_tokens,
1532                output_tokens: u.completion_tokens,
1533                cost_usd: None,
1534            });
1535            Ok(ChatOutcome { text, usage })
1536        }
1537    }
1538}
1539
1540/// Render the conversation as `Role: content` lines for inlining in a prompt.
1541/// Used by the judge, the simulated user, and the no-resume fallback path of
1542/// `respond`.
1543fn render_transcript(messages: &[Message]) -> String {
1544    messages
1545        .iter()
1546        .map(|m| {
1547            let role = match m.role {
1548                Role::User => "User",
1549                Role::Assistant => "Assistant",
1550                Role::System => "System",
1551            };
1552            format!("{role}: {}", m.content)
1553        })
1554        .collect::<Vec<_>>()
1555        .join("\n")
1556}
1557
1558/// The prompt for `respond` when we cannot resume a harness session: inline the
1559/// whole conversation so the stateless harness call sees it. The skill is
1560/// passed separately as `--system`, so it does *not* appear here.
1561fn render_transcript_for_respond(messages: &[Message]) -> String {
1562    format!(
1563        "Conversation so far (most recent last):\n{}\n\n\
1564         Write only the assistant's next reply, following your system \
1565         instructions. Output the reply text and nothing else.",
1566        render_transcript(messages),
1567    )
1568}
1569
1570/// The most recent user message in the transcript — used as the next-turn
1571/// prompt when resuming a real harness session.
1572fn latest_user_message(messages: &[Message]) -> Option<String> {
1573    messages
1574        .iter()
1575        .rev()
1576        .find(|m| m.role == Role::User)
1577        .map(|m| m.content.clone())
1578}
1579
1580fn build_user_prompt(persona: &str, messages: &[Message]) -> String {
1581    format!(
1582        "You are role-playing the USER in a conversation with an AI assistant. \
1583         Stay in character:\n\n{persona}\n\n\
1584         Conversation so far (most recent last):\n{transcript}\n\n\
1585         Write only the user's next message. Output the message text and nothing \
1586         else.",
1587        transcript = render_transcript(messages),
1588    )
1589}
1590
1591fn build_judge_prompt(query: &JudgeQuery<'_>, messages: &[Message]) -> String {
1592    let transcript = render_transcript(messages);
1593    match query.kind {
1594        JudgeKind::Boolean => format!(
1595            "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
1596             Criterion: {criterion}\n\n\
1597             Transcript:\n{transcript}\n\n\
1598             Decide whether the criterion is satisfied. Respond with ONLY a \
1599             single-line JSON object and nothing else:\n\
1600             {{\"value\": true or false, \"reason\": \"<one short sentence>\"}}",
1601            criterion = query.criterion,
1602        ),
1603        JudgeKind::Numeric => {
1604            let (min, max) = query.scale.unwrap_or((0.0, 10.0));
1605            format!(
1606                "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
1607                 Criterion: {criterion}\n\n\
1608                 Transcript:\n{transcript}\n\n\
1609                 Score how well the criterion is satisfied on a scale from {min} to \
1610                 {max} (inclusive). Respond with ONLY a single-line JSON object and \
1611                 nothing else:\n\
1612                 {{\"value\": <number between {min} and {max}>, \"reason\": \"<one short sentence>\"}}",
1613                criterion = query.criterion,
1614            )
1615        }
1616    }
1617}
1618
1619/// Extract the first JSON object from `text`, tolerating code fences and prose
1620/// around it (real models do not always emit bare JSON).
1621fn extract_json_object(text: &str) -> Option<&str> {
1622    let start = text.find('{')?;
1623    let end = text.rfind('}')?;
1624    if end > start {
1625        Some(&text[start..=end])
1626    } else {
1627        None
1628    }
1629}
1630
1631fn parse_verdict(kind: JudgeKind, text: &str) -> Result<JudgeVerdict> {
1632    let json = extract_json_object(text).ok_or_else(|| {
1633        Error::provider(
1634            "oneharness:judge",
1635            format!("judge did not return a JSON object; got: {text}"),
1636        )
1637    })?;
1638    let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
1639        Error::provider(
1640            "oneharness:judge",
1641            format!("judge verdict was not valid JSON: {e}; got: {json}"),
1642        )
1643    })?;
1644    let reason = value
1645        .get("reason")
1646        .and_then(serde_json::Value::as_str)
1647        .unwrap_or("")
1648        .to_string();
1649    let raw = value
1650        .get("value")
1651        .ok_or_else(|| Error::provider("oneharness:judge", "judge verdict has no `value` field"))?;
1652
1653    let verdict_value = match kind {
1654        JudgeKind::Boolean => JudgeValue::Bool(raw.as_bool().ok_or_else(|| {
1655            Error::provider(
1656                "oneharness:judge",
1657                format!("boolean judge `value` was not a bool: {raw}"),
1658            )
1659        })?),
1660        JudgeKind::Numeric => JudgeValue::Number(raw.as_f64().ok_or_else(|| {
1661            Error::provider(
1662                "oneharness:judge",
1663                format!("numeric judge `value` was not a number: {raw}"),
1664            )
1665        })?),
1666    };
1667
1668    Ok(JudgeVerdict {
1669        value: verdict_value,
1670        reason,
1671        usage: None,
1672    })
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677    use super::*;
1678
1679    #[test]
1680    fn empty_argv_is_rejected() {
1681        assert!(CommandProvider::new(vec![]).is_err());
1682    }
1683
1684    #[test]
1685    fn request_serializes_with_op_tag() {
1686        let req = Request::Judge {
1687            model: "m",
1688            kind: "numeric",
1689            criterion: "polite",
1690            min: Some(0.0),
1691            max: Some(10.0),
1692            messages: &[],
1693        };
1694        let json = serde_json::to_string(&req).unwrap();
1695        assert!(json.contains("\"op\":\"judge\""));
1696        assert!(json.contains("\"kind\":\"numeric\""));
1697    }
1698
1699    #[test]
1700    fn respond_no_session_inlines_transcript_but_not_skill() {
1701        // The skill is passed via --system now, so the prompt the harness sees
1702        // for respond carries only the transcript.
1703        let messages = [
1704            Message::user("Hi"),
1705            Message::assistant("Hello"),
1706            Message::user("Again?"),
1707        ];
1708        let prompt = render_transcript_for_respond(&messages);
1709        assert!(prompt.contains("User: Hi"));
1710        assert!(prompt.contains("Assistant: Hello"));
1711        assert!(prompt.contains("User: Again?"));
1712        // The skill body must not leak here — it belongs in --system.
1713        assert!(!prompt.contains("SKILL"));
1714    }
1715
1716    #[test]
1717    fn respond_with_session_sends_only_latest_user_message() {
1718        let messages = [
1719            Message::user("Hi"),
1720            Message::assistant("Hello"),
1721            Message::user("Again?"),
1722        ];
1723        assert_eq!(latest_user_message(&messages).as_deref(), Some("Again?"));
1724    }
1725
1726    #[test]
1727    fn extracts_json_from_fenced_or_prose_text() {
1728        assert_eq!(
1729            extract_json_object("```json\n{\"value\": true}\n```"),
1730            Some("{\"value\": true}")
1731        );
1732        assert_eq!(
1733            extract_json_object("Sure! {\"value\": 8, \"reason\": \"x\"} done"),
1734            Some("{\"value\": 8, \"reason\": \"x\"}")
1735        );
1736        assert_eq!(extract_json_object("no json here"), None);
1737    }
1738
1739    #[test]
1740    fn parses_boolean_and_numeric_verdicts() {
1741        let b = parse_verdict(JudgeKind::Boolean, "{\"value\": true, \"reason\": \"ok\"}").unwrap();
1742        assert!(matches!(b.value, JudgeValue::Bool(true)));
1743        assert_eq!(b.reason, "ok");
1744
1745        let n =
1746            parse_verdict(JudgeKind::Numeric, "{\"value\": 8.5, \"reason\": \"good\"}").unwrap();
1747        assert!(matches!(n.value, JudgeValue::Number(v) if (v - 8.5).abs() < f64::EPSILON));
1748    }
1749
1750    #[test]
1751    fn verdict_with_wrong_value_type_errors() {
1752        assert!(parse_verdict(JudgeKind::Boolean, "{\"value\": 3}").is_err());
1753        assert!(parse_verdict(JudgeKind::Numeric, "{\"value\": true}").is_err());
1754        assert!(parse_verdict(JudgeKind::Boolean, "no json").is_err());
1755    }
1756
1757    #[test]
1758    fn usage_accumulates_independently_per_field() {
1759        let mut total = Usage::default();
1760        total.add(&Usage {
1761            input_tokens: Some(10),
1762            output_tokens: None,
1763            cost_usd: Some(0.01),
1764        });
1765        total.add(&Usage {
1766            input_tokens: Some(5),
1767            output_tokens: Some(3),
1768            cost_usd: None,
1769        });
1770        assert_eq!(total.input_tokens, Some(15));
1771        assert_eq!(total.output_tokens, Some(3));
1772        assert!((total.cost_usd.unwrap() - 0.01).abs() < f64::EPSILON);
1773        assert!(!total.is_empty());
1774    }
1775
1776    #[test]
1777    fn reply_text_prefers_extracted_then_falls_back_to_stdout() {
1778        // Extracted text wins when present.
1779        assert_eq!(
1780            select_reply_text(Some("clean reply".into()), "raw noise"),
1781            Some("clean reply".into())
1782        );
1783        // Null/blank extracted text falls back to raw stdout (the contract's
1784        // escape hatch when oneharness can't extract but the reply is in stdout).
1785        assert_eq!(
1786            select_reply_text(None, "{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}"),
1787            Some("{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}".into())
1788        );
1789        assert_eq!(
1790            select_reply_text(Some("   ".into()), "fallback"),
1791            Some("fallback".into())
1792        );
1793        // Neither present is the only real error.
1794        assert_eq!(select_reply_text(None, "   \n"), None);
1795        assert_eq!(select_reply_text(Some(String::new()), ""), None);
1796    }
1797
1798    #[test]
1799    fn supports_resume_covers_known_harnesses() {
1800        assert!(supports_resume("claude-code"));
1801        assert!(supports_resume("opencode"));
1802        assert!(supports_resume("cursor"));
1803        assert!(!supports_resume("codex"));
1804        assert!(!supports_resume("goose"));
1805    }
1806
1807    fn api_config(vendor: ApiVendor) -> ApiJudgeConfig {
1808        ApiJudgeConfig {
1809            vendor,
1810            api_key_env: None,
1811            base_url: None,
1812            timeout_secs: 60,
1813            curl_bin: "curl".to_string(),
1814            strict_json: true,
1815        }
1816    }
1817
1818    #[test]
1819    fn api_judge_resolves_vendor_defaults() {
1820        let anthropic = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
1821        assert_eq!(anthropic.api_key_env, "ANTHROPIC_API_KEY");
1822        assert_eq!(anthropic.endpoint, "https://api.anthropic.com/v1/messages");
1823
1824        let openai = ApiJudgeProvider::new(&api_config(ApiVendor::Openai));
1825        assert_eq!(openai.api_key_env, "OPENAI_API_KEY");
1826        assert_eq!(
1827            openai.endpoint,
1828            "https://api.openai.com/v1/chat/completions"
1829        );
1830    }
1831
1832    #[test]
1833    fn api_judge_honors_overrides() {
1834        let provider = ApiJudgeProvider::new(&ApiJudgeConfig {
1835            vendor: ApiVendor::Openai,
1836            api_key_env: Some("MY_KEY".to_string()),
1837            base_url: Some("https://proxy.example/v1/chat/completions".to_string()),
1838            timeout_secs: 5,
1839            curl_bin: "curl".to_string(),
1840            strict_json: true,
1841        });
1842        assert_eq!(provider.api_key_env, "MY_KEY");
1843        assert_eq!(
1844            provider.endpoint,
1845            "https://proxy.example/v1/chat/completions"
1846        );
1847    }
1848
1849    #[test]
1850    fn build_chat_body_shapes_per_vendor() {
1851        let anthropic = build_chat_body(ApiVendor::Anthropic, "claude-x", "sys", "hi", None);
1852        assert_eq!(anthropic["model"], "claude-x");
1853        assert_eq!(anthropic["system"], "sys");
1854        assert_eq!(anthropic["messages"][0]["role"], "user");
1855        // Anthropic carries the system prompt in its own top-level field.
1856        assert_eq!(anthropic["messages"].as_array().unwrap().len(), 1);
1857        // No schema requested → no structured-outputs field.
1858        assert!(anthropic.get("output_config").is_none());
1859
1860        let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", None);
1861        assert_eq!(openai["messages"][0]["role"], "system");
1862        assert_eq!(openai["messages"][1]["role"], "user");
1863        assert!(openai.get("system").is_none());
1864        assert!(openai.get("response_format").is_none());
1865    }
1866
1867    #[test]
1868    fn build_chat_body_attaches_strict_schema_per_vendor() {
1869        let schema = verdict_schema(JudgeKind::Boolean);
1870        let anthropic = build_chat_body(
1871            ApiVendor::Anthropic,
1872            "claude-x",
1873            "sys",
1874            "hi",
1875            Some(schema.clone()),
1876        );
1877        // Anthropic uses output_config.format.
1878        assert_eq!(anthropic["output_config"]["format"]["type"], "json_schema");
1879        assert_eq!(
1880            anthropic["output_config"]["format"]["schema"]["properties"]["value"]["type"],
1881            "boolean"
1882        );
1883
1884        let numeric = verdict_schema(JudgeKind::Numeric);
1885        let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", Some(numeric));
1886        // OpenAI uses response_format.json_schema with strict: true.
1887        assert_eq!(openai["response_format"]["type"], "json_schema");
1888        assert_eq!(openai["response_format"]["json_schema"]["strict"], true);
1889        assert_eq!(
1890            openai["response_format"]["json_schema"]["schema"]["properties"]["value"]["type"],
1891            "number"
1892        );
1893    }
1894
1895    #[test]
1896    fn verdict_schema_requires_value_and_reason_with_no_extras() {
1897        let schema = verdict_schema(JudgeKind::Numeric);
1898        assert_eq!(schema["additionalProperties"], false);
1899        let required: Vec<&str> = schema["required"]
1900            .as_array()
1901            .unwrap()
1902            .iter()
1903            .map(|v| v.as_str().unwrap())
1904            .collect();
1905        assert_eq!(required, ["value", "reason"]);
1906    }
1907
1908    #[test]
1909    fn parses_anthropic_success_with_usage() {
1910        let raw = r#"{"content":[{"type":"text","text":"{\"value\": true}"}],
1911            "stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":3}}"#;
1912        let outcome = parse_chat_response(ApiVendor::Anthropic, raw).unwrap();
1913        assert_eq!(outcome.text, "{\"value\": true}");
1914        let usage = outcome.usage.unwrap();
1915        assert_eq!(usage.input_tokens, Some(12));
1916        assert_eq!(usage.output_tokens, Some(3));
1917        assert!(usage.cost_usd.is_none());
1918    }
1919
1920    #[test]
1921    fn parses_openai_success_with_usage() {
1922        let raw = r#"{"choices":[{"message":{"content":"{\"value\": 8}"}}],
1923            "usage":{"prompt_tokens":20,"completion_tokens":4}}"#;
1924        let outcome = parse_chat_response(ApiVendor::Openai, raw).unwrap();
1925        assert_eq!(outcome.text, "{\"value\": 8}");
1926        let usage = outcome.usage.unwrap();
1927        assert_eq!(usage.input_tokens, Some(20));
1928        assert_eq!(usage.output_tokens, Some(4));
1929    }
1930
1931    #[test]
1932    fn parses_and_classifies_api_errors() {
1933        let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
1934        let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
1935        assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
1936
1937        let rate = r#"{"error":{"type":"rate_limit_exceeded","message":"slow down"}}"#;
1938        let err = parse_chat_response(ApiVendor::Openai, rate).unwrap_err();
1939        assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "rate_limit"));
1940    }
1941
1942    #[test]
1943    fn empty_reply_is_an_error() {
1944        let raw = r#"{"content":[],"stop_reason":"refusal"}"#;
1945        assert!(parse_chat_response(ApiVendor::Anthropic, raw).is_err());
1946    }
1947
1948    #[test]
1949    fn classify_api_error_maps_known_kinds() {
1950        assert_eq!(
1951            classify_api_error(Some("invalid_api_key")).as_deref(),
1952            Some("auth")
1953        );
1954        assert_eq!(
1955            classify_api_error(Some("insufficient_quota")).as_deref(),
1956            Some("quota")
1957        );
1958        assert_eq!(
1959            classify_api_error(Some("not_found_error")).as_deref(),
1960            Some("model_not_found")
1961        );
1962        assert_eq!(
1963            classify_api_error(Some("overloaded_error")).as_deref(),
1964            Some("overloaded")
1965        );
1966        assert_eq!(classify_api_error(Some("something_else")), None);
1967        assert_eq!(classify_api_error(None), None);
1968    }
1969
1970    #[test]
1971    fn retryable_covers_transient_errors_only() {
1972        let overloaded = r#"{"error":{"type":"overloaded_error","message":"busy"}}"#;
1973        let err = parse_chat_response(ApiVendor::Anthropic, overloaded).unwrap_err();
1974        assert!(is_retryable(&err), "overload should retry");
1975
1976        let rate = r#"{"error":{"type":"rate_limit_error","message":"slow"}}"#;
1977        let err = parse_chat_response(ApiVendor::Anthropic, rate).unwrap_err();
1978        assert!(is_retryable(&err), "rate limit should retry");
1979
1980        let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
1981        let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
1982        assert!(!is_retryable(&err), "auth must not retry");
1983    }
1984
1985    #[test]
1986    fn curl_escape_handles_quotes_and_backslashes() {
1987        assert_eq!(curl_escape(r#"a"b\c"#), r#"a\"b\\c"#);
1988    }
1989
1990    /// A skill-running provider stub so the SplitProvider's delegation can be
1991    /// checked without touching the network.
1992    struct StubResponder;
1993
1994    impl Provider for StubResponder {
1995        fn respond(
1996            &self,
1997            _platform: &str,
1998            _model: &str,
1999            _skill: &SkillRef<'_>,
2000            _messages: &[Message],
2001            _session: Option<&str>,
2002        ) -> Result<AssistantTurn> {
2003            Ok(AssistantTurn {
2004                message: "stub reply".to_string(),
2005                ..Default::default()
2006            })
2007        }
2008
2009        fn simulate_user(
2010            &self,
2011            _model: &str,
2012            _persona: &str,
2013            _messages: &[Message],
2014        ) -> Result<UserTurn> {
2015            unreachable!("split provider routes user simulation to the judge")
2016        }
2017
2018        fn judge(
2019            &self,
2020            _model: &str,
2021            _query: &JudgeQuery<'_>,
2022            _messages: &[Message],
2023        ) -> Result<JudgeVerdict> {
2024            unreachable!("split provider routes judging to the judge")
2025        }
2026
2027        fn supports_resume(&self, platform: &str) -> bool {
2028            platform == "claude-code"
2029        }
2030    }
2031
2032    #[test]
2033    fn split_provider_delegates_respond_and_resume() {
2034        let split = SplitProvider::new(
2035            Box::new(StubResponder),
2036            ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic)),
2037        );
2038        // respond + supports_resume go to the responder...
2039        assert!(split.supports_resume("claude-code"));
2040        assert!(!split.supports_resume("codex"));
2041        let skill = SkillRef {
2042            name: "s",
2043            dir: "/tmp/s",
2044            instructions: "do things",
2045        };
2046        let turn = split
2047            .respond("claude-code", "m", &skill, &[], None)
2048            .unwrap();
2049        assert_eq!(turn.message, "stub reply");
2050    }
2051
2052    #[test]
2053    fn api_judge_does_not_run_skills() {
2054        let provider = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
2055        let skill = SkillRef {
2056            name: "s",
2057            dir: "/tmp/s",
2058            instructions: "x",
2059        };
2060        assert!(provider.respond("p", "m", &skill, &[], None).is_err());
2061    }
2062
2063    // -----------------------------------------------------------------------
2064    // Subprocess-driven coverage: these spawn small shell scripts standing in
2065    // for the provider command / oneharness / curl, so the actual process
2066    // plumbing (`CommandProvider::call`, `OneharnessProvider::run`,
2067    // `ApiJudgeProvider::run_curl`/`exec_curl`/`write_curl_config`) is exercised
2068    // end to end without any network. Unix-only; the whole crate ships to a
2069    // Linux/macOS matrix (see AGENTS.md "Stack and composition").
2070
2071    #[cfg(unix)]
2072    mod subprocess {
2073        // `std::io::Write` is already in scope via `super::*` (the module-level
2074        // `use std::io::Write as _`), so `write_all` resolves without a re-import.
2075        use super::*;
2076        use std::os::unix::fs::PermissionsExt as _;
2077        use std::path::PathBuf;
2078
2079        /// Write an executable shell script into a unique temp dir and return its
2080        /// path. Each call gets its own directory so concurrent tests never race.
2081        fn script(tag: &str, body: &str) -> PathBuf {
2082            use std::sync::atomic::{AtomicU64, Ordering};
2083            static N: AtomicU64 = AtomicU64::new(0);
2084            let dir = std::env::temp_dir().join(format!(
2085                "skilltest-prov-{}-{tag}-{}",
2086                std::process::id(),
2087                N.fetch_add(1, Ordering::Relaxed)
2088            ));
2089            std::fs::create_dir_all(&dir).unwrap();
2090            let path = dir.join("script.sh");
2091            let mut f = std::fs::File::create(&path).unwrap();
2092            f.write_all(format!("#!/bin/sh\n{body}").as_bytes())
2093                .unwrap();
2094            let mut perms = std::fs::metadata(&path).unwrap().permissions();
2095            perms.set_mode(0o755);
2096            std::fs::set_permissions(&path, perms).unwrap();
2097            path
2098        }
2099
2100        fn skill_ref() -> SkillRef<'static> {
2101            SkillRef {
2102                name: "greeter",
2103                dir: "/tmp/greeter",
2104                instructions: "Be nice.",
2105            }
2106        }
2107
2108        // ---- CommandProvider over a real subprocess ----
2109
2110        #[test]
2111        fn command_provider_respond_parses_response() {
2112            // Echo a fixed respond payload; ignore stdin.
2113            let bin = script(
2114                "respond",
2115                "cat >/dev/null\necho '{\"message\":\"hi there\",\"done\":true,\
2116                 \"usage\":{\"input_tokens\":4,\"output_tokens\":2},\"session_id\":\"s1\"}'\n",
2117            );
2118            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2119            let turn = provider
2120                .respond("demo", "fake", &skill_ref(), &[Message::user("hi")], None)
2121                .unwrap();
2122            assert_eq!(turn.message, "hi there");
2123            assert!(turn.done);
2124            assert_eq!(turn.session_id.as_deref(), Some("s1"));
2125            assert_eq!(turn.usage.unwrap().input_tokens, Some(4));
2126        }
2127
2128        #[test]
2129        fn command_provider_user_and_judge_parse_responses() {
2130            let user_bin = script(
2131                "user",
2132                "cat >/dev/null\necho '{\"message\":\"more please\",\"stop\":true}'\n",
2133            );
2134            let user_provider =
2135                CommandProvider::new(vec![user_bin.to_string_lossy().into_owned()]).unwrap();
2136            let user = user_provider.simulate_user("m", "persona", &[]).unwrap();
2137            assert_eq!(user.message, "more please");
2138            assert!(user.stop);
2139
2140            let judge_bin = script(
2141                "judge",
2142                "cat >/dev/null\necho '{\"value\":7.5,\"reason\":\"ok\"}'\n",
2143            );
2144            let judge_provider =
2145                CommandProvider::new(vec![judge_bin.to_string_lossy().into_owned()]).unwrap();
2146            let query = JudgeQuery {
2147                kind: JudgeKind::Numeric,
2148                criterion: "polite",
2149                scale: Some((0.0, 10.0)),
2150            };
2151            let verdict = judge_provider.judge("m", &query, &[]).unwrap();
2152            assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 7.5).abs() < 1e-9));
2153            assert_eq!(verdict.reason, "ok");
2154        }
2155
2156        #[test]
2157        fn command_provider_surfaces_nonzero_exit() {
2158            let bin = script("fail", "cat >/dev/null\necho 'boom' 1>&2\nexit 2\n");
2159            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2160            let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2161            let msg = err.to_string();
2162            assert!(msg.contains("provider exited"), "got: {msg}");
2163            assert!(msg.contains("boom"), "stderr is surfaced: {msg}");
2164        }
2165
2166        #[test]
2167        fn command_provider_rejects_empty_and_bad_output() {
2168            let empty = script("empty", "cat >/dev/null\n");
2169            let provider =
2170                CommandProvider::new(vec![empty.to_string_lossy().into_owned()]).unwrap();
2171            assert!(provider
2172                .judge(
2173                    "m",
2174                    &JudgeQuery {
2175                        kind: JudgeKind::Boolean,
2176                        criterion: "x",
2177                        scale: None
2178                    },
2179                    &[],
2180                )
2181                .unwrap_err()
2182                .to_string()
2183                .contains("no output"));
2184
2185            let garbage = script("garbage", "cat >/dev/null\necho 'not json'\n");
2186            let provider =
2187                CommandProvider::new(vec![garbage.to_string_lossy().into_owned()]).unwrap();
2188            assert!(provider
2189                .respond("demo", "m", &skill_ref(), &[], None)
2190                .unwrap_err()
2191                .to_string()
2192                .contains("not valid JSON"));
2193        }
2194
2195        #[test]
2196        fn command_provider_reports_missing_binary() {
2197            let provider =
2198                CommandProvider::new(vec!["/no/such/skilltest-provider-binary".to_string()])
2199                    .unwrap();
2200            let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2201            assert!(err.to_string().contains("could not run provider"));
2202        }
2203
2204        #[test]
2205        fn command_provider_session_is_threaded_into_request() {
2206            // The script writes the request it received to a sidecar file so the
2207            // test can assert the `session` field made it onto the wire.
2208            let dir =
2209                std::env::temp_dir().join(format!("skilltest-prov-sess-{}", std::process::id()));
2210            std::fs::create_dir_all(&dir).unwrap();
2211            let seen = dir.join("seen.json");
2212            let bin = script(
2213                "session",
2214                &format!(
2215                    "cat > '{}'\necho '{{\"message\":\"ok\"}}'\n",
2216                    seen.display()
2217                ),
2218            );
2219            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2220            provider
2221                .respond(
2222                    "demo",
2223                    "m",
2224                    &skill_ref(),
2225                    &[Message::user("hi")],
2226                    Some("session-xyz"),
2227                )
2228                .unwrap();
2229            let request = std::fs::read_to_string(&seen).unwrap();
2230            assert!(
2231                request.contains("\"session\":\"session-xyz\""),
2232                "got: {request}"
2233            );
2234            assert!(request.contains("\"op\":\"respond\""));
2235        }
2236
2237        // ---- OneharnessProvider over a fake oneharness ----
2238
2239        fn oh_provider(bin: PathBuf) -> OneharnessProvider {
2240            OneharnessProvider::new(&OneharnessConfig {
2241                bin: bin.to_string_lossy().into_owned(),
2242                judge_harness: "claude-code".to_string(),
2243                timeout_secs: 30,
2244            })
2245        }
2246
2247        #[test]
2248        fn oneharness_respond_extracts_text_and_session() {
2249            let bin = script(
2250                "oh-ok",
2251                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2252                 \"text\":\"  hello back  \",\"session_id\":\"oh1\",\
2253                 \"usage\":{\"input_tokens\":5}}]}'\n",
2254            );
2255            let turn = oh_provider(bin)
2256                .respond(
2257                    "claude-code",
2258                    "sonnet",
2259                    &skill_ref(),
2260                    &[Message::user("hi")],
2261                    None,
2262                )
2263                .unwrap();
2264            assert_eq!(turn.message, "hello back");
2265            assert_eq!(turn.session_id.as_deref(), Some("oh1"));
2266            assert_eq!(turn.usage.unwrap().input_tokens, Some(5));
2267            assert!(turn.events.is_empty());
2268        }
2269
2270        #[test]
2271        fn oneharness_respond_surfaces_normalized_events() {
2272            // oneharness `--events` populates a per-result `events` array; the
2273            // provider lifts it onto the assistant turn so consumers can analyze
2274            // what the skill did.
2275            let bin = script(
2276                "oh-events",
2277                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2278                 \"text\":\"done\",\"events\":[{\"kind\":\"tool_call\",\"name\":\"bash\",\
2279                 \"input\":{\"command\":\"git commit -m x\"},\"output\":\"ok\",\"index\":0}]}]}'\n",
2280            );
2281            let turn = oh_provider(bin)
2282                .respond(
2283                    "claude-code",
2284                    "sonnet",
2285                    &skill_ref(),
2286                    &[Message::user("hi")],
2287                    None,
2288                )
2289                .unwrap();
2290            assert_eq!(turn.events.len(), 1);
2291            assert_eq!(turn.events[0].kind, "tool_call");
2292            assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
2293            assert_eq!(
2294                turn.events[0].input,
2295                Some(serde_json::json!({"command": "git commit -m x"}))
2296            );
2297            assert_eq!(turn.events[0].output.as_deref(), Some("ok"));
2298        }
2299
2300        #[test]
2301        fn oneharness_respond_events_absent_is_empty_not_error() {
2302            // A harness that exposes no tool transcript yields no `events`; the
2303            // turn simply carries an empty list (never an error).
2304            let bin = script(
2305                "oh-noevents",
2306                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
2307            );
2308            let turn = oh_provider(bin)
2309                .respond("goose", "m", &skill_ref(), &[Message::user("hi")], None)
2310                .unwrap();
2311            assert!(turn.events.is_empty());
2312        }
2313
2314        #[test]
2315        fn oneharness_stream_forwards_events_then_parses_the_result() {
2316            // `oneharness run --stream` emits one NDJSON `{"type":"event",…}` line
2317            // per tool event, then a terminal `{"type":"result","report":{…}}`.
2318            // `respond_streaming` forwards each event live and returns the turn
2319            // parsed from the result.
2320            let bin = script(
2321                "oh-stream",
2322                "cat >/dev/null\n\
2323                 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2324                 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":0}}'\n\
2325                 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2326                 \"text\":\"  done  \",\"session_id\":\"s1\",\"usage\":{\"input_tokens\":7}}]}}'\n",
2327            );
2328            let mut seen = Vec::new();
2329            let turn = oh_provider(bin)
2330                .respond_streaming(
2331                    "claude-code",
2332                    "sonnet",
2333                    &skill_ref(),
2334                    &[Message::user("hi")],
2335                    None,
2336                    &mut |event| {
2337                        seen.push(event.name.clone());
2338                        ControlFlow::Continue(())
2339                    },
2340                )
2341                .unwrap();
2342            // The event was forwarded live, and the result was parsed for the turn.
2343            assert_eq!(seen, vec![Some("bash".to_string())]);
2344            assert_eq!(turn.message, "done");
2345            assert_eq!(turn.session_id.as_deref(), Some("s1"));
2346            assert_eq!(turn.usage.unwrap().input_tokens, Some(7));
2347            assert_eq!(turn.events.len(), 1);
2348            assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
2349        }
2350
2351        #[test]
2352        fn oneharness_stream_short_circuits_on_break() {
2353            // The sink breaks on the first event; the oneharness child is killed
2354            // and the later events/result are never delivered. The turn carries
2355            // only the events seen before the abort.
2356            let bin = script(
2357                "oh-stream-abort",
2358                "cat >/dev/null\n\
2359                 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2360                 \"name\":\"rm\",\"input\":{\"command\":\"rm -rf /\"},\"index\":0}}'\n\
2361                 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2362                 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":1}}'\n\
2363                 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2364                 \"text\":\"done\"}]}}'\n",
2365            );
2366            let mut seen = 0usize;
2367            let turn = oh_provider(bin)
2368                .respond_streaming(
2369                    "claude-code",
2370                    "sonnet",
2371                    &skill_ref(),
2372                    &[Message::user("hi")],
2373                    None,
2374                    &mut |event| {
2375                        seen += 1;
2376                        assert_eq!(event.name.as_deref(), Some("rm"));
2377                        ControlFlow::Break(())
2378                    },
2379                )
2380                .unwrap();
2381            assert_eq!(seen, 1, "aborted after the first event");
2382            assert_eq!(turn.events.len(), 1);
2383            assert_eq!(turn.events[0].name.as_deref(), Some("rm"));
2384            // Torn off before the result line, so no reply text.
2385            assert!(turn.message.is_empty());
2386        }
2387
2388        #[test]
2389        fn oneharness_stream_errors_when_no_result_line() {
2390            // A stream that ends without a terminal `result` line is a protocol
2391            // error (distinct from a deliberate abort).
2392            let bin = script(
2393                "oh-stream-noresult",
2394                "cat >/dev/null\n\
2395                 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2396                 \"name\":\"bash\",\"input\":{},\"index\":0}}'\n",
2397            );
2398            let err = oh_provider(bin)
2399                .respond_streaming(
2400                    "claude-code",
2401                    "sonnet",
2402                    &skill_ref(),
2403                    &[Message::user("hi")],
2404                    None,
2405                    &mut |_| ControlFlow::Continue(()),
2406                )
2407                .unwrap_err();
2408            assert!(
2409                matches!(err, Error::Provider { .. }),
2410                "expected a provider error, got: {err:?}"
2411            );
2412        }
2413
2414        #[test]
2415        fn oneharness_buffered_run_passes_events_and_omits_mode() {
2416            // The buffered path uses `--compact --events` and — deliberately —
2417            // passes no `--mode` (oneharness's default applies).
2418            let bin = script(
2419                "oh-args",
2420                "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
2421                 cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
2422            );
2423            let dir = bin.parent().unwrap().to_path_buf();
2424            oh_provider(bin)
2425                .respond(
2426                    "claude-code",
2427                    "sonnet",
2428                    &skill_ref(),
2429                    &[Message::user("hi")],
2430                    None,
2431                )
2432                .unwrap();
2433            let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
2434                .unwrap()
2435                .lines()
2436                .map(str::to_string)
2437                .collect();
2438            assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
2439            assert!(args.iter().any(|a| a == "--compact"), "got: {args:?}");
2440            assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
2441            assert!(!args.iter().any(|a| a == "--stream"), "got: {args:?}");
2442        }
2443
2444        #[test]
2445        fn oneharness_stream_run_passes_stream_and_omits_mode() {
2446            // The streaming path uses `--stream --events` and — like the buffered
2447            // path — passes no `--mode`.
2448            let bin = script(
2449                "oh-args-stream",
2450                "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
2451                 cat >/dev/null\n\
2452                 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2453                 \"text\":\"hi\"}]}}'\n",
2454            );
2455            let dir = bin.parent().unwrap().to_path_buf();
2456            oh_provider(bin)
2457                .respond_streaming(
2458                    "claude-code",
2459                    "sonnet",
2460                    &skill_ref(),
2461                    &[Message::user("hi")],
2462                    None,
2463                    &mut |_| ControlFlow::Continue(()),
2464                )
2465                .unwrap();
2466            let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
2467                .unwrap()
2468                .lines()
2469                .map(str::to_string)
2470                .collect();
2471            assert!(args.iter().any(|a| a == "--stream"), "got: {args:?}");
2472            assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
2473            assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
2474            assert!(!args.iter().any(|a| a == "--compact"), "got: {args:?}");
2475        }
2476
2477        #[test]
2478        fn oneharness_falls_back_to_stdout_when_text_null() {
2479            let bin = script(
2480                "oh-fallback",
2481                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2482                 \"stdout\":\"raw reply\"}]}'\n",
2483            );
2484            let user = oh_provider(bin).simulate_user("m", "persona", &[]).unwrap();
2485            assert_eq!(user.message, "raw reply");
2486        }
2487
2488        #[test]
2489        fn oneharness_judge_parses_verdict() {
2490            let bin = script(
2491                "oh-judge",
2492                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2493                 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"good\\\"}\"}]}'\n",
2494            );
2495            let query = JudgeQuery {
2496                kind: JudgeKind::Boolean,
2497                criterion: "polite",
2498                scale: None,
2499            };
2500            let verdict = oh_provider(bin).judge("m", &query, &[]).unwrap();
2501            assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2502            assert_eq!(verdict.reason, "good");
2503        }
2504
2505        #[test]
2506        fn oneharness_classifies_failure_kind() {
2507            let bin = script(
2508                "oh-auth",
2509                "cat >/dev/null\necho '{\"results\":[{\"status\":\"error\",\
2510                 \"failure_kind\":\"auth\",\"error\":\"no creds\"}]}'\n",
2511            );
2512            let err = oh_provider(bin)
2513                .respond("claude-code", "m", &skill_ref(), &[], None)
2514                .unwrap_err();
2515            assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
2516        }
2517
2518        #[test]
2519        fn oneharness_reports_status_without_failure_kind() {
2520            let bin = script(
2521                "oh-err",
2522                "cat >/dev/null\necho '{\"results\":[{\"status\":\"timeout\",\
2523                 \"stderr\":\"deadline\"}]}'\n",
2524            );
2525            let err = oh_provider(bin).simulate_user("m", "p", &[]).unwrap_err();
2526            let msg = err.to_string();
2527            assert!(msg.contains("harness run failed"), "got: {msg}");
2528            assert!(msg.contains("deadline"));
2529        }
2530
2531        #[test]
2532        fn oneharness_errors_on_unparseable_output_and_no_results() {
2533            let garbage = script(
2534                "oh-garbage",
2535                "cat >/dev/null\necho 'not json' 1>&2\necho 'x'\n",
2536            );
2537            assert!(oh_provider(garbage)
2538                .simulate_user("m", "p", &[])
2539                .unwrap_err()
2540                .to_string()
2541                .contains("could not parse oneharness output"));
2542
2543            let empty = script("oh-empty", "cat >/dev/null\necho '{\"results\":[]}'\n");
2544            assert!(oh_provider(empty)
2545                .simulate_user("m", "p", &[])
2546                .unwrap_err()
2547                .to_string()
2548                .contains("no results"));
2549        }
2550
2551        #[test]
2552        fn oneharness_errors_when_no_text_or_stdout() {
2553            let bin = script(
2554                "oh-silent",
2555                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\"}]}'\n",
2556            );
2557            assert!(oh_provider(bin)
2558                .respond("claude-code", "m", &skill_ref(), &[], None)
2559                .unwrap_err()
2560                .to_string()
2561                .contains("neither extractable text nor stdout"));
2562        }
2563
2564        #[test]
2565        fn oneharness_respond_resume_sends_only_latest_message() {
2566            // Capture the prompt (stdin) and the argv to assert resume behavior:
2567            // with a session, only the last user message is sent and --resume is
2568            // forwarded; without, the whole transcript is inlined.
2569            let dir =
2570                std::env::temp_dir().join(format!("skilltest-oh-resume-{}", std::process::id()));
2571            std::fs::create_dir_all(&dir).unwrap();
2572            let prompt_file = dir.join("prompt.txt");
2573            let argv_file = dir.join("argv.txt");
2574            let bin = script(
2575                "oh-resume",
2576                &format!(
2577                    "echo \"$@\" > '{}'\ncat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
2578                    argv_file.display(),
2579                    prompt_file.display(),
2580                ),
2581            );
2582            let messages = [
2583                Message::user("first"),
2584                Message::assistant("reply"),
2585                Message::user("second"),
2586            ];
2587            oh_provider(bin.clone())
2588                .respond(
2589                    "claude-code",
2590                    "sonnet",
2591                    &skill_ref(),
2592                    &messages,
2593                    Some("sess-1"),
2594                )
2595                .unwrap();
2596            let prompt = std::fs::read_to_string(&prompt_file).unwrap();
2597            assert_eq!(
2598                prompt.trim(),
2599                "second",
2600                "resume sends only the latest user message"
2601            );
2602            let argv = std::fs::read_to_string(&argv_file).unwrap();
2603            assert!(argv.contains("--resume sess-1"), "argv: {argv}");
2604            assert!(
2605                argv.contains("--system"),
2606                "skill is the system prompt: {argv}"
2607            );
2608        }
2609
2610        #[test]
2611        fn oneharness_omits_model_flag_when_model_empty() {
2612            let dir =
2613                std::env::temp_dir().join(format!("skilltest-oh-model-{}", std::process::id()));
2614            std::fs::create_dir_all(&dir).unwrap();
2615            let argv_file = dir.join("argv.txt");
2616            let bin = script(
2617                "oh-nomodel",
2618                &format!(
2619                    "echo \"$@\" > '{}'\ncat >/dev/null\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
2620                    argv_file.display(),
2621                ),
2622            );
2623            oh_provider(bin)
2624                .respond("cursor", "", &skill_ref(), &[Message::user("hi")], None)
2625                .unwrap();
2626            let argv = std::fs::read_to_string(&argv_file).unwrap();
2627            assert!(
2628                !argv.contains("--model"),
2629                "empty model omits the flag: {argv}"
2630            );
2631        }
2632
2633        #[test]
2634        fn oneharness_reports_missing_binary() {
2635            let provider = oh_provider(PathBuf::from("/no/such/oneharness-binary"));
2636            let err = provider
2637                .respond("claude-code", "m", &skill_ref(), &[], None)
2638                .unwrap_err();
2639            assert!(err.to_string().contains("could not run"));
2640        }
2641
2642        // ---- ApiJudgeProvider over a fake curl ----
2643
2644        fn api_provider_with_curl(curl: PathBuf, vendor: ApiVendor) -> ApiJudgeProvider {
2645            ApiJudgeProvider::new(&ApiJudgeConfig {
2646                vendor,
2647                api_key_env: Some("SKILLTEST_TEST_API_KEY".to_string()),
2648                base_url: Some("https://example.invalid/v1".to_string()),
2649                timeout_secs: 5,
2650                curl_bin: curl.to_string_lossy().into_owned(),
2651                strict_json: true,
2652            })
2653        }
2654
2655        #[test]
2656        fn api_judge_judges_through_fake_curl() {
2657            // The fake curl echoes an Anthropic-shaped success body.
2658            let curl = script(
2659                "curl-ok",
2660                "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
2661                 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"polite\\\"}\"}],\
2662                 \"usage\":{\"input_tokens\":9,\"output_tokens\":3}}'\n",
2663            );
2664            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2665            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2666            let query = JudgeQuery {
2667                kind: JudgeKind::Boolean,
2668                criterion: "polite",
2669                scale: None,
2670            };
2671            let verdict = provider
2672                .judge("claude-x", &query, &[Message::user("hi")])
2673                .unwrap();
2674            assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2675            assert_eq!(verdict.usage.unwrap().input_tokens, Some(9));
2676            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2677        }
2678
2679        #[test]
2680        fn api_judge_simulates_user_through_fake_curl() {
2681            let curl = script(
2682                "curl-user",
2683                "cat >/dev/null\necho '{\"choices\":[{\"message\":\
2684                 {\"content\":\"sure, go on\"}}]}'\n",
2685            );
2686            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2687            let provider = api_provider_with_curl(curl, ApiVendor::Openai);
2688            let user = provider.simulate_user("gpt-x", "a patient", &[]).unwrap();
2689            assert_eq!(user.message, "sure, go on");
2690            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2691        }
2692
2693        #[test]
2694        fn api_judge_errors_when_key_absent() {
2695            let curl = script("curl-unused", "cat >/dev/null\necho '{}'\n");
2696            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2697            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2698            let err = provider
2699                .judge(
2700                    "m",
2701                    &JudgeQuery {
2702                        kind: JudgeKind::Boolean,
2703                        criterion: "x",
2704                        scale: None,
2705                    },
2706                    &[],
2707                )
2708                .unwrap_err();
2709            assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
2710        }
2711
2712        #[test]
2713        fn api_judge_surfaces_curl_failure() {
2714            let curl = script(
2715                "curl-fail",
2716                "cat >/dev/null\necho 'curl: (6) bad host' 1>&2\nexit 6\n",
2717            );
2718            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2719            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2720            let err = provider
2721                .judge(
2722                    "m",
2723                    &JudgeQuery {
2724                        kind: JudgeKind::Boolean,
2725                        criterion: "x",
2726                        scale: None,
2727                    },
2728                    &[],
2729                )
2730                .unwrap_err();
2731            assert!(err.to_string().contains("curl failed"));
2732            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2733        }
2734
2735        #[test]
2736        fn write_curl_config_sets_private_mode_and_headers() {
2737            let dir = std::env::temp_dir().join(format!("skilltest-cfg-{}", std::process::id()));
2738            std::fs::create_dir_all(&dir).unwrap();
2739            let path = dir.join("c.cfg");
2740            write_curl_config(
2741                &path,
2742                "https://api.example/v1",
2743                &[("x-api-key".to_string(), "secret\"quote".to_string())],
2744                42,
2745            )
2746            .unwrap();
2747            let text = std::fs::read_to_string(&path).unwrap();
2748            assert!(text.contains("url = \"https://api.example/v1\""));
2749            assert!(text.contains("max-time = 42"));
2750            // The quote in the header value is escaped.
2751            assert!(text.contains("secret\\\"quote"), "escaped header: {text}");
2752            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2753            assert_eq!(mode & 0o777, 0o600, "config is private");
2754        }
2755
2756        #[test]
2757        fn api_judge_retries_a_transient_error_then_succeeds() {
2758            // The fake curl returns an overloaded error on its first invocation
2759            // and a success on the second, so the retry path in `chat` is taken.
2760            let dir = std::env::temp_dir().join(format!("skilltest-retry-{}", std::process::id()));
2761            std::fs::create_dir_all(&dir).unwrap();
2762            let counter = dir.join("n");
2763            let curl = script(
2764                "curl-retry",
2765                &format!(
2766                    "cat >/dev/null\nif [ -f '{c}' ]; then \
2767                       echo '{{\"content\":[{{\"type\":\"text\",\"text\":\"{{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}}\"}}]}}'; \
2768                     else touch '{c}'; \
2769                       echo '{{\"error\":{{\"type\":\"overloaded_error\",\"message\":\"busy\"}}}}'; \
2770                     fi\n",
2771                    c = counter.display(),
2772                ),
2773            );
2774            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2775            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2776            let verdict = provider
2777                .judge(
2778                    "m",
2779                    &JudgeQuery {
2780                        kind: JudgeKind::Boolean,
2781                        criterion: "x",
2782                        scale: None,
2783                    },
2784                    &[],
2785                )
2786                .unwrap();
2787            assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2788            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2789        }
2790
2791        #[test]
2792        fn api_judge_gives_up_after_max_retries() {
2793            // Always overloaded: the loop exhausts MAX_RETRIES and surfaces it.
2794            let curl = script(
2795                "curl-busy",
2796                "cat >/dev/null\necho '{\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}'\n",
2797            );
2798            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2799            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2800            let err = provider
2801                .judge(
2802                    "m",
2803                    &JudgeQuery {
2804                        kind: JudgeKind::Boolean,
2805                        criterion: "x",
2806                        scale: None,
2807                    },
2808                    &[],
2809                )
2810                .unwrap_err();
2811            assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "overloaded"));
2812            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2813        }
2814
2815        #[test]
2816        fn api_judge_reports_missing_curl_binary() {
2817            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2818            let provider =
2819                api_provider_with_curl(PathBuf::from("/no/such/curl-binary"), ApiVendor::Anthropic);
2820            let err = provider
2821                .judge(
2822                    "m",
2823                    &JudgeQuery {
2824                        kind: JudgeKind::Boolean,
2825                        criterion: "x",
2826                        scale: None,
2827                    },
2828                    &[],
2829                )
2830                .unwrap_err();
2831            assert!(err.to_string().contains("could not run"));
2832            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2833        }
2834
2835        #[test]
2836        fn api_judge_surfaces_unparseable_response() {
2837            let curl = script("curl-garbage", "cat >/dev/null\necho 'not json at all'\n");
2838            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2839            let provider = api_provider_with_curl(curl, ApiVendor::Openai);
2840            let err = provider
2841                .judge(
2842                    "m",
2843                    &JudgeQuery {
2844                        kind: JudgeKind::Boolean,
2845                        criterion: "x",
2846                        scale: None,
2847                    },
2848                    &[],
2849                )
2850                .unwrap_err();
2851            assert!(err.to_string().contains("could not parse API response"));
2852            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2853        }
2854
2855        #[test]
2856        fn split_provider_routes_judge_and_user_through_the_api() {
2857            // A SplitProvider's judge/simulate_user must hit the API judge (the
2858            // fake curl), while respond goes to the stub responder.
2859            let curl = script(
2860                "split-curl",
2861                "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
2862                 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}\"}]}'\n",
2863            );
2864            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2865            let judge = api_provider_with_curl(curl, ApiVendor::Anthropic);
2866            let split = SplitProvider::new(Box::new(super::StubResponder), judge);
2867            let verdict = split
2868                .judge(
2869                    "m",
2870                    &JudgeQuery {
2871                        kind: JudgeKind::Boolean,
2872                        criterion: "polite",
2873                        scale: None,
2874                    },
2875                    &[],
2876                )
2877                .unwrap();
2878            assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2879            std::env::remove_var("SKILLTEST_TEST_API_KEY");
2880        }
2881
2882        #[test]
2883        fn oneharness_numeric_judge_uses_numeric_prompt() {
2884            // A numeric judge exercises the numeric branch of build_judge_prompt
2885            // (the scale text) and the numeric verdict parse path.
2886            let dir = std::env::temp_dir().join(format!("skilltest-ohnum-{}", std::process::id()));
2887            std::fs::create_dir_all(&dir).unwrap();
2888            let prompt_file = dir.join("prompt.txt");
2889            let bin = script(
2890                "oh-numeric",
2891                &format!(
2892                    "cat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"{{\\\"value\\\": 8.5, \\\"reason\\\": \\\"warm\\\"}}\"}}]}}'\n",
2893                    prompt_file.display(),
2894                ),
2895            );
2896            let query = JudgeQuery {
2897                kind: JudgeKind::Numeric,
2898                criterion: "warmth",
2899                scale: Some((0.0, 10.0)),
2900            };
2901            let verdict = oh_provider(bin)
2902                .judge("m", &query, &[Message::assistant("hi")])
2903                .unwrap();
2904            assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 8.5).abs() < 1e-9));
2905            let prompt = std::fs::read_to_string(&prompt_file).unwrap();
2906            assert!(
2907                prompt.contains("scale from 0 to 10"),
2908                "numeric prompt: {prompt}"
2909            );
2910        }
2911
2912        #[test]
2913        fn supports_resume_method_matches_free_function() {
2914            let provider = oh_provider(PathBuf::from("/bin/true"));
2915            assert!(provider.supports_resume("claude-code"));
2916            assert!(!provider.supports_resume("codex"));
2917        }
2918    }
2919
2920    // Non-subprocess error-path coverage for the verdict parser and the
2921    // classified-error fallback.
2922
2923    #[test]
2924    fn parse_verdict_rejects_missing_object_and_value() {
2925        // No JSON object at all.
2926        assert!(parse_verdict(JudgeKind::Boolean, "just prose, no braces").is_err());
2927        // A JSON object with no `value` field.
2928        assert!(parse_verdict(JudgeKind::Boolean, "{\"reason\": \"x\"}").is_err());
2929        // Malformed JSON inside the braces.
2930        assert!(parse_verdict(JudgeKind::Numeric, "{not: valid}").is_err());
2931    }
2932
2933    #[test]
2934    fn extract_json_object_handles_reversed_braces() {
2935        // A stray `}` before `{` is not a valid object span.
2936        assert_eq!(extract_json_object("} then {"), None);
2937    }
2938
2939    #[test]
2940    fn unclassified_api_error_falls_back_to_plain_provider_error() {
2941        // An error type we don't classify becomes an unclassified provider error.
2942        let raw = r#"{"error":{"type":"some_new_error","message":"odd"}}"#;
2943        let err = parse_chat_response(ApiVendor::Openai, raw).unwrap_err();
2944        assert!(matches!(err, Error::Provider { kind: None, .. }));
2945        assert!(err.to_string().contains("odd"));
2946    }
2947
2948    #[test]
2949    fn openai_empty_choice_text_is_an_error() {
2950        let raw = r#"{"choices":[]}"#;
2951        assert!(parse_chat_response(ApiVendor::Openai, raw).is_err());
2952    }
2953
2954    #[test]
2955    fn truncate_for_error_caps_length_on_a_char_boundary() {
2956        let long = "x".repeat(1000);
2957        assert_eq!(truncate_for_error(&long).chars().count(), 500);
2958    }
2959}