Skip to main content

skilltest_core/
provider.rs

1//! The provider boundary. `skilltest` never talks to a model directly; a
2//! [`Provider`] runs the skill, plays the simulated user, and judges the
3//! transcript.
4//!
5//! There are two real implementations. [`OneharnessProvider`] (the default) runs
6//! each prompt on a harness through the
7//! [`oneharness`](https://github.com/nickderobertis/oneharness) CLI and parses
8//! its JSON. [`CommandProvider`] speaks a small JSON-lines protocol (see
9//! `docs/protocol.md`) and backs both the deterministic `skilltest-fake-provider`
10//! used by the gate and any custom provider you write. The [`Provider`] trait
11//! also lets the runner be unit-tested against an in-memory fake.
12
13use std::io::{BufRead as _, BufReader, Write as _};
14use std::ops::ControlFlow;
15use std::process::{Command, Stdio};
16
17use serde::{Deserialize, Serialize};
18
19use crate::config::{ApiJudgeConfig, ApiVendor, OneharnessConfig};
20use crate::conversation::{Message, Role, ToolEvent};
21use crate::error::{Error, Result};
22use crate::eval::JudgeValue;
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
769impl OneharnessProvider {
770    /// Build a provider from its configuration.
771    #[must_use]
772    pub fn new(config: &OneharnessConfig) -> Self {
773        Self {
774            bin: config.bin.clone(),
775            judge_harness: config.judge_harness.clone(),
776            timeout_secs: config.timeout_secs,
777        }
778    }
779
780    /// Run one prompt on `harness` and return the normalized text plus the
781    /// session id and usage (when oneharness lifted them from the harness's
782    /// output).
783    fn run(&self, args: &RunArgs<'_>) -> Result<RunOutcome> {
784        let timeout = self.timeout_secs.to_string();
785        let mut cmd = Command::new(&self.bin);
786        // Intentionally no `--output-format` override: oneharness already requests
787        // each harness's *default* format (json for claude-code/opencode,
788        // stream-json for cursor, text for codex/goose/qwen/crush/copilot) and
789        // extracts the reply accordingly. Forcing `json` everywhere broke the
790        // text-native harnesses — oneharness would json-extract their plain-text
791        // reply and find nothing ("harness produced no extractable text").
792        //
793        // `--events` asks oneharness to surface normalized tool events. It is safe
794        // for text extraction: oneharness only upgrades a harness whose default
795        // format carries no tool transcript to its events-capable format
796        // (claude→stream-json, codex→exec --json, qwen→stream-json) and still
797        // extracts the reply from it; harnesses whose default already carries a
798        // transcript (opencode, cursor) or expose none (goose/crush/copilot) are
799        // left on their default. So the reply keeps working everywhere and
800        // `events` is populated wherever the harness can express it.
801        //
802        // No `--mode`: oneharness applies its own default approval mode; users
803        // tune it (e.g. `bypass`) via oneharness config, not from here.
804        cmd.args([
805            "run",
806            "--harness",
807            args.harness,
808            "--compact",
809            "--events",
810            "--timeout",
811            &timeout,
812            "--prompt-file",
813            "-",
814        ]);
815        // An empty model means "unspecified" — omit `--model` so the harness uses
816        // its own default (cursor/crush/copilot) or an env-selected model (qwen
817        // via OPENAI_MODEL, goose via GOOSE_MODEL), exactly as oneharness's own
818        // smoke scripts do. Forwarding `--model ""` would push a broken empty
819        // model flag to the harness CLI.
820        if !args.model.is_empty() {
821            cmd.args(["--model", args.model]);
822        }
823        if let Some(system) = args.system {
824            cmd.args(["--system", system]);
825        }
826        if let Some(resume) = args.resume {
827            cmd.args(["--resume", resume]);
828        }
829        // A mock plan rides oneharness's ephemeral per-run delivery: the
830        // compiled ruleset via `--mock-rules`, and always a `--spy-file` so
831        // every observed call (mocked or allowed) is recorded.
832        let mock_files = args.mocks.map(MockFiles::prepare).transpose()?;
833        if let Some(files) = &mock_files {
834            if let Some(rules) = &files.rules {
835                cmd.arg("--mock-rules");
836                cmd.arg(rules);
837            }
838            cmd.arg("--spy-file");
839            cmd.arg(&files.spy);
840        }
841
842        let mut child = cmd
843            .stdin(Stdio::piped())
844            .stdout(Stdio::piped())
845            .stderr(Stdio::piped())
846            .spawn()
847            .map_err(|e| {
848                Error::provider(
849                    "oneharness",
850                    format!(
851                        "could not run `{}`: {e}. Is oneharness installed and on PATH?",
852                        self.bin
853                    ),
854                )
855            })?;
856
857        child
858            .stdin
859            .as_mut()
860            .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?
861            .write_all(args.prompt.as_bytes())
862            .map_err(|e| Error::provider("oneharness", format!("could not write prompt: {e}")))?;
863
864        let output = child.wait_with_output().map_err(|e| {
865            Error::provider("oneharness", format!("oneharness did not complete: {e}"))
866        })?;
867
868        let stdout = String::from_utf8_lossy(&output.stdout);
869        let envelope: OhEnvelope = serde_json::from_str(stdout.trim()).map_err(|e| {
870            Error::provider(
871                "oneharness",
872                format!(
873                    "could not parse oneharness output: {e}; stderr: {}",
874                    String::from_utf8_lossy(&output.stderr).trim()
875                ),
876            )
877        })?;
878
879        let result = envelope
880            .results
881            .into_iter()
882            .next()
883            .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
884
885        if result.status != "ok" {
886            let detail = result
887                .error
888                .filter(|e| !e.is_empty())
889                .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
890                .unwrap_or_else(|| format!("status `{}`", result.status));
891            let context = format!("oneharness:{}", args.harness);
892            let message = format!("harness run failed: {detail}");
893            return Err(match result.failure_kind {
894                Some(kind) if !kind.is_empty() => {
895                    Error::provider_classified(context, message, kind)
896                }
897                _ => Error::provider(context, message),
898            });
899        }
900
901        // Prefer oneharness's extracted `text`; fall back to raw stdout when a
902        // harness's output shape defeats extraction (oneharness's documented
903        // contract — see OhResult::stdout). Only a run that produced *neither* is
904        // a real error.
905        let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
906            Error::provider(
907                format!("oneharness:{}", args.harness),
908                "harness produced neither extractable text nor stdout",
909            )
910        })?;
911        let mock_calls = mock_files.as_ref().map(MockFiles::records).transpose()?;
912        Ok(RunOutcome {
913            text,
914            session_id: result.session_id,
915            usage: result.usage,
916            events: result.events.unwrap_or_default(),
917            mock_calls,
918        })
919    }
920
921    /// Like [`OneharnessProvider::run`], but drives `oneharness run --stream`,
922    /// forwarding each normalized tool event to `on_event` the instant it is
923    /// observed. When `on_event` returns [`ControlFlow::Break`], the oneharness
924    /// child is killed — closing its stream tears the harness down, so a bad turn
925    /// is cut off instead of paid for in full — and the partial outcome (the
926    /// events seen so far) is returned.
927    fn run_streaming(
928        &self,
929        args: &RunArgs<'_>,
930        on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
931    ) -> Result<RunOutcome> {
932        let timeout = self.timeout_secs.to_string();
933        let mut cmd = Command::new(&self.bin);
934        // `--stream` emits NDJSON: one `{"type":"event",…}` line per tool event
935        // as observed, then a terminal `{"type":"result","report":{…}}`. It
936        // implies `--events`; no `--compact` (the stream is line-oriented) and no
937        // `--mode` (oneharness's default applies — see `run`).
938        cmd.args([
939            "run",
940            "--harness",
941            args.harness,
942            "--stream",
943            "--events",
944            "--timeout",
945            &timeout,
946            "--prompt-file",
947            "-",
948        ]);
949        if !args.model.is_empty() {
950            cmd.args(["--model", args.model]);
951        }
952        if let Some(system) = args.system {
953            cmd.args(["--system", system]);
954        }
955        if let Some(resume) = args.resume {
956            cmd.args(["--resume", resume]);
957        }
958        let mock_files = args.mocks.map(MockFiles::prepare).transpose()?;
959        if let Some(files) = &mock_files {
960            if let Some(rules) = &files.rules {
961                cmd.arg("--mock-rules");
962                cmd.arg(rules);
963            }
964            cmd.arg("--spy-file");
965            cmd.arg(&files.spy);
966        }
967
968        let mut child = cmd
969            .stdin(Stdio::piped())
970            .stdout(Stdio::piped())
971            .stderr(Stdio::piped())
972            .spawn()
973            .map_err(|e| {
974                Error::provider(
975                    "oneharness",
976                    format!(
977                        "could not run `{}`: {e}. Is oneharness installed and on PATH?",
978                        self.bin
979                    ),
980                )
981            })?;
982
983        // Write the prompt and close stdin so oneharness starts, then read its
984        // NDJSON incrementally — events arrive live and we never deadlock on a
985        // full stdout pipe.
986        {
987            let mut stdin = child
988                .stdin
989                .take()
990                .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?;
991            stdin.write_all(args.prompt.as_bytes()).map_err(|e| {
992                Error::provider("oneharness", format!("could not write prompt: {e}"))
993            })?;
994        }
995
996        let stdout = child
997            .stdout
998            .take()
999            .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdout"))?;
1000        let reader = BufReader::new(stdout);
1001
1002        let mut events: Vec<ToolEvent> = Vec::new();
1003        let mut result_env: Option<OhEnvelope> = None;
1004        let mut aborted = false;
1005
1006        for line in reader.lines() {
1007            let line = line.map_err(|e| {
1008                Error::provider("oneharness", format!("could not read stream: {e}"))
1009            })?;
1010            let trimmed = line.trim();
1011            if trimmed.is_empty() {
1012                continue;
1013            }
1014            // Tolerate non-JSON log lines interleaved on the stream.
1015            let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1016                continue;
1017            };
1018            match value.get("type").and_then(serde_json::Value::as_str) {
1019                Some("event") => {
1020                    if let Ok(event) = serde_json::from_value::<ToolEvent>(value["event"].clone()) {
1021                        let flow = on_event(&event);
1022                        events.push(event);
1023                        if flow.is_break() {
1024                            aborted = true;
1025                            let _ = child.kill();
1026                            break;
1027                        }
1028                    }
1029                }
1030                Some("result") => {
1031                    if let Ok(env) = serde_json::from_value::<OhEnvelope>(value["report"].clone()) {
1032                        result_env = Some(env);
1033                    }
1034                }
1035                _ => {}
1036            }
1037        }
1038
1039        let output = child.wait_with_output().map_err(|e| {
1040            Error::provider("oneharness", format!("oneharness did not complete: {e}"))
1041        })?;
1042
1043        if aborted {
1044            // Torn down on purpose; return the partial turn (events seen so
1045            // far). The spy log may be torn mid-line by the kill, and an
1046            // aborted run is never scored, so no records are reported.
1047            return Ok(RunOutcome {
1048                text: String::new(),
1049                session_id: None,
1050                usage: None,
1051                events,
1052                mock_calls: None,
1053            });
1054        }
1055
1056        let envelope = result_env.ok_or_else(|| {
1057            Error::provider(
1058                "oneharness",
1059                format!(
1060                    "oneharness stream produced no result; stderr: {}",
1061                    String::from_utf8_lossy(&output.stderr).trim()
1062                ),
1063            )
1064        })?;
1065        let result = envelope
1066            .results
1067            .into_iter()
1068            .next()
1069            .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
1070        if result.status != "ok" {
1071            let detail = result
1072                .error
1073                .filter(|e| !e.is_empty())
1074                .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
1075                .unwrap_or_else(|| format!("status `{}`", result.status));
1076            let context = format!("oneharness:{}", args.harness);
1077            let message = format!("harness run failed: {detail}");
1078            return Err(match result.failure_kind {
1079                Some(kind) if !kind.is_empty() => {
1080                    Error::provider_classified(context, message, kind)
1081                }
1082                _ => Error::provider(context, message),
1083            });
1084        }
1085        let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
1086            Error::provider(
1087                format!("oneharness:{}", args.harness),
1088                "harness produced neither extractable text nor stdout",
1089            )
1090        })?;
1091        // Prefer the events we streamed; fall back to the result's events only if
1092        // the stream carried none.
1093        let events = if events.is_empty() {
1094            result.events.unwrap_or_default()
1095        } else {
1096            events
1097        };
1098        let mock_calls = mock_files.as_ref().map(MockFiles::records).transpose()?;
1099        Ok(RunOutcome {
1100            text,
1101            session_id: result.session_id,
1102            usage: result.usage,
1103            events,
1104            mock_calls,
1105        })
1106    }
1107}
1108
1109impl Provider for OneharnessProvider {
1110    fn respond(
1111        &self,
1112        platform: &str,
1113        model: &str,
1114        skill: &SkillRef<'_>,
1115        messages: &[Message],
1116        session: Option<&str>,
1117    ) -> Result<AssistantTurn> {
1118        self.respond_with_mocks(platform, model, skill, messages, session, None)
1119    }
1120
1121    fn respond_with_mocks(
1122        &self,
1123        platform: &str,
1124        model: &str,
1125        skill: &SkillRef<'_>,
1126        messages: &[Message],
1127        session: Option<&str>,
1128        mocks: Option<&MockPlan<'_>>,
1129    ) -> Result<AssistantTurn> {
1130        // If we have a real session to continue on a supporting harness, only
1131        // send the last user message — the harness still has its prior state.
1132        // Otherwise inline the whole transcript so harnesses without resume
1133        // still see the conversation.
1134        let prompt = if session.is_some() {
1135            latest_user_message(messages).unwrap_or_default()
1136        } else {
1137            render_transcript_for_respond(messages)
1138        };
1139        let outcome = self.run(&RunArgs {
1140            harness: platform,
1141            model,
1142            prompt: &prompt,
1143            system: Some(skill.instructions),
1144            resume: session,
1145            mocks,
1146        })?;
1147        Ok(AssistantTurn {
1148            message: outcome.text.trim().to_string(),
1149            done: false,
1150            usage: outcome.usage,
1151            session_id: outcome.session_id,
1152            events: outcome.events,
1153            mock_calls: outcome.mock_calls,
1154        })
1155    }
1156
1157    fn respond_streaming(
1158        &self,
1159        platform: &str,
1160        model: &str,
1161        skill: &SkillRef<'_>,
1162        messages: &[Message],
1163        session: Option<&str>,
1164        on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1165    ) -> Result<AssistantTurn> {
1166        self.respond_streaming_with_mocks(platform, model, skill, messages, session, None, on_event)
1167    }
1168
1169    // One over clippy's arg limit; the signature is respond_streaming's plus
1170    // the mock plan, and a params struct would obscure the trait symmetry.
1171    #[allow(clippy::too_many_arguments)]
1172    fn respond_streaming_with_mocks(
1173        &self,
1174        platform: &str,
1175        model: &str,
1176        skill: &SkillRef<'_>,
1177        messages: &[Message],
1178        session: Option<&str>,
1179        mocks: Option<&MockPlan<'_>>,
1180        on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1181    ) -> Result<AssistantTurn> {
1182        let prompt = if session.is_some() {
1183            latest_user_message(messages).unwrap_or_default()
1184        } else {
1185            render_transcript_for_respond(messages)
1186        };
1187        let outcome = self.run_streaming(
1188            &RunArgs {
1189                harness: platform,
1190                model,
1191                prompt: &prompt,
1192                system: Some(skill.instructions),
1193                resume: session,
1194                mocks,
1195            },
1196            on_event,
1197        )?;
1198        Ok(AssistantTurn {
1199            message: outcome.text.trim().to_string(),
1200            done: false,
1201            usage: outcome.usage,
1202            session_id: outcome.session_id,
1203            events: outcome.events,
1204            mock_calls: outcome.mock_calls,
1205        })
1206    }
1207
1208    fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1209        let prompt = build_user_prompt(persona, messages);
1210        let outcome = self.run(&RunArgs::plain(&self.judge_harness, model, &prompt))?;
1211        Ok(UserTurn {
1212            message: outcome.text.trim().to_string(),
1213            stop: false,
1214            usage: outcome.usage,
1215        })
1216    }
1217
1218    fn judge(
1219        &self,
1220        model: &str,
1221        query: &JudgeQuery<'_>,
1222        messages: &[Message],
1223    ) -> Result<JudgeVerdict> {
1224        let prompt = build_judge_prompt(query, messages);
1225        let outcome = self.run(&RunArgs::plain(&self.judge_harness, model, &prompt))?;
1226        let mut verdict = parse_verdict(query.kind, &outcome.text)?;
1227        verdict.usage = outcome.usage;
1228        Ok(verdict)
1229    }
1230
1231    fn supports_resume(&self, platform: &str) -> bool {
1232        supports_resume(platform)
1233    }
1234}
1235
1236/// The harnesses oneharness's adapter table marks `supports_resume = true`
1237/// (claude-code's `--resume`, opencode's `--session`, cursor's `--resume`). Kept
1238/// in sync with the `oneharness list` registry — when a new harness ships
1239/// session continuation, add it here so the runner threads `session_id`.
1240#[must_use]
1241pub fn supports_resume(harness: &str) -> bool {
1242    matches!(harness, "claude-code" | "opencode" | "cursor")
1243}
1244
1245// ---------------------------------------------------------------------------
1246// ApiJudgeProvider + SplitProvider
1247// ---------------------------------------------------------------------------
1248
1249/// A judge-only [`Provider`] that scores evals and plays the simulated user with
1250/// a *direct* model API call (Anthropic or OpenAI), rather than running them
1251/// through a harness.
1252///
1253/// Why this exists: routing the judge through a full agentic harness pays an
1254/// agent-loop cold start on every short verdict. A direct API call is one HTTP
1255/// round trip — faster and cheaper on API-key auth — and still reuses the exact
1256/// same judge/user prompts and tolerant verdict parsing as
1257/// [`OneharnessProvider`], so the two are directly comparable.
1258///
1259/// It does not run skills: `respond` returns an error. Compose it with a
1260/// skill-running provider via [`SplitProvider`] so the harness under test still
1261/// drives `respond`, while the judge runs on the API.
1262///
1263/// The request is sent with `curl` (Rust has no official vendor SDK). The API
1264/// key is read from an env var and passed through a private (`0600`) `curl`
1265/// config file, so it never appears in `argv` / `ps`.
1266pub struct ApiJudgeProvider {
1267    vendor: ApiVendor,
1268    api_key_env: String,
1269    endpoint: String,
1270    timeout_secs: u64,
1271    curl_bin: String,
1272    strict_json: bool,
1273}
1274
1275/// How many times a transient API failure (rate limit / overload) is retried
1276/// before giving up, with exponential backoff between attempts.
1277const MAX_RETRIES: u32 = 2;
1278
1279/// One model reply plus the usage the API reported for it.
1280#[derive(Debug)]
1281struct ChatOutcome {
1282    text: String,
1283    usage: Option<Usage>,
1284}
1285
1286/// A minimal system prompt; the full judge / user-simulation instructions live
1287/// in the shared prompt builders, so this stays identical across vendors.
1288const JUDGE_SYSTEM: &str =
1289    "Follow the user's instructions exactly and respond with only what they ask for.";
1290
1291impl ApiJudgeProvider {
1292    /// Build a provider from its configuration, resolving per-vendor defaults
1293    /// for the API-key env var and endpoint.
1294    #[must_use]
1295    pub fn new(config: &ApiJudgeConfig) -> Self {
1296        let api_key_env = config
1297            .api_key_env
1298            .clone()
1299            .unwrap_or_else(|| match config.vendor {
1300                ApiVendor::Anthropic => "ANTHROPIC_API_KEY".to_string(),
1301                ApiVendor::Openai => "OPENAI_API_KEY".to_string(),
1302            });
1303        let endpoint = config
1304            .base_url
1305            .clone()
1306            .unwrap_or_else(|| match config.vendor {
1307                ApiVendor::Anthropic => "https://api.anthropic.com/v1/messages".to_string(),
1308                ApiVendor::Openai => "https://api.openai.com/v1/chat/completions".to_string(),
1309            });
1310        Self {
1311            vendor: config.vendor,
1312            api_key_env,
1313            endpoint,
1314            timeout_secs: config.timeout_secs,
1315            curl_bin: config.curl_bin.clone(),
1316            strict_json: config.strict_json,
1317        }
1318    }
1319
1320    /// One chat round trip: build the vendor request, POST it, parse the reply.
1321    /// `schema`, when set, constrains the reply to that JSON schema via the
1322    /// vendor's structured-outputs feature. Transient failures (rate limit /
1323    /// overload) are retried with exponential backoff.
1324    fn chat(
1325        &self,
1326        model: &str,
1327        system: &str,
1328        user: &str,
1329        schema: Option<serde_json::Value>,
1330    ) -> Result<ChatOutcome> {
1331        let key = std::env::var(&self.api_key_env).map_err(|_| {
1332            Error::provider_classified(
1333                "api-judge",
1334                format!("API key env var `{}` is not set", self.api_key_env),
1335                "auth",
1336            )
1337        })?;
1338        let body = build_chat_body(self.vendor, model, system, user, schema);
1339        let payload = serde_json::to_vec(&body)
1340            .map_err(|e| Error::provider("api-judge", format!("could not encode request: {e}")))?;
1341
1342        let mut attempt = 0;
1343        loop {
1344            let result = self
1345                .run_curl(&key, &payload)
1346                .and_then(|raw| parse_chat_response(self.vendor, &raw));
1347            match result {
1348                Ok(outcome) => return Ok(outcome),
1349                Err(err) if attempt < MAX_RETRIES && is_retryable(&err) => {
1350                    attempt += 1;
1351                    std::thread::sleep(std::time::Duration::from_millis(500 * (1 << attempt)));
1352                }
1353                Err(err) => return Err(err),
1354            }
1355        }
1356    }
1357
1358    /// Per-vendor request headers.
1359    fn headers(&self, key: &str) -> Vec<(String, String)> {
1360        match self.vendor {
1361            ApiVendor::Anthropic => vec![
1362                ("x-api-key".to_string(), key.to_string()),
1363                ("anthropic-version".to_string(), "2023-06-01".to_string()),
1364                ("content-type".to_string(), "application/json".to_string()),
1365            ],
1366            ApiVendor::Openai => vec![
1367                ("authorization".to_string(), format!("Bearer {key}")),
1368                ("content-type".to_string(), "application/json".to_string()),
1369            ],
1370        }
1371    }
1372
1373    /// POST `body` via `curl`, with the URL + headers (including the API key) in
1374    /// a private config file so the key stays out of `argv`. Returns stdout.
1375    fn run_curl(&self, key: &str, body: &[u8]) -> Result<String> {
1376        let path = std::env::temp_dir().join(format!(
1377            "skilltest-judge-{}-{}.cfg",
1378            std::process::id(),
1379            curl_config_nonce()
1380        ));
1381        write_curl_config(&path, &self.endpoint, &self.headers(key), self.timeout_secs)?;
1382        let outcome = self.exec_curl(&path, body);
1383        // The key-bearing config is needed only for this one invocation.
1384        let _ = std::fs::remove_file(&path);
1385        outcome
1386    }
1387
1388    fn exec_curl(&self, config_path: &std::path::Path, body: &[u8]) -> Result<String> {
1389        let mut child = Command::new(&self.curl_bin)
1390            .arg("--config")
1391            .arg(config_path)
1392            .arg("--data-binary")
1393            .arg("@-")
1394            .stdin(Stdio::piped())
1395            .stdout(Stdio::piped())
1396            .stderr(Stdio::piped())
1397            .spawn()
1398            .map_err(|e| {
1399                Error::provider(
1400                    "api-judge",
1401                    format!(
1402                        "could not run `{}`: {e}. Is curl installed and on PATH?",
1403                        self.curl_bin
1404                    ),
1405                )
1406            })?;
1407
1408        child
1409            .stdin
1410            .as_mut()
1411            .ok_or_else(|| Error::provider("api-judge", "could not open curl stdin"))?
1412            .write_all(body)
1413            .map_err(|e| Error::provider("api-judge", format!("could not write request: {e}")))?;
1414
1415        let output = child
1416            .wait_with_output()
1417            .map_err(|e| Error::provider("api-judge", format!("curl did not complete: {e}")))?;
1418
1419        if !output.status.success() {
1420            let stderr = String::from_utf8_lossy(&output.stderr);
1421            return Err(Error::provider(
1422                "api-judge",
1423                format!("curl failed ({}): {}", output.status, stderr.trim()),
1424            ));
1425        }
1426        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1427    }
1428}
1429
1430impl Provider for ApiJudgeProvider {
1431    fn respond(
1432        &self,
1433        _platform: &str,
1434        _model: &str,
1435        _skill: &SkillRef<'_>,
1436        _messages: &[Message],
1437        _session: Option<&str>,
1438    ) -> Result<AssistantTurn> {
1439        Err(Error::provider(
1440            "api-judge",
1441            "the API judge does not run skills; use it as the judge in a SplitProvider",
1442        ))
1443    }
1444
1445    fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1446        let prompt = build_user_prompt(persona, messages);
1447        // Free-form text reply — never schema-constrained.
1448        let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, None)?;
1449        Ok(UserTurn {
1450            message: outcome.text.trim().to_string(),
1451            stop: false,
1452            usage: outcome.usage,
1453        })
1454    }
1455
1456    fn judge(
1457        &self,
1458        model: &str,
1459        query: &JudgeQuery<'_>,
1460        messages: &[Message],
1461    ) -> Result<JudgeVerdict> {
1462        let prompt = build_judge_prompt(query, messages);
1463        // Constrain the verdict to the `{value, reason}` schema when strict JSON
1464        // is on, so the reply is guaranteed parseable rather than scraped.
1465        let schema = self.strict_json.then(|| verdict_schema(query.kind));
1466        let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, schema)?;
1467        let mut verdict = parse_verdict(query.kind, &outcome.text)?;
1468        verdict.usage = outcome.usage;
1469        Ok(verdict)
1470    }
1471}
1472
1473/// A [`Provider`] that runs skills with one provider and judges with another:
1474/// `respond` (and `supports_resume`) go to the skill-running provider; `judge`
1475/// and `simulate_user` go to the judge. This keeps harness fidelity for the
1476/// thing under test while letting the judge run on a fast, cheap, deterministic
1477/// backend (typically [`ApiJudgeProvider`]).
1478pub struct SplitProvider {
1479    responder: Box<dyn Provider>,
1480    judge: ApiJudgeProvider,
1481}
1482
1483impl SplitProvider {
1484    /// Compose a skill-running `responder` with an API `judge`.
1485    #[must_use]
1486    pub fn new(responder: Box<dyn Provider>, judge: ApiJudgeProvider) -> Self {
1487        Self { responder, judge }
1488    }
1489}
1490
1491impl Provider for SplitProvider {
1492    fn respond(
1493        &self,
1494        platform: &str,
1495        model: &str,
1496        skill: &SkillRef<'_>,
1497        messages: &[Message],
1498        session: Option<&str>,
1499    ) -> Result<AssistantTurn> {
1500        self.responder
1501            .respond(platform, model, skill, messages, session)
1502    }
1503
1504    fn respond_with_mocks(
1505        &self,
1506        platform: &str,
1507        model: &str,
1508        skill: &SkillRef<'_>,
1509        messages: &[Message],
1510        session: Option<&str>,
1511        mocks: Option<&MockPlan<'_>>,
1512    ) -> Result<AssistantTurn> {
1513        self.responder
1514            .respond_with_mocks(platform, model, skill, messages, session, mocks)
1515    }
1516
1517    // One over clippy's arg limit; the signature is respond_streaming's plus
1518    // the mock plan, and a params struct would obscure the trait symmetry.
1519    #[allow(clippy::too_many_arguments)]
1520    fn respond_streaming_with_mocks(
1521        &self,
1522        platform: &str,
1523        model: &str,
1524        skill: &SkillRef<'_>,
1525        messages: &[Message],
1526        session: Option<&str>,
1527        mocks: Option<&MockPlan<'_>>,
1528        on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1529    ) -> Result<AssistantTurn> {
1530        self.responder.respond_streaming_with_mocks(
1531            platform, model, skill, messages, session, mocks, on_event,
1532        )
1533    }
1534
1535    fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1536        self.judge.simulate_user(model, persona, messages)
1537    }
1538
1539    fn judge(
1540        &self,
1541        model: &str,
1542        query: &JudgeQuery<'_>,
1543        messages: &[Message],
1544    ) -> Result<JudgeVerdict> {
1545        self.judge.judge(model, query, messages)
1546    }
1547
1548    fn supports_resume(&self, platform: &str) -> bool {
1549        self.responder.supports_resume(platform)
1550    }
1551}
1552
1553/// A process-local monotonic counter, combined with the pid to make a unique
1554/// temp-file name for each concurrent `curl` config.
1555fn curl_config_nonce() -> u64 {
1556    use std::sync::atomic::{AtomicU64, Ordering};
1557    static COUNTER: AtomicU64 = AtomicU64::new(0);
1558    COUNTER.fetch_add(1, Ordering::Relaxed)
1559}
1560
1561/// Escape a value for a double-quoted `curl` config entry.
1562fn curl_escape(value: &str) -> String {
1563    value.replace('\\', "\\\\").replace('"', "\\\"")
1564}
1565
1566/// Write a `curl` config file (`0600` on Unix) carrying the URL, headers, and
1567/// timeout. The request body is streamed separately on stdin (`--data-binary
1568/// @-`), so it never needs escaping into this file.
1569fn write_curl_config(
1570    path: &std::path::Path,
1571    url: &str,
1572    headers: &[(String, String)],
1573    timeout_secs: u64,
1574) -> Result<()> {
1575    let mut config = String::new();
1576    config.push_str(&format!("url = \"{}\"\n", curl_escape(url)));
1577    config.push_str("request = \"POST\"\n");
1578    for (name, value) in headers {
1579        config.push_str(&format!("header = \"{}: {}\"\n", name, curl_escape(value)));
1580    }
1581    config.push_str(&format!("max-time = {timeout_secs}\n"));
1582    config.push_str("silent\nshow-error\n");
1583
1584    let mut options = std::fs::OpenOptions::new();
1585    options.write(true).create(true).truncate(true);
1586    #[cfg(unix)]
1587    {
1588        use std::os::unix::fs::OpenOptionsExt as _;
1589        options.mode(0o600);
1590    }
1591    let mut file = options
1592        .open(path)
1593        .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1594    file.write_all(config.as_bytes())
1595        .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1596    Ok(())
1597}
1598
1599/// The JSON schema a judge verdict must match: `{value, reason}` with `value`
1600/// typed by the eval kind. Numeric bounds are intentionally omitted — vendor
1601/// structured outputs don't enforce `minimum`/`maximum`, and the runner already
1602/// range-checks the parsed value.
1603fn verdict_schema(kind: JudgeKind) -> serde_json::Value {
1604    let value_type = match kind {
1605        JudgeKind::Boolean => "boolean",
1606        JudgeKind::Numeric => "number",
1607    };
1608    serde_json::json!({
1609        "type": "object",
1610        "properties": {
1611            "value": { "type": value_type },
1612            "reason": { "type": "string" },
1613        },
1614        "required": ["value", "reason"],
1615        "additionalProperties": false,
1616    })
1617}
1618
1619/// Build the JSON request body for one chat completion. Outgoing data, so it is
1620/// constructed directly; responses are parsed into typed models below. When
1621/// `schema` is set, the vendor's structured-outputs field is added so the reply
1622/// is guaranteed to match it.
1623fn build_chat_body(
1624    vendor: ApiVendor,
1625    model: &str,
1626    system: &str,
1627    user: &str,
1628    schema: Option<serde_json::Value>,
1629) -> serde_json::Value {
1630    match vendor {
1631        ApiVendor::Anthropic => {
1632            let mut body = serde_json::json!({
1633                "model": model,
1634                "max_tokens": 1024,
1635                "system": system,
1636                "messages": [{ "role": "user", "content": user }],
1637            });
1638            if let Some(schema) = schema {
1639                body["output_config"] =
1640                    serde_json::json!({ "format": { "type": "json_schema", "schema": schema } });
1641            }
1642            body
1643        }
1644        ApiVendor::Openai => {
1645            let mut body = serde_json::json!({
1646                "model": model,
1647                "max_tokens": 1024,
1648                "messages": [
1649                    { "role": "system", "content": system },
1650                    { "role": "user", "content": user },
1651                ],
1652            });
1653            if let Some(schema) = schema {
1654                body["response_format"] = serde_json::json!({
1655                    "type": "json_schema",
1656                    "json_schema": { "name": "verdict", "strict": true, "schema": schema },
1657                });
1658            }
1659            body
1660        }
1661    }
1662}
1663
1664/// True iff the error is a transient API condition worth retrying.
1665fn is_retryable(err: &Error) -> bool {
1666    matches!(
1667        err,
1668        Error::Provider { kind: Some(k), .. } if k == "rate_limit" || k == "overloaded"
1669    )
1670}
1671
1672// Typed views of the vendor responses (trust-boundary input — always parsed,
1673// never string-matched).
1674
1675#[derive(Deserialize)]
1676struct ApiErrorBody {
1677    #[serde(rename = "type", default)]
1678    kind: Option<String>,
1679    #[serde(default)]
1680    message: Option<String>,
1681}
1682
1683#[derive(Deserialize)]
1684struct AnthropicBlock {
1685    #[serde(rename = "type")]
1686    kind: String,
1687    #[serde(default)]
1688    text: Option<String>,
1689}
1690
1691#[derive(Deserialize)]
1692struct AnthropicUsage {
1693    #[serde(default)]
1694    input_tokens: Option<u64>,
1695    #[serde(default)]
1696    output_tokens: Option<u64>,
1697}
1698
1699#[derive(Deserialize)]
1700struct AnthropicResponse {
1701    #[serde(default)]
1702    content: Vec<AnthropicBlock>,
1703    #[serde(default)]
1704    usage: Option<AnthropicUsage>,
1705    #[serde(default)]
1706    stop_reason: Option<String>,
1707    #[serde(default)]
1708    error: Option<ApiErrorBody>,
1709}
1710
1711#[derive(Deserialize)]
1712struct OpenAiMessage {
1713    #[serde(default)]
1714    content: Option<String>,
1715}
1716
1717#[derive(Deserialize)]
1718struct OpenAiChoice {
1719    #[serde(default)]
1720    message: Option<OpenAiMessage>,
1721}
1722
1723#[derive(Deserialize)]
1724struct OpenAiUsage {
1725    #[serde(default)]
1726    prompt_tokens: Option<u64>,
1727    #[serde(default)]
1728    completion_tokens: Option<u64>,
1729}
1730
1731#[derive(Deserialize)]
1732struct OpenAiResponse {
1733    #[serde(default)]
1734    choices: Vec<OpenAiChoice>,
1735    #[serde(default)]
1736    usage: Option<OpenAiUsage>,
1737    #[serde(default)]
1738    error: Option<ApiErrorBody>,
1739}
1740
1741/// Map a vendor error `type` onto skilltest's classified provider-error kinds so
1742/// the CLI can give the same pointed hints it gives for harness failures.
1743fn classify_api_error(kind: Option<&str>) -> Option<String> {
1744    match kind? {
1745        "authentication_error" | "invalid_api_key" | "permission_error" => Some("auth".to_string()),
1746        "rate_limit_error" | "rate_limit_exceeded" => Some("rate_limit".to_string()),
1747        "insufficient_quota" | "billing_error" => Some("quota".to_string()),
1748        "not_found_error" => Some("model_not_found".to_string()),
1749        // Transient server-side conditions — surfaced as `overloaded` so the
1750        // runner retries them (see `is_retryable`).
1751        "overloaded_error" | "api_error" | "server_error" | "service_unavailable" => {
1752            Some("overloaded".to_string())
1753        }
1754        _ => None,
1755    }
1756}
1757
1758fn api_error(err: ApiErrorBody) -> Error {
1759    let message = err
1760        .message
1761        .unwrap_or_else(|| "API returned an error".to_string());
1762    match classify_api_error(err.kind.as_deref()) {
1763        Some(kind) => Error::provider_classified("api-judge", message, kind),
1764        None => Error::provider("api-judge", message),
1765    }
1766}
1767
1768/// Take the first chars of `raw` for an error message, on a UTF-8 boundary.
1769fn truncate_for_error(raw: &str) -> String {
1770    raw.chars().take(500).collect()
1771}
1772
1773/// Parse a vendor chat response into the reply text plus normalized usage.
1774fn parse_chat_response(vendor: ApiVendor, raw: &str) -> Result<ChatOutcome> {
1775    match vendor {
1776        ApiVendor::Anthropic => {
1777            let resp: AnthropicResponse = serde_json::from_str(raw.trim()).map_err(|e| {
1778                Error::provider(
1779                    "api-judge",
1780                    format!(
1781                        "could not parse API response: {e}; got: {}",
1782                        truncate_for_error(raw)
1783                    ),
1784                )
1785            })?;
1786            if let Some(err) = resp.error {
1787                return Err(api_error(err));
1788            }
1789            let text = resp
1790                .content
1791                .iter()
1792                .filter(|b| b.kind == "text")
1793                .filter_map(|b| b.text.as_deref())
1794                .collect::<String>();
1795            if text.trim().is_empty() {
1796                return Err(Error::provider(
1797                    "api-judge",
1798                    format!(
1799                        "judge returned no text (stop_reason: {:?})",
1800                        resp.stop_reason
1801                    ),
1802                ));
1803            }
1804            let usage = resp.usage.map(|u| Usage {
1805                input_tokens: u.input_tokens,
1806                output_tokens: u.output_tokens,
1807                cost_usd: None,
1808            });
1809            Ok(ChatOutcome { text, usage })
1810        }
1811        ApiVendor::Openai => {
1812            let resp: OpenAiResponse = serde_json::from_str(raw.trim()).map_err(|e| {
1813                Error::provider(
1814                    "api-judge",
1815                    format!(
1816                        "could not parse API response: {e}; got: {}",
1817                        truncate_for_error(raw)
1818                    ),
1819                )
1820            })?;
1821            if let Some(err) = resp.error {
1822                return Err(api_error(err));
1823            }
1824            let text = resp
1825                .choices
1826                .into_iter()
1827                .next()
1828                .and_then(|c| c.message)
1829                .and_then(|m| m.content)
1830                .unwrap_or_default();
1831            if text.trim().is_empty() {
1832                return Err(Error::provider("api-judge", "judge returned no text"));
1833            }
1834            let usage = resp.usage.map(|u| Usage {
1835                input_tokens: u.prompt_tokens,
1836                output_tokens: u.completion_tokens,
1837                cost_usd: None,
1838            });
1839            Ok(ChatOutcome { text, usage })
1840        }
1841    }
1842}
1843
1844/// Render the conversation as `Role: content` lines for inlining in a prompt.
1845/// Used by the judge, the simulated user, and the no-resume fallback path of
1846/// `respond`.
1847fn render_transcript(messages: &[Message]) -> String {
1848    messages
1849        .iter()
1850        .map(|m| {
1851            let role = match m.role {
1852                Role::User => "User",
1853                Role::Assistant => "Assistant",
1854                Role::System => "System",
1855            };
1856            format!("{role}: {}", m.content)
1857        })
1858        .collect::<Vec<_>>()
1859        .join("\n")
1860}
1861
1862/// The prompt for `respond` when we cannot resume a harness session: inline the
1863/// whole conversation so the stateless harness call sees it. The skill is
1864/// passed separately as `--system`, so it does *not* appear here.
1865fn render_transcript_for_respond(messages: &[Message]) -> String {
1866    format!(
1867        "Conversation so far (most recent last):\n{}\n\n\
1868         Write only the assistant's next reply, following your system \
1869         instructions. Output the reply text and nothing else.",
1870        render_transcript(messages),
1871    )
1872}
1873
1874/// The most recent user message in the transcript — used as the next-turn
1875/// prompt when resuming a real harness session.
1876fn latest_user_message(messages: &[Message]) -> Option<String> {
1877    messages
1878        .iter()
1879        .rev()
1880        .find(|m| m.role == Role::User)
1881        .map(|m| m.content.clone())
1882}
1883
1884fn build_user_prompt(persona: &str, messages: &[Message]) -> String {
1885    format!(
1886        "You are role-playing the USER in a conversation with an AI assistant. \
1887         Stay in character:\n\n{persona}\n\n\
1888         Conversation so far (most recent last):\n{transcript}\n\n\
1889         Write only the user's next message. Output the message text and nothing \
1890         else.",
1891        transcript = render_transcript(messages),
1892    )
1893}
1894
1895fn build_judge_prompt(query: &JudgeQuery<'_>, messages: &[Message]) -> String {
1896    let transcript = render_transcript(messages);
1897    match query.kind {
1898        JudgeKind::Boolean => format!(
1899            "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
1900             Criterion: {criterion}\n\n\
1901             Transcript:\n{transcript}\n\n\
1902             Decide whether the criterion is satisfied. Respond with ONLY a \
1903             single-line JSON object and nothing else:\n\
1904             {{\"value\": true or false, \"reason\": \"<one short sentence>\"}}",
1905            criterion = query.criterion,
1906        ),
1907        JudgeKind::Numeric => {
1908            let (min, max) = query.scale.unwrap_or((0.0, 10.0));
1909            format!(
1910                "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
1911                 Criterion: {criterion}\n\n\
1912                 Transcript:\n{transcript}\n\n\
1913                 Score how well the criterion is satisfied on a scale from {min} to \
1914                 {max} (inclusive). Respond with ONLY a single-line JSON object and \
1915                 nothing else:\n\
1916                 {{\"value\": <number between {min} and {max}>, \"reason\": \"<one short sentence>\"}}",
1917                criterion = query.criterion,
1918            )
1919        }
1920    }
1921}
1922
1923/// Extract the first JSON object from `text`, tolerating code fences and prose
1924/// around it (real models do not always emit bare JSON).
1925fn extract_json_object(text: &str) -> Option<&str> {
1926    let start = text.find('{')?;
1927    let end = text.rfind('}')?;
1928    if end > start {
1929        Some(&text[start..=end])
1930    } else {
1931        None
1932    }
1933}
1934
1935fn parse_verdict(kind: JudgeKind, text: &str) -> Result<JudgeVerdict> {
1936    let json = extract_json_object(text).ok_or_else(|| {
1937        Error::provider(
1938            "oneharness:judge",
1939            format!("judge did not return a JSON object; got: {text}"),
1940        )
1941    })?;
1942    let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
1943        Error::provider(
1944            "oneharness:judge",
1945            format!("judge verdict was not valid JSON: {e}; got: {json}"),
1946        )
1947    })?;
1948    let reason = value
1949        .get("reason")
1950        .and_then(serde_json::Value::as_str)
1951        .unwrap_or("")
1952        .to_string();
1953    let raw = value
1954        .get("value")
1955        .ok_or_else(|| Error::provider("oneharness:judge", "judge verdict has no `value` field"))?;
1956
1957    let verdict_value = match kind {
1958        JudgeKind::Boolean => JudgeValue::Bool(raw.as_bool().ok_or_else(|| {
1959            Error::provider(
1960                "oneharness:judge",
1961                format!("boolean judge `value` was not a bool: {raw}"),
1962            )
1963        })?),
1964        JudgeKind::Numeric => JudgeValue::Number(raw.as_f64().ok_or_else(|| {
1965            Error::provider(
1966                "oneharness:judge",
1967                format!("numeric judge `value` was not a number: {raw}"),
1968            )
1969        })?),
1970    };
1971
1972    Ok(JudgeVerdict {
1973        value: verdict_value,
1974        reason,
1975        usage: None,
1976    })
1977}
1978
1979#[cfg(test)]
1980mod tests {
1981    use super::*;
1982
1983    #[test]
1984    fn empty_argv_is_rejected() {
1985        assert!(CommandProvider::new(vec![]).is_err());
1986    }
1987
1988    #[test]
1989    fn request_serializes_with_op_tag() {
1990        let req = Request::Judge {
1991            model: "m",
1992            kind: "numeric",
1993            criterion: "polite",
1994            min: Some(0.0),
1995            max: Some(10.0),
1996            messages: &[],
1997        };
1998        let json = serde_json::to_string(&req).unwrap();
1999        assert!(json.contains("\"op\":\"judge\""));
2000        assert!(json.contains("\"kind\":\"numeric\""));
2001    }
2002
2003    #[test]
2004    fn respond_no_session_inlines_transcript_but_not_skill() {
2005        // The skill is passed via --system now, so the prompt the harness sees
2006        // for respond carries only the transcript.
2007        let messages = [
2008            Message::user("Hi"),
2009            Message::assistant("Hello"),
2010            Message::user("Again?"),
2011        ];
2012        let prompt = render_transcript_for_respond(&messages);
2013        assert!(prompt.contains("User: Hi"));
2014        assert!(prompt.contains("Assistant: Hello"));
2015        assert!(prompt.contains("User: Again?"));
2016        // The skill body must not leak here — it belongs in --system.
2017        assert!(!prompt.contains("SKILL"));
2018    }
2019
2020    #[test]
2021    fn respond_with_session_sends_only_latest_user_message() {
2022        let messages = [
2023            Message::user("Hi"),
2024            Message::assistant("Hello"),
2025            Message::user("Again?"),
2026        ];
2027        assert_eq!(latest_user_message(&messages).as_deref(), Some("Again?"));
2028    }
2029
2030    #[test]
2031    fn extracts_json_from_fenced_or_prose_text() {
2032        assert_eq!(
2033            extract_json_object("```json\n{\"value\": true}\n```"),
2034            Some("{\"value\": true}")
2035        );
2036        assert_eq!(
2037            extract_json_object("Sure! {\"value\": 8, \"reason\": \"x\"} done"),
2038            Some("{\"value\": 8, \"reason\": \"x\"}")
2039        );
2040        assert_eq!(extract_json_object("no json here"), None);
2041    }
2042
2043    #[test]
2044    fn parses_boolean_and_numeric_verdicts() {
2045        let b = parse_verdict(JudgeKind::Boolean, "{\"value\": true, \"reason\": \"ok\"}").unwrap();
2046        assert!(matches!(b.value, JudgeValue::Bool(true)));
2047        assert_eq!(b.reason, "ok");
2048
2049        let n =
2050            parse_verdict(JudgeKind::Numeric, "{\"value\": 8.5, \"reason\": \"good\"}").unwrap();
2051        assert!(matches!(n.value, JudgeValue::Number(v) if (v - 8.5).abs() < f64::EPSILON));
2052    }
2053
2054    #[test]
2055    fn verdict_with_wrong_value_type_errors() {
2056        assert!(parse_verdict(JudgeKind::Boolean, "{\"value\": 3}").is_err());
2057        assert!(parse_verdict(JudgeKind::Numeric, "{\"value\": true}").is_err());
2058        assert!(parse_verdict(JudgeKind::Boolean, "no json").is_err());
2059    }
2060
2061    #[test]
2062    fn usage_accumulates_independently_per_field() {
2063        let mut total = Usage::default();
2064        total.add(&Usage {
2065            input_tokens: Some(10),
2066            output_tokens: None,
2067            cost_usd: Some(0.01),
2068        });
2069        total.add(&Usage {
2070            input_tokens: Some(5),
2071            output_tokens: Some(3),
2072            cost_usd: None,
2073        });
2074        assert_eq!(total.input_tokens, Some(15));
2075        assert_eq!(total.output_tokens, Some(3));
2076        assert!((total.cost_usd.unwrap() - 0.01).abs() < f64::EPSILON);
2077        assert!(!total.is_empty());
2078    }
2079
2080    #[test]
2081    fn reply_text_prefers_extracted_then_falls_back_to_stdout() {
2082        // Extracted text wins when present.
2083        assert_eq!(
2084            select_reply_text(Some("clean reply".into()), "raw noise"),
2085            Some("clean reply".into())
2086        );
2087        // Null/blank extracted text falls back to raw stdout (the contract's
2088        // escape hatch when oneharness can't extract but the reply is in stdout).
2089        assert_eq!(
2090            select_reply_text(None, "{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}"),
2091            Some("{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}".into())
2092        );
2093        assert_eq!(
2094            select_reply_text(Some("   ".into()), "fallback"),
2095            Some("fallback".into())
2096        );
2097        // Neither present is the only real error.
2098        assert_eq!(select_reply_text(None, "   \n"), None);
2099        assert_eq!(select_reply_text(Some(String::new()), ""), None);
2100    }
2101
2102    #[test]
2103    fn supports_resume_covers_known_harnesses() {
2104        assert!(supports_resume("claude-code"));
2105        assert!(supports_resume("opencode"));
2106        assert!(supports_resume("cursor"));
2107        assert!(!supports_resume("codex"));
2108        assert!(!supports_resume("goose"));
2109    }
2110
2111    fn api_config(vendor: ApiVendor) -> ApiJudgeConfig {
2112        ApiJudgeConfig {
2113            vendor,
2114            api_key_env: None,
2115            base_url: None,
2116            timeout_secs: 60,
2117            curl_bin: "curl".to_string(),
2118            strict_json: true,
2119        }
2120    }
2121
2122    #[test]
2123    fn api_judge_resolves_vendor_defaults() {
2124        let anthropic = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
2125        assert_eq!(anthropic.api_key_env, "ANTHROPIC_API_KEY");
2126        assert_eq!(anthropic.endpoint, "https://api.anthropic.com/v1/messages");
2127
2128        let openai = ApiJudgeProvider::new(&api_config(ApiVendor::Openai));
2129        assert_eq!(openai.api_key_env, "OPENAI_API_KEY");
2130        assert_eq!(
2131            openai.endpoint,
2132            "https://api.openai.com/v1/chat/completions"
2133        );
2134    }
2135
2136    #[test]
2137    fn api_judge_honors_overrides() {
2138        let provider = ApiJudgeProvider::new(&ApiJudgeConfig {
2139            vendor: ApiVendor::Openai,
2140            api_key_env: Some("MY_KEY".to_string()),
2141            base_url: Some("https://proxy.example/v1/chat/completions".to_string()),
2142            timeout_secs: 5,
2143            curl_bin: "curl".to_string(),
2144            strict_json: true,
2145        });
2146        assert_eq!(provider.api_key_env, "MY_KEY");
2147        assert_eq!(
2148            provider.endpoint,
2149            "https://proxy.example/v1/chat/completions"
2150        );
2151    }
2152
2153    #[test]
2154    fn build_chat_body_shapes_per_vendor() {
2155        let anthropic = build_chat_body(ApiVendor::Anthropic, "claude-x", "sys", "hi", None);
2156        assert_eq!(anthropic["model"], "claude-x");
2157        assert_eq!(anthropic["system"], "sys");
2158        assert_eq!(anthropic["messages"][0]["role"], "user");
2159        // Anthropic carries the system prompt in its own top-level field.
2160        assert_eq!(anthropic["messages"].as_array().unwrap().len(), 1);
2161        // No schema requested → no structured-outputs field.
2162        assert!(anthropic.get("output_config").is_none());
2163
2164        let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", None);
2165        assert_eq!(openai["messages"][0]["role"], "system");
2166        assert_eq!(openai["messages"][1]["role"], "user");
2167        assert!(openai.get("system").is_none());
2168        assert!(openai.get("response_format").is_none());
2169    }
2170
2171    #[test]
2172    fn build_chat_body_attaches_strict_schema_per_vendor() {
2173        let schema = verdict_schema(JudgeKind::Boolean);
2174        let anthropic = build_chat_body(
2175            ApiVendor::Anthropic,
2176            "claude-x",
2177            "sys",
2178            "hi",
2179            Some(schema.clone()),
2180        );
2181        // Anthropic uses output_config.format.
2182        assert_eq!(anthropic["output_config"]["format"]["type"], "json_schema");
2183        assert_eq!(
2184            anthropic["output_config"]["format"]["schema"]["properties"]["value"]["type"],
2185            "boolean"
2186        );
2187
2188        let numeric = verdict_schema(JudgeKind::Numeric);
2189        let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", Some(numeric));
2190        // OpenAI uses response_format.json_schema with strict: true.
2191        assert_eq!(openai["response_format"]["type"], "json_schema");
2192        assert_eq!(openai["response_format"]["json_schema"]["strict"], true);
2193        assert_eq!(
2194            openai["response_format"]["json_schema"]["schema"]["properties"]["value"]["type"],
2195            "number"
2196        );
2197    }
2198
2199    #[test]
2200    fn verdict_schema_requires_value_and_reason_with_no_extras() {
2201        let schema = verdict_schema(JudgeKind::Numeric);
2202        assert_eq!(schema["additionalProperties"], false);
2203        let required: Vec<&str> = schema["required"]
2204            .as_array()
2205            .unwrap()
2206            .iter()
2207            .map(|v| v.as_str().unwrap())
2208            .collect();
2209        assert_eq!(required, ["value", "reason"]);
2210    }
2211
2212    #[test]
2213    fn parses_anthropic_success_with_usage() {
2214        let raw = r#"{"content":[{"type":"text","text":"{\"value\": true}"}],
2215            "stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":3}}"#;
2216        let outcome = parse_chat_response(ApiVendor::Anthropic, raw).unwrap();
2217        assert_eq!(outcome.text, "{\"value\": true}");
2218        let usage = outcome.usage.unwrap();
2219        assert_eq!(usage.input_tokens, Some(12));
2220        assert_eq!(usage.output_tokens, Some(3));
2221        assert!(usage.cost_usd.is_none());
2222    }
2223
2224    #[test]
2225    fn parses_openai_success_with_usage() {
2226        let raw = r#"{"choices":[{"message":{"content":"{\"value\": 8}"}}],
2227            "usage":{"prompt_tokens":20,"completion_tokens":4}}"#;
2228        let outcome = parse_chat_response(ApiVendor::Openai, raw).unwrap();
2229        assert_eq!(outcome.text, "{\"value\": 8}");
2230        let usage = outcome.usage.unwrap();
2231        assert_eq!(usage.input_tokens, Some(20));
2232        assert_eq!(usage.output_tokens, Some(4));
2233    }
2234
2235    #[test]
2236    fn parses_and_classifies_api_errors() {
2237        let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
2238        let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
2239        assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
2240
2241        let rate = r#"{"error":{"type":"rate_limit_exceeded","message":"slow down"}}"#;
2242        let err = parse_chat_response(ApiVendor::Openai, rate).unwrap_err();
2243        assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "rate_limit"));
2244    }
2245
2246    #[test]
2247    fn empty_reply_is_an_error() {
2248        let raw = r#"{"content":[],"stop_reason":"refusal"}"#;
2249        assert!(parse_chat_response(ApiVendor::Anthropic, raw).is_err());
2250    }
2251
2252    #[test]
2253    fn classify_api_error_maps_known_kinds() {
2254        assert_eq!(
2255            classify_api_error(Some("invalid_api_key")).as_deref(),
2256            Some("auth")
2257        );
2258        assert_eq!(
2259            classify_api_error(Some("insufficient_quota")).as_deref(),
2260            Some("quota")
2261        );
2262        assert_eq!(
2263            classify_api_error(Some("not_found_error")).as_deref(),
2264            Some("model_not_found")
2265        );
2266        assert_eq!(
2267            classify_api_error(Some("overloaded_error")).as_deref(),
2268            Some("overloaded")
2269        );
2270        assert_eq!(classify_api_error(Some("something_else")), None);
2271        assert_eq!(classify_api_error(None), None);
2272    }
2273
2274    #[test]
2275    fn retryable_covers_transient_errors_only() {
2276        let overloaded = r#"{"error":{"type":"overloaded_error","message":"busy"}}"#;
2277        let err = parse_chat_response(ApiVendor::Anthropic, overloaded).unwrap_err();
2278        assert!(is_retryable(&err), "overload should retry");
2279
2280        let rate = r#"{"error":{"type":"rate_limit_error","message":"slow"}}"#;
2281        let err = parse_chat_response(ApiVendor::Anthropic, rate).unwrap_err();
2282        assert!(is_retryable(&err), "rate limit should retry");
2283
2284        let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
2285        let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
2286        assert!(!is_retryable(&err), "auth must not retry");
2287    }
2288
2289    #[test]
2290    fn curl_escape_handles_quotes_and_backslashes() {
2291        assert_eq!(curl_escape(r#"a"b\c"#), r#"a\"b\\c"#);
2292    }
2293
2294    /// A skill-running provider stub so the SplitProvider's delegation can be
2295    /// checked without touching the network.
2296    struct StubResponder;
2297
2298    impl Provider for StubResponder {
2299        fn respond(
2300            &self,
2301            _platform: &str,
2302            _model: &str,
2303            _skill: &SkillRef<'_>,
2304            _messages: &[Message],
2305            _session: Option<&str>,
2306        ) -> Result<AssistantTurn> {
2307            Ok(AssistantTurn {
2308                message: "stub reply".to_string(),
2309                ..Default::default()
2310            })
2311        }
2312
2313        fn simulate_user(
2314            &self,
2315            _model: &str,
2316            _persona: &str,
2317            _messages: &[Message],
2318        ) -> Result<UserTurn> {
2319            unreachable!("split provider routes user simulation to the judge")
2320        }
2321
2322        fn judge(
2323            &self,
2324            _model: &str,
2325            _query: &JudgeQuery<'_>,
2326            _messages: &[Message],
2327        ) -> Result<JudgeVerdict> {
2328            unreachable!("split provider routes judging to the judge")
2329        }
2330
2331        fn supports_resume(&self, platform: &str) -> bool {
2332            platform == "claude-code"
2333        }
2334    }
2335
2336    #[test]
2337    fn split_provider_delegates_respond_and_resume() {
2338        let split = SplitProvider::new(
2339            Box::new(StubResponder),
2340            ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic)),
2341        );
2342        // respond + supports_resume go to the responder...
2343        assert!(split.supports_resume("claude-code"));
2344        assert!(!split.supports_resume("codex"));
2345        let skill = SkillRef {
2346            name: "s",
2347            dir: "/tmp/s",
2348            instructions: "do things",
2349        };
2350        let turn = split
2351            .respond("claude-code", "m", &skill, &[], None)
2352            .unwrap();
2353        assert_eq!(turn.message, "stub reply");
2354    }
2355
2356    #[test]
2357    fn api_judge_does_not_run_skills() {
2358        let provider = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
2359        let skill = SkillRef {
2360            name: "s",
2361            dir: "/tmp/s",
2362            instructions: "x",
2363        };
2364        assert!(provider.respond("p", "m", &skill, &[], None).is_err());
2365    }
2366
2367    // -----------------------------------------------------------------------
2368    // Subprocess-driven coverage: these spawn small shell scripts standing in
2369    // for the provider command / oneharness / curl, so the actual process
2370    // plumbing (`CommandProvider::call`, `OneharnessProvider::run`,
2371    // `ApiJudgeProvider::run_curl`/`exec_curl`/`write_curl_config`) is exercised
2372    // end to end without any network. Unix-only; the whole crate ships to a
2373    // Linux/macOS matrix (see AGENTS.md "Stack and composition").
2374
2375    #[cfg(unix)]
2376    mod subprocess {
2377        // `std::io::Write` is already in scope via `super::*` (the module-level
2378        // `use std::io::Write as _`), so `write_all` resolves without a re-import.
2379        use super::*;
2380        use std::os::unix::fs::PermissionsExt as _;
2381        use std::path::PathBuf;
2382
2383        /// Write an executable shell script into a unique temp dir and return its
2384        /// path. Each call gets its own directory so concurrent tests never race.
2385        fn script(tag: &str, body: &str) -> PathBuf {
2386            use std::sync::atomic::{AtomicU64, Ordering};
2387            static N: AtomicU64 = AtomicU64::new(0);
2388            let dir = std::env::temp_dir().join(format!(
2389                "skilltest-prov-{}-{tag}-{}",
2390                std::process::id(),
2391                N.fetch_add(1, Ordering::Relaxed)
2392            ));
2393            std::fs::create_dir_all(&dir).unwrap();
2394            let path = dir.join("script.sh");
2395            let mut f = std::fs::File::create(&path).unwrap();
2396            f.write_all(format!("#!/bin/sh\n{body}").as_bytes())
2397                .unwrap();
2398            let mut perms = std::fs::metadata(&path).unwrap().permissions();
2399            perms.set_mode(0o755);
2400            std::fs::set_permissions(&path, perms).unwrap();
2401            path
2402        }
2403
2404        fn skill_ref() -> SkillRef<'static> {
2405            SkillRef {
2406                name: "greeter",
2407                dir: "/tmp/greeter",
2408                instructions: "Be nice.",
2409            }
2410        }
2411
2412        // ---- CommandProvider over a real subprocess ----
2413
2414        #[test]
2415        fn command_provider_respond_parses_response() {
2416            // Echo a fixed respond payload; ignore stdin.
2417            let bin = script(
2418                "respond",
2419                "cat >/dev/null\necho '{\"message\":\"hi there\",\"done\":true,\
2420                 \"usage\":{\"input_tokens\":4,\"output_tokens\":2},\"session_id\":\"s1\"}'\n",
2421            );
2422            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2423            let turn = provider
2424                .respond("demo", "fake", &skill_ref(), &[Message::user("hi")], None)
2425                .unwrap();
2426            assert_eq!(turn.message, "hi there");
2427            assert!(turn.done);
2428            assert_eq!(turn.session_id.as_deref(), Some("s1"));
2429            assert_eq!(turn.usage.unwrap().input_tokens, Some(4));
2430        }
2431
2432        #[test]
2433        fn command_provider_user_and_judge_parse_responses() {
2434            let user_bin = script(
2435                "user",
2436                "cat >/dev/null\necho '{\"message\":\"more please\",\"stop\":true}'\n",
2437            );
2438            let user_provider =
2439                CommandProvider::new(vec![user_bin.to_string_lossy().into_owned()]).unwrap();
2440            let user = user_provider.simulate_user("m", "persona", &[]).unwrap();
2441            assert_eq!(user.message, "more please");
2442            assert!(user.stop);
2443
2444            let judge_bin = script(
2445                "judge",
2446                "cat >/dev/null\necho '{\"value\":7.5,\"reason\":\"ok\"}'\n",
2447            );
2448            let judge_provider =
2449                CommandProvider::new(vec![judge_bin.to_string_lossy().into_owned()]).unwrap();
2450            let query = JudgeQuery {
2451                kind: JudgeKind::Numeric,
2452                criterion: "polite",
2453                scale: Some((0.0, 10.0)),
2454            };
2455            let verdict = judge_provider.judge("m", &query, &[]).unwrap();
2456            assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 7.5).abs() < 1e-9));
2457            assert_eq!(verdict.reason, "ok");
2458        }
2459
2460        #[test]
2461        fn command_provider_surfaces_nonzero_exit() {
2462            let bin = script("fail", "cat >/dev/null\necho 'boom' 1>&2\nexit 2\n");
2463            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2464            let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2465            let msg = err.to_string();
2466            assert!(msg.contains("provider exited"), "got: {msg}");
2467            assert!(msg.contains("boom"), "stderr is surfaced: {msg}");
2468        }
2469
2470        #[test]
2471        fn command_provider_rejects_empty_and_bad_output() {
2472            let empty = script("empty", "cat >/dev/null\n");
2473            let provider =
2474                CommandProvider::new(vec![empty.to_string_lossy().into_owned()]).unwrap();
2475            assert!(provider
2476                .judge(
2477                    "m",
2478                    &JudgeQuery {
2479                        kind: JudgeKind::Boolean,
2480                        criterion: "x",
2481                        scale: None
2482                    },
2483                    &[],
2484                )
2485                .unwrap_err()
2486                .to_string()
2487                .contains("no output"));
2488
2489            let garbage = script("garbage", "cat >/dev/null\necho 'not json'\n");
2490            let provider =
2491                CommandProvider::new(vec![garbage.to_string_lossy().into_owned()]).unwrap();
2492            assert!(provider
2493                .respond("demo", "m", &skill_ref(), &[], None)
2494                .unwrap_err()
2495                .to_string()
2496                .contains("not valid JSON"));
2497        }
2498
2499        #[test]
2500        fn command_provider_reports_missing_binary() {
2501            let provider =
2502                CommandProvider::new(vec!["/no/such/skilltest-provider-binary".to_string()])
2503                    .unwrap();
2504            let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2505            assert!(err.to_string().contains("could not run provider"));
2506        }
2507
2508        #[test]
2509        fn command_provider_session_is_threaded_into_request() {
2510            // The script writes the request it received to a sidecar file so the
2511            // test can assert the `session` field made it onto the wire.
2512            let dir =
2513                std::env::temp_dir().join(format!("skilltest-prov-sess-{}", std::process::id()));
2514            std::fs::create_dir_all(&dir).unwrap();
2515            let seen = dir.join("seen.json");
2516            let bin = script(
2517                "session",
2518                &format!(
2519                    "cat > '{}'\necho '{{\"message\":\"ok\"}}'\n",
2520                    seen.display()
2521                ),
2522            );
2523            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2524            provider
2525                .respond(
2526                    "demo",
2527                    "m",
2528                    &skill_ref(),
2529                    &[Message::user("hi")],
2530                    Some("session-xyz"),
2531                )
2532                .unwrap();
2533            let request = std::fs::read_to_string(&seen).unwrap();
2534            assert!(
2535                request.contains("\"session\":\"session-xyz\""),
2536                "got: {request}"
2537            );
2538            assert!(request.contains("\"op\":\"respond\""));
2539        }
2540
2541        #[test]
2542        fn command_provider_threads_mocks_and_parses_records() {
2543            // The script records the request and answers with mock_calls.
2544            let dir =
2545                std::env::temp_dir().join(format!("skilltest-prov-mocks-{}", std::process::id()));
2546            std::fs::create_dir_all(&dir).unwrap();
2547            let seen = dir.join("seen.json");
2548            let bin = script(
2549                "mocks",
2550                &format!(
2551                    "cat > '{}'\necho '{{\"message\":\"ok\",\"mock_calls\":[{{\"tool\":\"bash\",                     \"input\":{{\"command\":\"git push\"}},\"action\":\"stub\",\"rule\":0}}]}}'\n",
2552                    seen.display()
2553                ),
2554            );
2555            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2556            let rules = serde_json::json!({ "rules": [] });
2557            let plan = MockPlan {
2558                rules: Some(&rules),
2559            };
2560            let turn = provider
2561                .respond_with_mocks("demo", "m", &skill_ref(), &[], None, Some(&plan))
2562                .unwrap();
2563            let records = turn.mock_calls.expect("channel was on");
2564            assert_eq!(records.len(), 1);
2565            assert_eq!(records[0].action, "stub");
2566            assert_eq!(records[0].rule, Some(0));
2567            // The request carried the mocks block with the compiled rules.
2568            let request = std::fs::read_to_string(&seen).unwrap();
2569            assert!(
2570                request.contains("\"mocks\":{\"rules\":{\"rules\":[]}}"),
2571                "got: {request}"
2572            );
2573        }
2574
2575        #[test]
2576        fn command_provider_ignoring_mocks_is_loud() {
2577            // A provider that answers without `mock_calls` despite a plan has
2578            // silently ignored the mocks — that must never pass vacuously.
2579            let bin = script(
2580                "mocks-ignored",
2581                "cat >/dev/null\necho '{\"message\":\"ok\"}'\n",
2582            );
2583            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2584            let plan = MockPlan { rules: None };
2585            let err = provider
2586                .respond_with_mocks("demo", "m", &skill_ref(), &[], None, Some(&plan))
2587                .unwrap_err();
2588            assert!(
2589                err.to_string().contains("ignored the request's `mocks`"),
2590                "{err}"
2591            );
2592        }
2593
2594        #[test]
2595        fn default_provider_rejects_mocks_loudly() {
2596            // A Provider impl without mock support (the trait default) must
2597            // refuse a plan, never silently drop it.
2598            let plan = MockPlan { rules: None };
2599            let err = super::StubResponder
2600                .respond_with_mocks("p", "m", &skill_ref(), &[], None, Some(&plan))
2601                .unwrap_err();
2602            assert!(
2603                err.to_string().contains("does not support tool mocking"),
2604                "{err}"
2605            );
2606            // And with no plan it delegates to the plain respond.
2607            let turn = super::StubResponder
2608                .respond_with_mocks("p", "m", &skill_ref(), &[], None, None)
2609                .unwrap();
2610            assert_eq!(turn.message, "stub reply");
2611        }
2612
2613        #[test]
2614        fn default_streaming_rejects_mocks_and_delegates_without() {
2615            // The streaming default mirrors the buffered one: loud on a plan,
2616            // plain replay otherwise.
2617            let plan = MockPlan { rules: None };
2618            let err = super::StubResponder
2619                .respond_streaming_with_mocks(
2620                    "p",
2621                    "m",
2622                    &skill_ref(),
2623                    &[],
2624                    None,
2625                    Some(&plan),
2626                    &mut |_| ControlFlow::Continue(()),
2627                )
2628                .unwrap_err();
2629            assert!(err.to_string().contains("does not support tool mocking"));
2630            let turn = super::StubResponder
2631                .respond_streaming_with_mocks("p", "m", &skill_ref(), &[], None, None, &mut |_| {
2632                    ControlFlow::Continue(())
2633                })
2634                .unwrap();
2635            assert_eq!(turn.message, "stub reply");
2636        }
2637
2638        #[test]
2639        fn command_provider_streaming_with_mocks_replays_events() {
2640            // The command protocol is buffered; its streaming path replays the
2641            // finished turn's events and still carries the records.
2642            let bin = script(
2643                "mocks-stream",
2644                "cat >/dev/null\necho '{\"message\":\"ok\",\"events\":[{\"kind\":\"tool_call\",\"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":0}],\"mock_calls\":[]}'\n",
2645            );
2646            let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2647            let plan = MockPlan { rules: None };
2648            let mut seen = 0usize;
2649            let turn = provider
2650                .respond_streaming_with_mocks(
2651                    "demo",
2652                    "m",
2653                    &skill_ref(),
2654                    &[],
2655                    None,
2656                    Some(&plan),
2657                    &mut |event| {
2658                        seen += 1;
2659                        assert_eq!(event.name.as_deref(), Some("bash"));
2660                        ControlFlow::Break(())
2661                    },
2662                )
2663                .unwrap();
2664            assert_eq!(seen, 1);
2665            assert_eq!(turn.mock_calls, Some(Vec::new()));
2666        }
2667
2668        // ---- OneharnessProvider over a fake oneharness ----
2669
2670        fn oh_provider(bin: PathBuf) -> OneharnessProvider {
2671            OneharnessProvider::new(&OneharnessConfig {
2672                bin: bin.to_string_lossy().into_owned(),
2673                judge_harness: "claude-code".to_string(),
2674                timeout_secs: 30,
2675            })
2676        }
2677
2678        #[test]
2679        fn oneharness_respond_extracts_text_and_session() {
2680            let bin = script(
2681                "oh-ok",
2682                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2683                 \"text\":\"  hello back  \",\"session_id\":\"oh1\",\
2684                 \"usage\":{\"input_tokens\":5}}]}'\n",
2685            );
2686            let turn = oh_provider(bin)
2687                .respond(
2688                    "claude-code",
2689                    "sonnet",
2690                    &skill_ref(),
2691                    &[Message::user("hi")],
2692                    None,
2693                )
2694                .unwrap();
2695            assert_eq!(turn.message, "hello back");
2696            assert_eq!(turn.session_id.as_deref(), Some("oh1"));
2697            assert_eq!(turn.usage.unwrap().input_tokens, Some(5));
2698            assert!(turn.events.is_empty());
2699        }
2700
2701        #[test]
2702        fn oneharness_respond_surfaces_normalized_events() {
2703            // oneharness `--events` populates a per-result `events` array; the
2704            // provider lifts it onto the assistant turn so consumers can analyze
2705            // what the skill did.
2706            let bin = script(
2707                "oh-events",
2708                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2709                 \"text\":\"done\",\"events\":[{\"kind\":\"tool_call\",\"name\":\"bash\",\
2710                 \"input\":{\"command\":\"git commit -m x\"},\"output\":\"ok\",\"index\":0}]}]}'\n",
2711            );
2712            let turn = oh_provider(bin)
2713                .respond(
2714                    "claude-code",
2715                    "sonnet",
2716                    &skill_ref(),
2717                    &[Message::user("hi")],
2718                    None,
2719                )
2720                .unwrap();
2721            assert_eq!(turn.events.len(), 1);
2722            assert_eq!(turn.events[0].kind, "tool_call");
2723            assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
2724            assert_eq!(
2725                turn.events[0].input,
2726                Some(serde_json::json!({"command": "git commit -m x"}))
2727            );
2728            assert_eq!(turn.events[0].output.as_deref(), Some("ok"));
2729        }
2730
2731        #[test]
2732        fn oneharness_respond_events_absent_is_empty_not_error() {
2733            // A harness that exposes no tool transcript yields no `events`; the
2734            // turn simply carries an empty list (never an error).
2735            let bin = script(
2736                "oh-noevents",
2737                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
2738            );
2739            let turn = oh_provider(bin)
2740                .respond("goose", "m", &skill_ref(), &[Message::user("hi")], None)
2741                .unwrap();
2742            assert!(turn.events.is_empty());
2743        }
2744
2745        #[test]
2746        fn oneharness_stream_forwards_events_then_parses_the_result() {
2747            // `oneharness run --stream` emits one NDJSON `{"type":"event",…}` line
2748            // per tool event, then a terminal `{"type":"result","report":{…}}`.
2749            // `respond_streaming` forwards each event live and returns the turn
2750            // parsed from the result.
2751            let bin = script(
2752                "oh-stream",
2753                "cat >/dev/null\n\
2754                 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2755                 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":0}}'\n\
2756                 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2757                 \"text\":\"  done  \",\"session_id\":\"s1\",\"usage\":{\"input_tokens\":7}}]}}'\n",
2758            );
2759            let mut seen = Vec::new();
2760            let turn = oh_provider(bin)
2761                .respond_streaming(
2762                    "claude-code",
2763                    "sonnet",
2764                    &skill_ref(),
2765                    &[Message::user("hi")],
2766                    None,
2767                    &mut |event| {
2768                        seen.push(event.name.clone());
2769                        ControlFlow::Continue(())
2770                    },
2771                )
2772                .unwrap();
2773            // The event was forwarded live, and the result was parsed for the turn.
2774            assert_eq!(seen, vec![Some("bash".to_string())]);
2775            assert_eq!(turn.message, "done");
2776            assert_eq!(turn.session_id.as_deref(), Some("s1"));
2777            assert_eq!(turn.usage.unwrap().input_tokens, Some(7));
2778            assert_eq!(turn.events.len(), 1);
2779            assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
2780        }
2781
2782        #[test]
2783        fn oneharness_stream_short_circuits_on_break() {
2784            // The sink breaks on the first event; the oneharness child is killed
2785            // and the later events/result are never delivered. The turn carries
2786            // only the events seen before the abort.
2787            let bin = script(
2788                "oh-stream-abort",
2789                "cat >/dev/null\n\
2790                 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2791                 \"name\":\"rm\",\"input\":{\"command\":\"rm -rf /\"},\"index\":0}}'\n\
2792                 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2793                 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":1}}'\n\
2794                 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2795                 \"text\":\"done\"}]}}'\n",
2796            );
2797            let mut seen = 0usize;
2798            let turn = oh_provider(bin)
2799                .respond_streaming(
2800                    "claude-code",
2801                    "sonnet",
2802                    &skill_ref(),
2803                    &[Message::user("hi")],
2804                    None,
2805                    &mut |event| {
2806                        seen += 1;
2807                        assert_eq!(event.name.as_deref(), Some("rm"));
2808                        ControlFlow::Break(())
2809                    },
2810                )
2811                .unwrap();
2812            assert_eq!(seen, 1, "aborted after the first event");
2813            assert_eq!(turn.events.len(), 1);
2814            assert_eq!(turn.events[0].name.as_deref(), Some("rm"));
2815            // Torn off before the result line, so no reply text.
2816            assert!(turn.message.is_empty());
2817        }
2818
2819        #[test]
2820        fn oneharness_stream_errors_when_no_result_line() {
2821            // A stream that ends without a terminal `result` line is a protocol
2822            // error (distinct from a deliberate abort).
2823            let bin = script(
2824                "oh-stream-noresult",
2825                "cat >/dev/null\n\
2826                 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2827                 \"name\":\"bash\",\"input\":{},\"index\":0}}'\n",
2828            );
2829            let err = oh_provider(bin)
2830                .respond_streaming(
2831                    "claude-code",
2832                    "sonnet",
2833                    &skill_ref(),
2834                    &[Message::user("hi")],
2835                    None,
2836                    &mut |_| ControlFlow::Continue(()),
2837                )
2838                .unwrap_err();
2839            assert!(
2840                matches!(err, Error::Provider { .. }),
2841                "expected a provider error, got: {err:?}"
2842            );
2843        }
2844
2845        #[test]
2846        fn oneharness_buffered_run_passes_events_and_omits_mode() {
2847            // The buffered path uses `--compact --events` and — deliberately —
2848            // passes no `--mode` (oneharness's default applies).
2849            let bin = script(
2850                "oh-args",
2851                "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
2852                 cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
2853            );
2854            let dir = bin.parent().unwrap().to_path_buf();
2855            oh_provider(bin)
2856                .respond(
2857                    "claude-code",
2858                    "sonnet",
2859                    &skill_ref(),
2860                    &[Message::user("hi")],
2861                    None,
2862                )
2863                .unwrap();
2864            let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
2865                .unwrap()
2866                .lines()
2867                .map(str::to_string)
2868                .collect();
2869            assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
2870            assert!(args.iter().any(|a| a == "--compact"), "got: {args:?}");
2871            assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
2872            assert!(!args.iter().any(|a| a == "--stream"), "got: {args:?}");
2873        }
2874
2875        #[test]
2876        fn oneharness_stream_run_passes_stream_and_omits_mode() {
2877            // The streaming path uses `--stream --events` and — like the buffered
2878            // path — passes no `--mode`.
2879            let bin = script(
2880                "oh-args-stream",
2881                "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
2882                 cat >/dev/null\n\
2883                 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2884                 \"text\":\"hi\"}]}}'\n",
2885            );
2886            let dir = bin.parent().unwrap().to_path_buf();
2887            oh_provider(bin)
2888                .respond_streaming(
2889                    "claude-code",
2890                    "sonnet",
2891                    &skill_ref(),
2892                    &[Message::user("hi")],
2893                    None,
2894                    &mut |_| ControlFlow::Continue(()),
2895                )
2896                .unwrap();
2897            let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
2898                .unwrap()
2899                .lines()
2900                .map(str::to_string)
2901                .collect();
2902            assert!(args.iter().any(|a| a == "--stream"), "got: {args:?}");
2903            assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
2904            assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
2905            assert!(!args.iter().any(|a| a == "--compact"), "got: {args:?}");
2906        }
2907
2908        #[test]
2909        fn oneharness_falls_back_to_stdout_when_text_null() {
2910            let bin = script(
2911                "oh-fallback",
2912                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2913                 \"stdout\":\"raw reply\"}]}'\n",
2914            );
2915            let user = oh_provider(bin).simulate_user("m", "persona", &[]).unwrap();
2916            assert_eq!(user.message, "raw reply");
2917        }
2918
2919        #[test]
2920        fn oneharness_judge_parses_verdict() {
2921            let bin = script(
2922                "oh-judge",
2923                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2924                 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"good\\\"}\"}]}'\n",
2925            );
2926            let query = JudgeQuery {
2927                kind: JudgeKind::Boolean,
2928                criterion: "polite",
2929                scale: None,
2930            };
2931            let verdict = oh_provider(bin).judge("m", &query, &[]).unwrap();
2932            assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2933            assert_eq!(verdict.reason, "good");
2934        }
2935
2936        #[test]
2937        fn oneharness_classifies_failure_kind() {
2938            let bin = script(
2939                "oh-auth",
2940                "cat >/dev/null\necho '{\"results\":[{\"status\":\"error\",\
2941                 \"failure_kind\":\"auth\",\"error\":\"no creds\"}]}'\n",
2942            );
2943            let err = oh_provider(bin)
2944                .respond("claude-code", "m", &skill_ref(), &[], None)
2945                .unwrap_err();
2946            assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
2947        }
2948
2949        #[test]
2950        fn oneharness_reports_status_without_failure_kind() {
2951            let bin = script(
2952                "oh-err",
2953                "cat >/dev/null\necho '{\"results\":[{\"status\":\"timeout\",\
2954                 \"stderr\":\"deadline\"}]}'\n",
2955            );
2956            let err = oh_provider(bin).simulate_user("m", "p", &[]).unwrap_err();
2957            let msg = err.to_string();
2958            assert!(msg.contains("harness run failed"), "got: {msg}");
2959            assert!(msg.contains("deadline"));
2960        }
2961
2962        #[test]
2963        fn oneharness_errors_on_unparseable_output_and_no_results() {
2964            let garbage = script(
2965                "oh-garbage",
2966                "cat >/dev/null\necho 'not json' 1>&2\necho 'x'\n",
2967            );
2968            assert!(oh_provider(garbage)
2969                .simulate_user("m", "p", &[])
2970                .unwrap_err()
2971                .to_string()
2972                .contains("could not parse oneharness output"));
2973
2974            let empty = script("oh-empty", "cat >/dev/null\necho '{\"results\":[]}'\n");
2975            assert!(oh_provider(empty)
2976                .simulate_user("m", "p", &[])
2977                .unwrap_err()
2978                .to_string()
2979                .contains("no results"));
2980        }
2981
2982        #[test]
2983        fn oneharness_errors_when_no_text_or_stdout() {
2984            let bin = script(
2985                "oh-silent",
2986                "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\"}]}'\n",
2987            );
2988            assert!(oh_provider(bin)
2989                .respond("claude-code", "m", &skill_ref(), &[], None)
2990                .unwrap_err()
2991                .to_string()
2992                .contains("neither extractable text nor stdout"));
2993        }
2994
2995        #[test]
2996        fn oneharness_respond_resume_sends_only_latest_message() {
2997            // Capture the prompt (stdin) and the argv to assert resume behavior:
2998            // with a session, only the last user message is sent and --resume is
2999            // forwarded; without, the whole transcript is inlined.
3000            let dir =
3001                std::env::temp_dir().join(format!("skilltest-oh-resume-{}", std::process::id()));
3002            std::fs::create_dir_all(&dir).unwrap();
3003            let prompt_file = dir.join("prompt.txt");
3004            let argv_file = dir.join("argv.txt");
3005            let bin = script(
3006                "oh-resume",
3007                &format!(
3008                    "echo \"$@\" > '{}'\ncat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3009                    argv_file.display(),
3010                    prompt_file.display(),
3011                ),
3012            );
3013            let messages = [
3014                Message::user("first"),
3015                Message::assistant("reply"),
3016                Message::user("second"),
3017            ];
3018            oh_provider(bin.clone())
3019                .respond(
3020                    "claude-code",
3021                    "sonnet",
3022                    &skill_ref(),
3023                    &messages,
3024                    Some("sess-1"),
3025                )
3026                .unwrap();
3027            let prompt = std::fs::read_to_string(&prompt_file).unwrap();
3028            assert_eq!(
3029                prompt.trim(),
3030                "second",
3031                "resume sends only the latest user message"
3032            );
3033            let argv = std::fs::read_to_string(&argv_file).unwrap();
3034            assert!(argv.contains("--resume sess-1"), "argv: {argv}");
3035            assert!(
3036                argv.contains("--system"),
3037                "skill is the system prompt: {argv}"
3038            );
3039        }
3040
3041        #[test]
3042        fn oneharness_omits_model_flag_when_model_empty() {
3043            let dir =
3044                std::env::temp_dir().join(format!("skilltest-oh-model-{}", std::process::id()));
3045            std::fs::create_dir_all(&dir).unwrap();
3046            let argv_file = dir.join("argv.txt");
3047            let bin = script(
3048                "oh-nomodel",
3049                &format!(
3050                    "echo \"$@\" > '{}'\ncat >/dev/null\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3051                    argv_file.display(),
3052                ),
3053            );
3054            oh_provider(bin)
3055                .respond("cursor", "", &skill_ref(), &[Message::user("hi")], None)
3056                .unwrap();
3057            let argv = std::fs::read_to_string(&argv_file).unwrap();
3058            assert!(
3059                !argv.contains("--model"),
3060                "empty model omits the flag: {argv}"
3061            );
3062        }
3063
3064        #[test]
3065        fn oneharness_reports_missing_binary() {
3066            let provider = oh_provider(PathBuf::from("/no/such/oneharness-binary"));
3067            let err = provider
3068                .respond("claude-code", "m", &skill_ref(), &[], None)
3069                .unwrap_err();
3070            assert!(err.to_string().contains("could not run"));
3071        }
3072
3073        #[test]
3074        fn oneharness_respond_with_mocks_passes_flags_and_reads_spy_log() {
3075            // The fake oneharness extracts --mock-rules/--spy-file from its
3076            // argv, copies the rules it was handed to a sidecar, and appends
3077            // spy lines the way `oneharness mock` would.
3078            let dir =
3079                std::env::temp_dir().join(format!("skilltest-oh-mocks-{}", std::process::id()));
3080            std::fs::create_dir_all(&dir).unwrap();
3081            let rules_seen = dir.join("rules-seen.json");
3082            let bin = script(
3083                "oh-mocks",
3084                &format!(
3085                    r#"rules=""; spy=""
3086while [ $# -gt 0 ]; do
3087  [ "$1" = "--mock-rules" ] && rules="$2"
3088  [ "$1" = "--spy-file" ] && spy="$2"
3089  shift
3090done
3091cat >/dev/null
3092cp "$rules" '{seen}'
3093printf '%s
3094' '{{"harness":"claude-code","event":{{"tool_name":"Bash","tool_input":{{"command":"git push"}}}},"action":"stub","rule":0}}' >> "$spy"
3095printf '%s
3096' '{{"harness":"claude-code","event":{{"tool_name":"Bash","tool_input":{{"command":"ls"}}}},"action":"allow","rule":null}}' >> "$spy"
3097echo '{{"results":[{{"status":"ok","text":"done"}}]}}'
3098"#,
3099                    seen = rules_seen.display(),
3100                ),
3101            );
3102            let rules = serde_json::json!({ "rules": [
3103                { "match": { "event_contains": "git push" },
3104                  "action": { "stub": { "output": "up-to-date", "exit_code": 0 } } }
3105            ]});
3106            let plan = MockPlan {
3107                rules: Some(&rules),
3108            };
3109            let turn = oh_provider(bin)
3110                .respond_with_mocks(
3111                    "claude-code",
3112                    "sonnet",
3113                    &skill_ref(),
3114                    &[Message::user("hi")],
3115                    None,
3116                    Some(&plan),
3117                )
3118                .unwrap();
3119            // The compiled rules reached oneharness verbatim.
3120            let seen: serde_json::Value =
3121                serde_json::from_str(&std::fs::read_to_string(&rules_seen).unwrap()).unwrap();
3122            assert_eq!(seen, rules);
3123            // The spy log came back as records, original inputs intact.
3124            let records = turn.mock_calls.expect("channel was on");
3125            assert_eq!(records.len(), 2);
3126            assert_eq!(records[0].action, "stub");
3127            assert_eq!(records[0].rule, Some(0));
3128            assert_eq!(records[0].input.as_ref().unwrap()["command"], "git push");
3129            assert_eq!(records[1].action, "allow");
3130        }
3131
3132        #[test]
3133        fn oneharness_spy_only_plan_omits_rules_flag_and_missing_log_is_empty() {
3134            // A spy-only plan (no rules): no --mock-rules flag, --spy-file
3135            // still passed; a run whose hook never fired leaves no log, which
3136            // reads as zero records — the channel stays Some.
3137            let dir =
3138                std::env::temp_dir().join(format!("skilltest-oh-spyonly-{}", std::process::id()));
3139            std::fs::create_dir_all(&dir).unwrap();
3140            let argv_file = dir.join("argv.txt");
3141            let bin = script(
3142                "oh-spyonly",
3143                &format!(
3144                    "echo \"$@\" > '{}'\ncat >/dev/null\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3145                    argv_file.display(),
3146                ),
3147            );
3148            let plan = MockPlan { rules: None };
3149            let turn = oh_provider(bin)
3150                .respond_with_mocks(
3151                    "claude-code",
3152                    "sonnet",
3153                    &skill_ref(),
3154                    &[Message::user("hi")],
3155                    None,
3156                    Some(&plan),
3157                )
3158                .unwrap();
3159            assert_eq!(turn.mock_calls, Some(Vec::new()));
3160            let argv = std::fs::read_to_string(&argv_file).unwrap();
3161            assert!(argv.contains("--spy-file"), "argv: {argv}");
3162            assert!(!argv.contains("--mock-rules"), "argv: {argv}");
3163        }
3164
3165        // ---- ApiJudgeProvider over a fake curl ----
3166
3167        fn api_provider_with_curl(curl: PathBuf, vendor: ApiVendor) -> ApiJudgeProvider {
3168            ApiJudgeProvider::new(&ApiJudgeConfig {
3169                vendor,
3170                api_key_env: Some("SKILLTEST_TEST_API_KEY".to_string()),
3171                base_url: Some("https://example.invalid/v1".to_string()),
3172                timeout_secs: 5,
3173                curl_bin: curl.to_string_lossy().into_owned(),
3174                strict_json: true,
3175            })
3176        }
3177
3178        #[test]
3179        fn api_judge_judges_through_fake_curl() {
3180            // The fake curl echoes an Anthropic-shaped success body.
3181            let curl = script(
3182                "curl-ok",
3183                "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
3184                 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"polite\\\"}\"}],\
3185                 \"usage\":{\"input_tokens\":9,\"output_tokens\":3}}'\n",
3186            );
3187            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3188            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3189            let query = JudgeQuery {
3190                kind: JudgeKind::Boolean,
3191                criterion: "polite",
3192                scale: None,
3193            };
3194            let verdict = provider
3195                .judge("claude-x", &query, &[Message::user("hi")])
3196                .unwrap();
3197            assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3198            assert_eq!(verdict.usage.unwrap().input_tokens, Some(9));
3199            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3200        }
3201
3202        #[test]
3203        fn api_judge_simulates_user_through_fake_curl() {
3204            let curl = script(
3205                "curl-user",
3206                "cat >/dev/null\necho '{\"choices\":[{\"message\":\
3207                 {\"content\":\"sure, go on\"}}]}'\n",
3208            );
3209            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3210            let provider = api_provider_with_curl(curl, ApiVendor::Openai);
3211            let user = provider.simulate_user("gpt-x", "a patient", &[]).unwrap();
3212            assert_eq!(user.message, "sure, go on");
3213            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3214        }
3215
3216        #[test]
3217        fn api_judge_errors_when_key_absent() {
3218            let curl = script("curl-unused", "cat >/dev/null\necho '{}'\n");
3219            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3220            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3221            let err = provider
3222                .judge(
3223                    "m",
3224                    &JudgeQuery {
3225                        kind: JudgeKind::Boolean,
3226                        criterion: "x",
3227                        scale: None,
3228                    },
3229                    &[],
3230                )
3231                .unwrap_err();
3232            assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
3233        }
3234
3235        #[test]
3236        fn api_judge_surfaces_curl_failure() {
3237            let curl = script(
3238                "curl-fail",
3239                "cat >/dev/null\necho 'curl: (6) bad host' 1>&2\nexit 6\n",
3240            );
3241            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3242            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3243            let err = provider
3244                .judge(
3245                    "m",
3246                    &JudgeQuery {
3247                        kind: JudgeKind::Boolean,
3248                        criterion: "x",
3249                        scale: None,
3250                    },
3251                    &[],
3252                )
3253                .unwrap_err();
3254            assert!(err.to_string().contains("curl failed"));
3255            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3256        }
3257
3258        #[test]
3259        fn write_curl_config_sets_private_mode_and_headers() {
3260            let dir = std::env::temp_dir().join(format!("skilltest-cfg-{}", std::process::id()));
3261            std::fs::create_dir_all(&dir).unwrap();
3262            let path = dir.join("c.cfg");
3263            write_curl_config(
3264                &path,
3265                "https://api.example/v1",
3266                &[("x-api-key".to_string(), "secret\"quote".to_string())],
3267                42,
3268            )
3269            .unwrap();
3270            let text = std::fs::read_to_string(&path).unwrap();
3271            assert!(text.contains("url = \"https://api.example/v1\""));
3272            assert!(text.contains("max-time = 42"));
3273            // The quote in the header value is escaped.
3274            assert!(text.contains("secret\\\"quote"), "escaped header: {text}");
3275            let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3276            assert_eq!(mode & 0o777, 0o600, "config is private");
3277        }
3278
3279        #[test]
3280        fn api_judge_retries_a_transient_error_then_succeeds() {
3281            // The fake curl returns an overloaded error on its first invocation
3282            // and a success on the second, so the retry path in `chat` is taken.
3283            let dir = std::env::temp_dir().join(format!("skilltest-retry-{}", std::process::id()));
3284            std::fs::create_dir_all(&dir).unwrap();
3285            let counter = dir.join("n");
3286            let curl = script(
3287                "curl-retry",
3288                &format!(
3289                    "cat >/dev/null\nif [ -f '{c}' ]; then \
3290                       echo '{{\"content\":[{{\"type\":\"text\",\"text\":\"{{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}}\"}}]}}'; \
3291                     else touch '{c}'; \
3292                       echo '{{\"error\":{{\"type\":\"overloaded_error\",\"message\":\"busy\"}}}}'; \
3293                     fi\n",
3294                    c = counter.display(),
3295                ),
3296            );
3297            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3298            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3299            let verdict = provider
3300                .judge(
3301                    "m",
3302                    &JudgeQuery {
3303                        kind: JudgeKind::Boolean,
3304                        criterion: "x",
3305                        scale: None,
3306                    },
3307                    &[],
3308                )
3309                .unwrap();
3310            assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3311            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3312        }
3313
3314        #[test]
3315        fn api_judge_gives_up_after_max_retries() {
3316            // Always overloaded: the loop exhausts MAX_RETRIES and surfaces it.
3317            let curl = script(
3318                "curl-busy",
3319                "cat >/dev/null\necho '{\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}'\n",
3320            );
3321            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3322            let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3323            let err = provider
3324                .judge(
3325                    "m",
3326                    &JudgeQuery {
3327                        kind: JudgeKind::Boolean,
3328                        criterion: "x",
3329                        scale: None,
3330                    },
3331                    &[],
3332                )
3333                .unwrap_err();
3334            assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "overloaded"));
3335            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3336        }
3337
3338        #[test]
3339        fn api_judge_reports_missing_curl_binary() {
3340            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3341            let provider =
3342                api_provider_with_curl(PathBuf::from("/no/such/curl-binary"), ApiVendor::Anthropic);
3343            let err = provider
3344                .judge(
3345                    "m",
3346                    &JudgeQuery {
3347                        kind: JudgeKind::Boolean,
3348                        criterion: "x",
3349                        scale: None,
3350                    },
3351                    &[],
3352                )
3353                .unwrap_err();
3354            assert!(err.to_string().contains("could not run"));
3355            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3356        }
3357
3358        #[test]
3359        fn api_judge_surfaces_unparseable_response() {
3360            let curl = script("curl-garbage", "cat >/dev/null\necho 'not json at all'\n");
3361            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3362            let provider = api_provider_with_curl(curl, ApiVendor::Openai);
3363            let err = provider
3364                .judge(
3365                    "m",
3366                    &JudgeQuery {
3367                        kind: JudgeKind::Boolean,
3368                        criterion: "x",
3369                        scale: None,
3370                    },
3371                    &[],
3372                )
3373                .unwrap_err();
3374            assert!(err.to_string().contains("could not parse API response"));
3375            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3376        }
3377
3378        #[test]
3379        fn split_provider_routes_judge_and_user_through_the_api() {
3380            // A SplitProvider's judge/simulate_user must hit the API judge (the
3381            // fake curl), while respond goes to the stub responder.
3382            let curl = script(
3383                "split-curl",
3384                "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
3385                 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}\"}]}'\n",
3386            );
3387            std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3388            let judge = api_provider_with_curl(curl, ApiVendor::Anthropic);
3389            let split = SplitProvider::new(Box::new(super::StubResponder), judge);
3390            let verdict = split
3391                .judge(
3392                    "m",
3393                    &JudgeQuery {
3394                        kind: JudgeKind::Boolean,
3395                        criterion: "polite",
3396                        scale: None,
3397                    },
3398                    &[],
3399                )
3400                .unwrap();
3401            assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3402            std::env::remove_var("SKILLTEST_TEST_API_KEY");
3403        }
3404
3405        #[test]
3406        fn oneharness_numeric_judge_uses_numeric_prompt() {
3407            // A numeric judge exercises the numeric branch of build_judge_prompt
3408            // (the scale text) and the numeric verdict parse path.
3409            let dir = std::env::temp_dir().join(format!("skilltest-ohnum-{}", std::process::id()));
3410            std::fs::create_dir_all(&dir).unwrap();
3411            let prompt_file = dir.join("prompt.txt");
3412            let bin = script(
3413                "oh-numeric",
3414                &format!(
3415                    "cat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"{{\\\"value\\\": 8.5, \\\"reason\\\": \\\"warm\\\"}}\"}}]}}'\n",
3416                    prompt_file.display(),
3417                ),
3418            );
3419            let query = JudgeQuery {
3420                kind: JudgeKind::Numeric,
3421                criterion: "warmth",
3422                scale: Some((0.0, 10.0)),
3423            };
3424            let verdict = oh_provider(bin)
3425                .judge("m", &query, &[Message::assistant("hi")])
3426                .unwrap();
3427            assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 8.5).abs() < 1e-9));
3428            let prompt = std::fs::read_to_string(&prompt_file).unwrap();
3429            assert!(
3430                prompt.contains("scale from 0 to 10"),
3431                "numeric prompt: {prompt}"
3432            );
3433        }
3434
3435        #[test]
3436        fn supports_resume_method_matches_free_function() {
3437            let provider = oh_provider(PathBuf::from("/bin/true"));
3438            assert!(provider.supports_resume("claude-code"));
3439            assert!(!provider.supports_resume("codex"));
3440        }
3441    }
3442
3443    // Non-subprocess error-path coverage for the verdict parser and the
3444    // classified-error fallback.
3445
3446    #[test]
3447    fn parse_verdict_rejects_missing_object_and_value() {
3448        // No JSON object at all.
3449        assert!(parse_verdict(JudgeKind::Boolean, "just prose, no braces").is_err());
3450        // A JSON object with no `value` field.
3451        assert!(parse_verdict(JudgeKind::Boolean, "{\"reason\": \"x\"}").is_err());
3452        // Malformed JSON inside the braces.
3453        assert!(parse_verdict(JudgeKind::Numeric, "{not: valid}").is_err());
3454    }
3455
3456    #[test]
3457    fn extract_json_object_handles_reversed_braces() {
3458        // A stray `}` before `{` is not a valid object span.
3459        assert_eq!(extract_json_object("} then {"), None);
3460    }
3461
3462    #[test]
3463    fn unclassified_api_error_falls_back_to_plain_provider_error() {
3464        // An error type we don't classify becomes an unclassified provider error.
3465        let raw = r#"{"error":{"type":"some_new_error","message":"odd"}}"#;
3466        let err = parse_chat_response(ApiVendor::Openai, raw).unwrap_err();
3467        assert!(matches!(err, Error::Provider { kind: None, .. }));
3468        assert!(err.to_string().contains("odd"));
3469    }
3470
3471    #[test]
3472    fn openai_empty_choice_text_is_an_error() {
3473        let raw = r#"{"choices":[]}"#;
3474        assert!(parse_chat_response(ApiVendor::Openai, raw).is_err());
3475    }
3476
3477    #[test]
3478    fn truncate_for_error_caps_length_on_a_char_boundary() {
3479        let long = "x".repeat(1000);
3480        assert_eq!(truncate_for_error(&long).chars().count(), 500);
3481    }
3482}