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