1use 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
25pub struct SkillRef<'a> {
27 pub name: &'a str,
28 pub dir: &'a str,
29 pub instructions: &'a str,
30}
31
32#[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
48pub struct JudgeQuery<'a> {
50 pub kind: JudgeKind,
51 pub criterion: &'a str,
52 pub scale: Option<(f64, f64)>,
53}
54
55#[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 #[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 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#[derive(Debug, Clone, Default)]
95pub struct AssistantTurn {
96 pub message: String,
97 pub done: bool,
99 pub usage: Option<Usage>,
101 pub session_id: Option<String>,
105 pub events: Vec<ToolEvent>,
110 pub mock_calls: Option<Vec<MockCall>>,
116}
117
118#[derive(Debug, Clone, Default)]
120pub struct UserTurn {
121 pub message: String,
122 pub stop: bool,
124 pub usage: Option<Usage>,
125}
126
127#[derive(Debug, Clone)]
129pub struct JudgeVerdict {
130 pub value: JudgeValue,
131 pub reason: String,
132 pub usage: Option<Usage>,
133}
134
135pub trait Provider {
137 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 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 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 #[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 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn>;
249
250 fn judge(
255 &self,
256 model: &str,
257 query: &JudgeQuery<'_>,
258 messages: &[Message],
259 ) -> Result<JudgeVerdict>;
260
261 fn supports_resume(&self, _platform: &str) -> bool {
266 false
267 }
268}
269
270#[derive(Serialize)]
275struct SkillPayload<'a> {
276 name: &'a str,
277 path: &'a str,
278 instructions: &'a str,
279}
280
281#[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 #[serde(default)]
331 events: Option<Vec<ToolEvent>>,
332 #[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
356pub struct CommandProvider {
362 argv: Vec<String>,
363}
364
365impl CommandProvider {
366 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 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 {
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 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 #[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 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
579pub struct OneharnessProvider {
617 bin: String,
618 judge_harness: String,
619 timeout_secs: u64,
620}
621
622#[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 #[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 #[serde(default)]
652 events: Option<Vec<ToolEvent>>,
653 #[serde(default)]
654 failure_kind: Option<String>,
655}
656
657struct RunArgs<'a> {
659 harness: &'a str,
660 model: &'a str,
661 prompt: &'a str,
662 system: Option<&'a str>,
665 resume: Option<&'a str>,
668 mocks: Option<&'a MockPlan<'a>>,
672}
673
674impl<'a> RunArgs<'a> {
675 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
688struct RunOutcome {
690 text: String,
691 session_id: Option<String>,
692 usage: Option<Usage>,
693 events: Vec<ToolEvent>,
694 mock_calls: Option<Vec<MockCall>>,
697}
698
699struct MockFiles {
703 dir: std::path::PathBuf,
704 rules: Option<std::path::PathBuf>,
705 spy: std::path::PathBuf,
706}
707
708impl MockFiles {
709 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 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
757fn 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 #[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 fn run(&self, args: &RunArgs<'_>) -> Result<RunOutcome> {
784 let timeout = self.timeout_secs.to_string();
785 let mut cmd = Command::new(&self.bin);
786 cmd.args([
805 "run",
806 "--harness",
807 args.harness,
808 "--compact",
809 "--events",
810 "--timeout",
811 &timeout,
812 "--prompt-file",
813 "-",
814 ]);
815 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 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 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 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 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 {
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 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 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 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 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 #[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#[must_use]
1241pub fn supports_resume(harness: &str) -> bool {
1242 matches!(harness, "claude-code" | "opencode" | "cursor")
1243}
1244
1245pub 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
1275const MAX_RETRIES: u32 = 2;
1278
1279#[derive(Debug)]
1281struct ChatOutcome {
1282 text: String,
1283 usage: Option<Usage>,
1284}
1285
1286const JUDGE_SYSTEM: &str =
1289 "Follow the user's instructions exactly and respond with only what they ask for.";
1290
1291impl ApiJudgeProvider {
1292 #[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 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 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 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 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 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 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
1473pub struct SplitProvider {
1479 responder: Box<dyn Provider>,
1480 judge: ApiJudgeProvider,
1481}
1482
1483impl SplitProvider {
1484 #[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 #[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
1553fn 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
1561fn curl_escape(value: &str) -> String {
1563 value.replace('\\', "\\\\").replace('"', "\\\"")
1564}
1565
1566fn 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
1599fn 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
1619fn 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
1664fn 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#[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
1741fn 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 "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
1768fn truncate_for_error(raw: &str) -> String {
1770 raw.chars().take(500).collect()
1771}
1772
1773fn 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
1844fn 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
1862fn 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
1874fn 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
1923fn 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 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 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 assert_eq!(
2084 select_reply_text(Some("clean reply".into()), "raw noise"),
2085 Some("clean reply".into())
2086 );
2087 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 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 assert_eq!(anthropic["messages"].as_array().unwrap().len(), 1);
2161 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 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 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 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 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 #[cfg(unix)]
2376 mod subprocess {
2377 use super::*;
2380 use std::os::unix::fs::PermissionsExt as _;
2381 use std::path::PathBuf;
2382
2383 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 #[test]
2415 fn command_provider_respond_parses_response() {
2416 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(turn.message.is_empty());
2817 }
2818
2819 #[test]
2820 fn oneharness_stream_errors_when_no_result_line() {
2821 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
3447 fn parse_verdict_rejects_missing_object_and_value() {
3448 assert!(parse_verdict(JudgeKind::Boolean, "just prose, no braces").is_err());
3450 assert!(parse_verdict(JudgeKind::Boolean, "{\"reason\": \"x\"}").is_err());
3452 assert!(parse_verdict(JudgeKind::Numeric, "{not: valid}").is_err());
3454 }
3455
3456 #[test]
3457 fn extract_json_object_handles_reversed_braces() {
3458 assert_eq!(extract_json_object("} then {"), None);
3460 }
3461
3462 #[test]
3463 fn unclassified_api_error_falls_back_to_plain_provider_error() {
3464 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}