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;
23
24pub struct SkillRef<'a> {
26 pub name: &'a str,
27 pub dir: &'a str,
28 pub instructions: &'a str,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum JudgeKind {
34 Boolean,
35 Numeric,
36}
37
38impl JudgeKind {
39 fn as_str(self) -> &'static str {
40 match self {
41 JudgeKind::Boolean => "boolean",
42 JudgeKind::Numeric => "numeric",
43 }
44 }
45}
46
47pub struct JudgeQuery<'a> {
49 pub kind: JudgeKind,
50 pub criterion: &'a str,
51 pub scale: Option<(f64, f64)>,
52}
53
54#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
61pub struct Usage {
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub input_tokens: Option<u64>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub output_tokens: Option<u64>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub cost_usd: Option<f64>,
68}
69
70impl Usage {
71 #[must_use]
73 pub fn is_empty(&self) -> bool {
74 self.input_tokens.is_none() && self.output_tokens.is_none() && self.cost_usd.is_none()
75 }
76
77 pub fn add(&mut self, other: &Usage) {
80 if let Some(v) = other.input_tokens {
81 self.input_tokens = Some(self.input_tokens.unwrap_or(0) + v);
82 }
83 if let Some(v) = other.output_tokens {
84 self.output_tokens = Some(self.output_tokens.unwrap_or(0) + v);
85 }
86 if let Some(v) = other.cost_usd {
87 self.cost_usd = Some(self.cost_usd.unwrap_or(0.0) + v);
88 }
89 }
90}
91
92#[derive(Debug, Clone, Default)]
94pub struct AssistantTurn {
95 pub message: String,
96 pub done: bool,
98 pub usage: Option<Usage>,
100 pub session_id: Option<String>,
104 pub events: Vec<ToolEvent>,
109}
110
111#[derive(Debug, Clone, Default)]
113pub struct UserTurn {
114 pub message: String,
115 pub stop: bool,
117 pub usage: Option<Usage>,
118}
119
120#[derive(Debug, Clone)]
122pub struct JudgeVerdict {
123 pub value: JudgeValue,
124 pub reason: String,
125 pub usage: Option<Usage>,
126}
127
128pub trait Provider {
130 fn respond(
139 &self,
140 platform: &str,
141 model: &str,
142 skill: &SkillRef<'_>,
143 messages: &[Message],
144 session: Option<&str>,
145 ) -> Result<AssistantTurn>;
146
147 fn respond_streaming(
160 &self,
161 platform: &str,
162 model: &str,
163 skill: &SkillRef<'_>,
164 messages: &[Message],
165 session: Option<&str>,
166 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
167 ) -> Result<AssistantTurn> {
168 let turn = self.respond(platform, model, skill, messages, session)?;
169 for event in &turn.events {
170 if on_event(event).is_break() {
171 break;
172 }
173 }
174 Ok(turn)
175 }
176
177 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn>;
182
183 fn judge(
188 &self,
189 model: &str,
190 query: &JudgeQuery<'_>,
191 messages: &[Message],
192 ) -> Result<JudgeVerdict>;
193
194 fn supports_resume(&self, _platform: &str) -> bool {
199 false
200 }
201}
202
203#[derive(Serialize)]
208struct SkillPayload<'a> {
209 name: &'a str,
210 path: &'a str,
211 instructions: &'a str,
212}
213
214#[derive(Serialize)]
215#[serde(tag = "op", rename_all = "lowercase")]
216enum Request<'a> {
217 Respond {
218 platform: &'a str,
219 model: &'a str,
220 skill: SkillPayload<'a>,
221 messages: &'a [Message],
222 #[serde(skip_serializing_if = "Option::is_none")]
223 session: Option<&'a str>,
224 },
225 User {
226 model: &'a str,
227 persona: &'a str,
228 messages: &'a [Message],
229 },
230 Judge {
231 model: &'a str,
232 kind: &'a str,
233 criterion: &'a str,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 min: Option<f64>,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 max: Option<f64>,
238 messages: &'a [Message],
239 },
240}
241
242#[derive(Deserialize)]
243struct RespondPayload {
244 message: String,
245 #[serde(default)]
246 done: bool,
247 #[serde(default)]
248 usage: Option<Usage>,
249 #[serde(default)]
250 session_id: Option<String>,
251 #[serde(default)]
254 events: Option<Vec<ToolEvent>>,
255}
256
257#[derive(Deserialize)]
258struct UserPayload {
259 message: String,
260 #[serde(default)]
261 stop: bool,
262 #[serde(default)]
263 usage: Option<Usage>,
264}
265
266#[derive(Deserialize)]
267struct JudgePayload {
268 value: JudgeValue,
269 #[serde(default)]
270 reason: String,
271 #[serde(default)]
272 usage: Option<Usage>,
273}
274
275pub struct CommandProvider {
281 argv: Vec<String>,
282}
283
284impl CommandProvider {
285 pub fn new(argv: Vec<String>) -> Result<Self> {
291 if argv.is_empty() {
292 return Err(Error::Invalid("provider command is empty".into()));
293 }
294 Ok(Self { argv })
295 }
296
297 fn call<T: for<'de> Deserialize<'de>>(&self, request: &Request<'_>, op: &str) -> Result<T> {
299 let payload = serde_json::to_vec(request).map_err(|e| {
300 Error::provider(op.to_string(), format!("could not encode request: {e}"))
301 })?;
302
303 let mut child = Command::new(&self.argv[0])
304 .args(&self.argv[1..])
305 .stdin(Stdio::piped())
306 .stdout(Stdio::piped())
307 .stderr(Stdio::piped())
308 .spawn()
309 .map_err(|e| {
310 Error::provider(
311 op.to_string(),
312 format!(
313 "could not run provider `{}`: {e}. Is it installed and on PATH?",
314 self.argv[0]
315 ),
316 )
317 })?;
318
319 {
322 let stdin = child
323 .stdin
324 .as_mut()
325 .ok_or_else(|| Error::provider(op.to_string(), "could not open provider stdin"))?;
326 stdin
327 .write_all(&payload)
328 .and_then(|()| stdin.write_all(b"\n"))
329 .map_err(|e| {
330 Error::provider(op.to_string(), format!("could not write request: {e}"))
331 })?;
332 }
333
334 let output = child.wait_with_output().map_err(|e| {
335 Error::provider(op.to_string(), format!("provider did not complete: {e}"))
336 })?;
337
338 if !output.status.success() {
339 let stderr = String::from_utf8_lossy(&output.stderr);
340 return Err(Error::provider(
341 op.to_string(),
342 format!("provider exited with {}: {}", output.status, stderr.trim()),
343 ));
344 }
345
346 let stdout = String::from_utf8_lossy(&output.stdout);
347 let line = stdout.trim();
348 if line.is_empty() {
349 return Err(Error::provider(
350 op.to_string(),
351 "provider produced no output (expected one JSON response object)",
352 ));
353 }
354 serde_json::from_str(line).map_err(|e| {
355 Error::provider(
356 op.to_string(),
357 format!("provider response was not valid JSON for `{op}`: {e}; got: {line}"),
358 )
359 })
360 }
361}
362
363impl Provider for CommandProvider {
364 fn respond(
365 &self,
366 platform: &str,
367 model: &str,
368 skill: &SkillRef<'_>,
369 messages: &[Message],
370 session: Option<&str>,
371 ) -> Result<AssistantTurn> {
372 let request = Request::Respond {
373 platform,
374 model,
375 skill: SkillPayload {
376 name: skill.name,
377 path: skill.dir,
378 instructions: skill.instructions,
379 },
380 messages,
381 session,
382 };
383 let payload: RespondPayload = self.call(&request, "respond")?;
384 Ok(AssistantTurn {
385 message: payload.message,
386 done: payload.done,
387 usage: payload.usage,
388 session_id: payload.session_id,
389 events: payload.events.unwrap_or_default(),
390 })
391 }
392
393 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
394 let request = Request::User {
395 model,
396 persona,
397 messages,
398 };
399 let payload: UserPayload = self.call(&request, "user")?;
400 Ok(UserTurn {
401 message: payload.message,
402 stop: payload.stop,
403 usage: payload.usage,
404 })
405 }
406
407 fn judge(
408 &self,
409 model: &str,
410 query: &JudgeQuery<'_>,
411 messages: &[Message],
412 ) -> Result<JudgeVerdict> {
413 let (min, max) = match query.scale {
414 Some((lo, hi)) => (Some(lo), Some(hi)),
415 None => (None, None),
416 };
417 let request = Request::Judge {
418 model,
419 kind: query.kind.as_str(),
420 criterion: query.criterion,
421 min,
422 max,
423 messages,
424 };
425 let payload: JudgePayload = self.call(&request, "judge")?;
426 Ok(JudgeVerdict {
427 value: payload.value,
428 reason: payload.reason,
429 usage: payload.usage,
430 })
431 }
432}
433
434pub struct OneharnessProvider {
471 bin: String,
472 judge_harness: String,
473 timeout_secs: u64,
474}
475
476#[derive(Deserialize)]
478struct OhEnvelope {
479 results: Vec<OhResult>,
480}
481
482#[derive(Deserialize)]
483struct OhResult {
484 status: String,
485 #[serde(default)]
486 text: Option<String>,
487 #[serde(default)]
494 stdout: String,
495 #[serde(default)]
496 stderr: String,
497 #[serde(default)]
498 error: Option<String>,
499 #[serde(default)]
500 session_id: Option<String>,
501 #[serde(default)]
502 usage: Option<Usage>,
503 #[serde(default)]
506 events: Option<Vec<ToolEvent>>,
507 #[serde(default)]
508 failure_kind: Option<String>,
509}
510
511struct RunArgs<'a> {
513 harness: &'a str,
514 model: &'a str,
515 prompt: &'a str,
516 system: Option<&'a str>,
519 resume: Option<&'a str>,
522}
523
524struct RunOutcome {
526 text: String,
527 session_id: Option<String>,
528 usage: Option<Usage>,
529 events: Vec<ToolEvent>,
530}
531
532fn select_reply_text(text: Option<String>, stdout: &str) -> Option<String> {
540 text.filter(|t| !t.trim().is_empty())
541 .or_else(|| (!stdout.trim().is_empty()).then(|| stdout.to_string()))
542}
543
544impl OneharnessProvider {
545 #[must_use]
547 pub fn new(config: &OneharnessConfig) -> Self {
548 Self {
549 bin: config.bin.clone(),
550 judge_harness: config.judge_harness.clone(),
551 timeout_secs: config.timeout_secs,
552 }
553 }
554
555 fn run(&self, args: &RunArgs<'_>) -> Result<RunOutcome> {
559 let timeout = self.timeout_secs.to_string();
560 let mut cmd = Command::new(&self.bin);
561 cmd.args([
580 "run",
581 "--harness",
582 args.harness,
583 "--compact",
584 "--events",
585 "--timeout",
586 &timeout,
587 "--prompt-file",
588 "-",
589 ]);
590 if !args.model.is_empty() {
596 cmd.args(["--model", args.model]);
597 }
598 if let Some(system) = args.system {
599 cmd.args(["--system", system]);
600 }
601 if let Some(resume) = args.resume {
602 cmd.args(["--resume", resume]);
603 }
604
605 let mut child = cmd
606 .stdin(Stdio::piped())
607 .stdout(Stdio::piped())
608 .stderr(Stdio::piped())
609 .spawn()
610 .map_err(|e| {
611 Error::provider(
612 "oneharness",
613 format!(
614 "could not run `{}`: {e}. Is oneharness installed and on PATH?",
615 self.bin
616 ),
617 )
618 })?;
619
620 child
621 .stdin
622 .as_mut()
623 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?
624 .write_all(args.prompt.as_bytes())
625 .map_err(|e| Error::provider("oneharness", format!("could not write prompt: {e}")))?;
626
627 let output = child.wait_with_output().map_err(|e| {
628 Error::provider("oneharness", format!("oneharness did not complete: {e}"))
629 })?;
630
631 let stdout = String::from_utf8_lossy(&output.stdout);
632 let envelope: OhEnvelope = serde_json::from_str(stdout.trim()).map_err(|e| {
633 Error::provider(
634 "oneharness",
635 format!(
636 "could not parse oneharness output: {e}; stderr: {}",
637 String::from_utf8_lossy(&output.stderr).trim()
638 ),
639 )
640 })?;
641
642 let result = envelope
643 .results
644 .into_iter()
645 .next()
646 .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
647
648 if result.status != "ok" {
649 let detail = result
650 .error
651 .filter(|e| !e.is_empty())
652 .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
653 .unwrap_or_else(|| format!("status `{}`", result.status));
654 let context = format!("oneharness:{}", args.harness);
655 let message = format!("harness run failed: {detail}");
656 return Err(match result.failure_kind {
657 Some(kind) if !kind.is_empty() => {
658 Error::provider_classified(context, message, kind)
659 }
660 _ => Error::provider(context, message),
661 });
662 }
663
664 let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
669 Error::provider(
670 format!("oneharness:{}", args.harness),
671 "harness produced neither extractable text nor stdout",
672 )
673 })?;
674 Ok(RunOutcome {
675 text,
676 session_id: result.session_id,
677 usage: result.usage,
678 events: result.events.unwrap_or_default(),
679 })
680 }
681
682 fn run_streaming(
689 &self,
690 args: &RunArgs<'_>,
691 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
692 ) -> Result<RunOutcome> {
693 let timeout = self.timeout_secs.to_string();
694 let mut cmd = Command::new(&self.bin);
695 cmd.args([
700 "run",
701 "--harness",
702 args.harness,
703 "--stream",
704 "--events",
705 "--timeout",
706 &timeout,
707 "--prompt-file",
708 "-",
709 ]);
710 if !args.model.is_empty() {
711 cmd.args(["--model", args.model]);
712 }
713 if let Some(system) = args.system {
714 cmd.args(["--system", system]);
715 }
716 if let Some(resume) = args.resume {
717 cmd.args(["--resume", resume]);
718 }
719
720 let mut child = cmd
721 .stdin(Stdio::piped())
722 .stdout(Stdio::piped())
723 .stderr(Stdio::piped())
724 .spawn()
725 .map_err(|e| {
726 Error::provider(
727 "oneharness",
728 format!(
729 "could not run `{}`: {e}. Is oneharness installed and on PATH?",
730 self.bin
731 ),
732 )
733 })?;
734
735 {
739 let mut stdin = child
740 .stdin
741 .take()
742 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?;
743 stdin.write_all(args.prompt.as_bytes()).map_err(|e| {
744 Error::provider("oneharness", format!("could not write prompt: {e}"))
745 })?;
746 }
747
748 let stdout = child
749 .stdout
750 .take()
751 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdout"))?;
752 let reader = BufReader::new(stdout);
753
754 let mut events: Vec<ToolEvent> = Vec::new();
755 let mut result_env: Option<OhEnvelope> = None;
756 let mut aborted = false;
757
758 for line in reader.lines() {
759 let line = line.map_err(|e| {
760 Error::provider("oneharness", format!("could not read stream: {e}"))
761 })?;
762 let trimmed = line.trim();
763 if trimmed.is_empty() {
764 continue;
765 }
766 let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
768 continue;
769 };
770 match value.get("type").and_then(serde_json::Value::as_str) {
771 Some("event") => {
772 if let Ok(event) = serde_json::from_value::<ToolEvent>(value["event"].clone()) {
773 let flow = on_event(&event);
774 events.push(event);
775 if flow.is_break() {
776 aborted = true;
777 let _ = child.kill();
778 break;
779 }
780 }
781 }
782 Some("result") => {
783 if let Ok(env) = serde_json::from_value::<OhEnvelope>(value["report"].clone()) {
784 result_env = Some(env);
785 }
786 }
787 _ => {}
788 }
789 }
790
791 let output = child.wait_with_output().map_err(|e| {
792 Error::provider("oneharness", format!("oneharness did not complete: {e}"))
793 })?;
794
795 if aborted {
796 return Ok(RunOutcome {
798 text: String::new(),
799 session_id: None,
800 usage: None,
801 events,
802 });
803 }
804
805 let envelope = result_env.ok_or_else(|| {
806 Error::provider(
807 "oneharness",
808 format!(
809 "oneharness stream produced no result; stderr: {}",
810 String::from_utf8_lossy(&output.stderr).trim()
811 ),
812 )
813 })?;
814 let result = envelope
815 .results
816 .into_iter()
817 .next()
818 .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
819 if result.status != "ok" {
820 let detail = result
821 .error
822 .filter(|e| !e.is_empty())
823 .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
824 .unwrap_or_else(|| format!("status `{}`", result.status));
825 let context = format!("oneharness:{}", args.harness);
826 let message = format!("harness run failed: {detail}");
827 return Err(match result.failure_kind {
828 Some(kind) if !kind.is_empty() => {
829 Error::provider_classified(context, message, kind)
830 }
831 _ => Error::provider(context, message),
832 });
833 }
834 let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
835 Error::provider(
836 format!("oneharness:{}", args.harness),
837 "harness produced neither extractable text nor stdout",
838 )
839 })?;
840 let events = if events.is_empty() {
843 result.events.unwrap_or_default()
844 } else {
845 events
846 };
847 Ok(RunOutcome {
848 text,
849 session_id: result.session_id,
850 usage: result.usage,
851 events,
852 })
853 }
854}
855
856impl Provider for OneharnessProvider {
857 fn respond(
858 &self,
859 platform: &str,
860 model: &str,
861 skill: &SkillRef<'_>,
862 messages: &[Message],
863 session: Option<&str>,
864 ) -> Result<AssistantTurn> {
865 let prompt = if session.is_some() {
870 latest_user_message(messages).unwrap_or_default()
871 } else {
872 render_transcript_for_respond(messages)
873 };
874 let outcome = self.run(&RunArgs {
875 harness: platform,
876 model,
877 prompt: &prompt,
878 system: Some(skill.instructions),
879 resume: session,
880 })?;
881 Ok(AssistantTurn {
882 message: outcome.text.trim().to_string(),
883 done: false,
884 usage: outcome.usage,
885 session_id: outcome.session_id,
886 events: outcome.events,
887 })
888 }
889
890 fn respond_streaming(
891 &self,
892 platform: &str,
893 model: &str,
894 skill: &SkillRef<'_>,
895 messages: &[Message],
896 session: Option<&str>,
897 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
898 ) -> Result<AssistantTurn> {
899 let prompt = if session.is_some() {
900 latest_user_message(messages).unwrap_or_default()
901 } else {
902 render_transcript_for_respond(messages)
903 };
904 let outcome = self.run_streaming(
905 &RunArgs {
906 harness: platform,
907 model,
908 prompt: &prompt,
909 system: Some(skill.instructions),
910 resume: session,
911 },
912 on_event,
913 )?;
914 Ok(AssistantTurn {
915 message: outcome.text.trim().to_string(),
916 done: false,
917 usage: outcome.usage,
918 session_id: outcome.session_id,
919 events: outcome.events,
920 })
921 }
922
923 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
924 let prompt = build_user_prompt(persona, messages);
925 let outcome = self.run(&RunArgs {
926 harness: &self.judge_harness,
927 model,
928 prompt: &prompt,
929 system: None,
930 resume: None,
931 })?;
932 Ok(UserTurn {
933 message: outcome.text.trim().to_string(),
934 stop: false,
935 usage: outcome.usage,
936 })
937 }
938
939 fn judge(
940 &self,
941 model: &str,
942 query: &JudgeQuery<'_>,
943 messages: &[Message],
944 ) -> Result<JudgeVerdict> {
945 let prompt = build_judge_prompt(query, messages);
946 let outcome = self.run(&RunArgs {
947 harness: &self.judge_harness,
948 model,
949 prompt: &prompt,
950 system: None,
951 resume: None,
952 })?;
953 let mut verdict = parse_verdict(query.kind, &outcome.text)?;
954 verdict.usage = outcome.usage;
955 Ok(verdict)
956 }
957
958 fn supports_resume(&self, platform: &str) -> bool {
959 supports_resume(platform)
960 }
961}
962
963#[must_use]
968pub fn supports_resume(harness: &str) -> bool {
969 matches!(harness, "claude-code" | "opencode" | "cursor")
970}
971
972pub struct ApiJudgeProvider {
994 vendor: ApiVendor,
995 api_key_env: String,
996 endpoint: String,
997 timeout_secs: u64,
998 curl_bin: String,
999 strict_json: bool,
1000}
1001
1002const MAX_RETRIES: u32 = 2;
1005
1006#[derive(Debug)]
1008struct ChatOutcome {
1009 text: String,
1010 usage: Option<Usage>,
1011}
1012
1013const JUDGE_SYSTEM: &str =
1016 "Follow the user's instructions exactly and respond with only what they ask for.";
1017
1018impl ApiJudgeProvider {
1019 #[must_use]
1022 pub fn new(config: &ApiJudgeConfig) -> Self {
1023 let api_key_env = config
1024 .api_key_env
1025 .clone()
1026 .unwrap_or_else(|| match config.vendor {
1027 ApiVendor::Anthropic => "ANTHROPIC_API_KEY".to_string(),
1028 ApiVendor::Openai => "OPENAI_API_KEY".to_string(),
1029 });
1030 let endpoint = config
1031 .base_url
1032 .clone()
1033 .unwrap_or_else(|| match config.vendor {
1034 ApiVendor::Anthropic => "https://api.anthropic.com/v1/messages".to_string(),
1035 ApiVendor::Openai => "https://api.openai.com/v1/chat/completions".to_string(),
1036 });
1037 Self {
1038 vendor: config.vendor,
1039 api_key_env,
1040 endpoint,
1041 timeout_secs: config.timeout_secs,
1042 curl_bin: config.curl_bin.clone(),
1043 strict_json: config.strict_json,
1044 }
1045 }
1046
1047 fn chat(
1052 &self,
1053 model: &str,
1054 system: &str,
1055 user: &str,
1056 schema: Option<serde_json::Value>,
1057 ) -> Result<ChatOutcome> {
1058 let key = std::env::var(&self.api_key_env).map_err(|_| {
1059 Error::provider_classified(
1060 "api-judge",
1061 format!("API key env var `{}` is not set", self.api_key_env),
1062 "auth",
1063 )
1064 })?;
1065 let body = build_chat_body(self.vendor, model, system, user, schema);
1066 let payload = serde_json::to_vec(&body)
1067 .map_err(|e| Error::provider("api-judge", format!("could not encode request: {e}")))?;
1068
1069 let mut attempt = 0;
1070 loop {
1071 let result = self
1072 .run_curl(&key, &payload)
1073 .and_then(|raw| parse_chat_response(self.vendor, &raw));
1074 match result {
1075 Ok(outcome) => return Ok(outcome),
1076 Err(err) if attempt < MAX_RETRIES && is_retryable(&err) => {
1077 attempt += 1;
1078 std::thread::sleep(std::time::Duration::from_millis(500 * (1 << attempt)));
1079 }
1080 Err(err) => return Err(err),
1081 }
1082 }
1083 }
1084
1085 fn headers(&self, key: &str) -> Vec<(String, String)> {
1087 match self.vendor {
1088 ApiVendor::Anthropic => vec![
1089 ("x-api-key".to_string(), key.to_string()),
1090 ("anthropic-version".to_string(), "2023-06-01".to_string()),
1091 ("content-type".to_string(), "application/json".to_string()),
1092 ],
1093 ApiVendor::Openai => vec![
1094 ("authorization".to_string(), format!("Bearer {key}")),
1095 ("content-type".to_string(), "application/json".to_string()),
1096 ],
1097 }
1098 }
1099
1100 fn run_curl(&self, key: &str, body: &[u8]) -> Result<String> {
1103 let path = std::env::temp_dir().join(format!(
1104 "skilltest-judge-{}-{}.cfg",
1105 std::process::id(),
1106 curl_config_nonce()
1107 ));
1108 write_curl_config(&path, &self.endpoint, &self.headers(key), self.timeout_secs)?;
1109 let outcome = self.exec_curl(&path, body);
1110 let _ = std::fs::remove_file(&path);
1112 outcome
1113 }
1114
1115 fn exec_curl(&self, config_path: &std::path::Path, body: &[u8]) -> Result<String> {
1116 let mut child = Command::new(&self.curl_bin)
1117 .arg("--config")
1118 .arg(config_path)
1119 .arg("--data-binary")
1120 .arg("@-")
1121 .stdin(Stdio::piped())
1122 .stdout(Stdio::piped())
1123 .stderr(Stdio::piped())
1124 .spawn()
1125 .map_err(|e| {
1126 Error::provider(
1127 "api-judge",
1128 format!(
1129 "could not run `{}`: {e}. Is curl installed and on PATH?",
1130 self.curl_bin
1131 ),
1132 )
1133 })?;
1134
1135 child
1136 .stdin
1137 .as_mut()
1138 .ok_or_else(|| Error::provider("api-judge", "could not open curl stdin"))?
1139 .write_all(body)
1140 .map_err(|e| Error::provider("api-judge", format!("could not write request: {e}")))?;
1141
1142 let output = child
1143 .wait_with_output()
1144 .map_err(|e| Error::provider("api-judge", format!("curl did not complete: {e}")))?;
1145
1146 if !output.status.success() {
1147 let stderr = String::from_utf8_lossy(&output.stderr);
1148 return Err(Error::provider(
1149 "api-judge",
1150 format!("curl failed ({}): {}", output.status, stderr.trim()),
1151 ));
1152 }
1153 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1154 }
1155}
1156
1157impl Provider for ApiJudgeProvider {
1158 fn respond(
1159 &self,
1160 _platform: &str,
1161 _model: &str,
1162 _skill: &SkillRef<'_>,
1163 _messages: &[Message],
1164 _session: Option<&str>,
1165 ) -> Result<AssistantTurn> {
1166 Err(Error::provider(
1167 "api-judge",
1168 "the API judge does not run skills; use it as the judge in a SplitProvider",
1169 ))
1170 }
1171
1172 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1173 let prompt = build_user_prompt(persona, messages);
1174 let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, None)?;
1176 Ok(UserTurn {
1177 message: outcome.text.trim().to_string(),
1178 stop: false,
1179 usage: outcome.usage,
1180 })
1181 }
1182
1183 fn judge(
1184 &self,
1185 model: &str,
1186 query: &JudgeQuery<'_>,
1187 messages: &[Message],
1188 ) -> Result<JudgeVerdict> {
1189 let prompt = build_judge_prompt(query, messages);
1190 let schema = self.strict_json.then(|| verdict_schema(query.kind));
1193 let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, schema)?;
1194 let mut verdict = parse_verdict(query.kind, &outcome.text)?;
1195 verdict.usage = outcome.usage;
1196 Ok(verdict)
1197 }
1198}
1199
1200pub struct SplitProvider {
1206 responder: Box<dyn Provider>,
1207 judge: ApiJudgeProvider,
1208}
1209
1210impl SplitProvider {
1211 #[must_use]
1213 pub fn new(responder: Box<dyn Provider>, judge: ApiJudgeProvider) -> Self {
1214 Self { responder, judge }
1215 }
1216}
1217
1218impl Provider for SplitProvider {
1219 fn respond(
1220 &self,
1221 platform: &str,
1222 model: &str,
1223 skill: &SkillRef<'_>,
1224 messages: &[Message],
1225 session: Option<&str>,
1226 ) -> Result<AssistantTurn> {
1227 self.responder
1228 .respond(platform, model, skill, messages, session)
1229 }
1230
1231 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1232 self.judge.simulate_user(model, persona, messages)
1233 }
1234
1235 fn judge(
1236 &self,
1237 model: &str,
1238 query: &JudgeQuery<'_>,
1239 messages: &[Message],
1240 ) -> Result<JudgeVerdict> {
1241 self.judge.judge(model, query, messages)
1242 }
1243
1244 fn supports_resume(&self, platform: &str) -> bool {
1245 self.responder.supports_resume(platform)
1246 }
1247}
1248
1249fn curl_config_nonce() -> u64 {
1252 use std::sync::atomic::{AtomicU64, Ordering};
1253 static COUNTER: AtomicU64 = AtomicU64::new(0);
1254 COUNTER.fetch_add(1, Ordering::Relaxed)
1255}
1256
1257fn curl_escape(value: &str) -> String {
1259 value.replace('\\', "\\\\").replace('"', "\\\"")
1260}
1261
1262fn write_curl_config(
1266 path: &std::path::Path,
1267 url: &str,
1268 headers: &[(String, String)],
1269 timeout_secs: u64,
1270) -> Result<()> {
1271 let mut config = String::new();
1272 config.push_str(&format!("url = \"{}\"\n", curl_escape(url)));
1273 config.push_str("request = \"POST\"\n");
1274 for (name, value) in headers {
1275 config.push_str(&format!("header = \"{}: {}\"\n", name, curl_escape(value)));
1276 }
1277 config.push_str(&format!("max-time = {timeout_secs}\n"));
1278 config.push_str("silent\nshow-error\n");
1279
1280 let mut options = std::fs::OpenOptions::new();
1281 options.write(true).create(true).truncate(true);
1282 #[cfg(unix)]
1283 {
1284 use std::os::unix::fs::OpenOptionsExt as _;
1285 options.mode(0o600);
1286 }
1287 let mut file = options
1288 .open(path)
1289 .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1290 file.write_all(config.as_bytes())
1291 .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1292 Ok(())
1293}
1294
1295fn verdict_schema(kind: JudgeKind) -> serde_json::Value {
1300 let value_type = match kind {
1301 JudgeKind::Boolean => "boolean",
1302 JudgeKind::Numeric => "number",
1303 };
1304 serde_json::json!({
1305 "type": "object",
1306 "properties": {
1307 "value": { "type": value_type },
1308 "reason": { "type": "string" },
1309 },
1310 "required": ["value", "reason"],
1311 "additionalProperties": false,
1312 })
1313}
1314
1315fn build_chat_body(
1320 vendor: ApiVendor,
1321 model: &str,
1322 system: &str,
1323 user: &str,
1324 schema: Option<serde_json::Value>,
1325) -> serde_json::Value {
1326 match vendor {
1327 ApiVendor::Anthropic => {
1328 let mut body = serde_json::json!({
1329 "model": model,
1330 "max_tokens": 1024,
1331 "system": system,
1332 "messages": [{ "role": "user", "content": user }],
1333 });
1334 if let Some(schema) = schema {
1335 body["output_config"] =
1336 serde_json::json!({ "format": { "type": "json_schema", "schema": schema } });
1337 }
1338 body
1339 }
1340 ApiVendor::Openai => {
1341 let mut body = serde_json::json!({
1342 "model": model,
1343 "max_tokens": 1024,
1344 "messages": [
1345 { "role": "system", "content": system },
1346 { "role": "user", "content": user },
1347 ],
1348 });
1349 if let Some(schema) = schema {
1350 body["response_format"] = serde_json::json!({
1351 "type": "json_schema",
1352 "json_schema": { "name": "verdict", "strict": true, "schema": schema },
1353 });
1354 }
1355 body
1356 }
1357 }
1358}
1359
1360fn is_retryable(err: &Error) -> bool {
1362 matches!(
1363 err,
1364 Error::Provider { kind: Some(k), .. } if k == "rate_limit" || k == "overloaded"
1365 )
1366}
1367
1368#[derive(Deserialize)]
1372struct ApiErrorBody {
1373 #[serde(rename = "type", default)]
1374 kind: Option<String>,
1375 #[serde(default)]
1376 message: Option<String>,
1377}
1378
1379#[derive(Deserialize)]
1380struct AnthropicBlock {
1381 #[serde(rename = "type")]
1382 kind: String,
1383 #[serde(default)]
1384 text: Option<String>,
1385}
1386
1387#[derive(Deserialize)]
1388struct AnthropicUsage {
1389 #[serde(default)]
1390 input_tokens: Option<u64>,
1391 #[serde(default)]
1392 output_tokens: Option<u64>,
1393}
1394
1395#[derive(Deserialize)]
1396struct AnthropicResponse {
1397 #[serde(default)]
1398 content: Vec<AnthropicBlock>,
1399 #[serde(default)]
1400 usage: Option<AnthropicUsage>,
1401 #[serde(default)]
1402 stop_reason: Option<String>,
1403 #[serde(default)]
1404 error: Option<ApiErrorBody>,
1405}
1406
1407#[derive(Deserialize)]
1408struct OpenAiMessage {
1409 #[serde(default)]
1410 content: Option<String>,
1411}
1412
1413#[derive(Deserialize)]
1414struct OpenAiChoice {
1415 #[serde(default)]
1416 message: Option<OpenAiMessage>,
1417}
1418
1419#[derive(Deserialize)]
1420struct OpenAiUsage {
1421 #[serde(default)]
1422 prompt_tokens: Option<u64>,
1423 #[serde(default)]
1424 completion_tokens: Option<u64>,
1425}
1426
1427#[derive(Deserialize)]
1428struct OpenAiResponse {
1429 #[serde(default)]
1430 choices: Vec<OpenAiChoice>,
1431 #[serde(default)]
1432 usage: Option<OpenAiUsage>,
1433 #[serde(default)]
1434 error: Option<ApiErrorBody>,
1435}
1436
1437fn classify_api_error(kind: Option<&str>) -> Option<String> {
1440 match kind? {
1441 "authentication_error" | "invalid_api_key" | "permission_error" => Some("auth".to_string()),
1442 "rate_limit_error" | "rate_limit_exceeded" => Some("rate_limit".to_string()),
1443 "insufficient_quota" | "billing_error" => Some("quota".to_string()),
1444 "not_found_error" => Some("model_not_found".to_string()),
1445 "overloaded_error" | "api_error" | "server_error" | "service_unavailable" => {
1448 Some("overloaded".to_string())
1449 }
1450 _ => None,
1451 }
1452}
1453
1454fn api_error(err: ApiErrorBody) -> Error {
1455 let message = err
1456 .message
1457 .unwrap_or_else(|| "API returned an error".to_string());
1458 match classify_api_error(err.kind.as_deref()) {
1459 Some(kind) => Error::provider_classified("api-judge", message, kind),
1460 None => Error::provider("api-judge", message),
1461 }
1462}
1463
1464fn truncate_for_error(raw: &str) -> String {
1466 raw.chars().take(500).collect()
1467}
1468
1469fn parse_chat_response(vendor: ApiVendor, raw: &str) -> Result<ChatOutcome> {
1471 match vendor {
1472 ApiVendor::Anthropic => {
1473 let resp: AnthropicResponse = serde_json::from_str(raw.trim()).map_err(|e| {
1474 Error::provider(
1475 "api-judge",
1476 format!(
1477 "could not parse API response: {e}; got: {}",
1478 truncate_for_error(raw)
1479 ),
1480 )
1481 })?;
1482 if let Some(err) = resp.error {
1483 return Err(api_error(err));
1484 }
1485 let text = resp
1486 .content
1487 .iter()
1488 .filter(|b| b.kind == "text")
1489 .filter_map(|b| b.text.as_deref())
1490 .collect::<String>();
1491 if text.trim().is_empty() {
1492 return Err(Error::provider(
1493 "api-judge",
1494 format!(
1495 "judge returned no text (stop_reason: {:?})",
1496 resp.stop_reason
1497 ),
1498 ));
1499 }
1500 let usage = resp.usage.map(|u| Usage {
1501 input_tokens: u.input_tokens,
1502 output_tokens: u.output_tokens,
1503 cost_usd: None,
1504 });
1505 Ok(ChatOutcome { text, usage })
1506 }
1507 ApiVendor::Openai => {
1508 let resp: OpenAiResponse = serde_json::from_str(raw.trim()).map_err(|e| {
1509 Error::provider(
1510 "api-judge",
1511 format!(
1512 "could not parse API response: {e}; got: {}",
1513 truncate_for_error(raw)
1514 ),
1515 )
1516 })?;
1517 if let Some(err) = resp.error {
1518 return Err(api_error(err));
1519 }
1520 let text = resp
1521 .choices
1522 .into_iter()
1523 .next()
1524 .and_then(|c| c.message)
1525 .and_then(|m| m.content)
1526 .unwrap_or_default();
1527 if text.trim().is_empty() {
1528 return Err(Error::provider("api-judge", "judge returned no text"));
1529 }
1530 let usage = resp.usage.map(|u| Usage {
1531 input_tokens: u.prompt_tokens,
1532 output_tokens: u.completion_tokens,
1533 cost_usd: None,
1534 });
1535 Ok(ChatOutcome { text, usage })
1536 }
1537 }
1538}
1539
1540fn render_transcript(messages: &[Message]) -> String {
1544 messages
1545 .iter()
1546 .map(|m| {
1547 let role = match m.role {
1548 Role::User => "User",
1549 Role::Assistant => "Assistant",
1550 Role::System => "System",
1551 };
1552 format!("{role}: {}", m.content)
1553 })
1554 .collect::<Vec<_>>()
1555 .join("\n")
1556}
1557
1558fn render_transcript_for_respond(messages: &[Message]) -> String {
1562 format!(
1563 "Conversation so far (most recent last):\n{}\n\n\
1564 Write only the assistant's next reply, following your system \
1565 instructions. Output the reply text and nothing else.",
1566 render_transcript(messages),
1567 )
1568}
1569
1570fn latest_user_message(messages: &[Message]) -> Option<String> {
1573 messages
1574 .iter()
1575 .rev()
1576 .find(|m| m.role == Role::User)
1577 .map(|m| m.content.clone())
1578}
1579
1580fn build_user_prompt(persona: &str, messages: &[Message]) -> String {
1581 format!(
1582 "You are role-playing the USER in a conversation with an AI assistant. \
1583 Stay in character:\n\n{persona}\n\n\
1584 Conversation so far (most recent last):\n{transcript}\n\n\
1585 Write only the user's next message. Output the message text and nothing \
1586 else.",
1587 transcript = render_transcript(messages),
1588 )
1589}
1590
1591fn build_judge_prompt(query: &JudgeQuery<'_>, messages: &[Message]) -> String {
1592 let transcript = render_transcript(messages);
1593 match query.kind {
1594 JudgeKind::Boolean => format!(
1595 "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
1596 Criterion: {criterion}\n\n\
1597 Transcript:\n{transcript}\n\n\
1598 Decide whether the criterion is satisfied. Respond with ONLY a \
1599 single-line JSON object and nothing else:\n\
1600 {{\"value\": true or false, \"reason\": \"<one short sentence>\"}}",
1601 criterion = query.criterion,
1602 ),
1603 JudgeKind::Numeric => {
1604 let (min, max) = query.scale.unwrap_or((0.0, 10.0));
1605 format!(
1606 "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
1607 Criterion: {criterion}\n\n\
1608 Transcript:\n{transcript}\n\n\
1609 Score how well the criterion is satisfied on a scale from {min} to \
1610 {max} (inclusive). Respond with ONLY a single-line JSON object and \
1611 nothing else:\n\
1612 {{\"value\": <number between {min} and {max}>, \"reason\": \"<one short sentence>\"}}",
1613 criterion = query.criterion,
1614 )
1615 }
1616 }
1617}
1618
1619fn extract_json_object(text: &str) -> Option<&str> {
1622 let start = text.find('{')?;
1623 let end = text.rfind('}')?;
1624 if end > start {
1625 Some(&text[start..=end])
1626 } else {
1627 None
1628 }
1629}
1630
1631fn parse_verdict(kind: JudgeKind, text: &str) -> Result<JudgeVerdict> {
1632 let json = extract_json_object(text).ok_or_else(|| {
1633 Error::provider(
1634 "oneharness:judge",
1635 format!("judge did not return a JSON object; got: {text}"),
1636 )
1637 })?;
1638 let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
1639 Error::provider(
1640 "oneharness:judge",
1641 format!("judge verdict was not valid JSON: {e}; got: {json}"),
1642 )
1643 })?;
1644 let reason = value
1645 .get("reason")
1646 .and_then(serde_json::Value::as_str)
1647 .unwrap_or("")
1648 .to_string();
1649 let raw = value
1650 .get("value")
1651 .ok_or_else(|| Error::provider("oneharness:judge", "judge verdict has no `value` field"))?;
1652
1653 let verdict_value = match kind {
1654 JudgeKind::Boolean => JudgeValue::Bool(raw.as_bool().ok_or_else(|| {
1655 Error::provider(
1656 "oneharness:judge",
1657 format!("boolean judge `value` was not a bool: {raw}"),
1658 )
1659 })?),
1660 JudgeKind::Numeric => JudgeValue::Number(raw.as_f64().ok_or_else(|| {
1661 Error::provider(
1662 "oneharness:judge",
1663 format!("numeric judge `value` was not a number: {raw}"),
1664 )
1665 })?),
1666 };
1667
1668 Ok(JudgeVerdict {
1669 value: verdict_value,
1670 reason,
1671 usage: None,
1672 })
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677 use super::*;
1678
1679 #[test]
1680 fn empty_argv_is_rejected() {
1681 assert!(CommandProvider::new(vec![]).is_err());
1682 }
1683
1684 #[test]
1685 fn request_serializes_with_op_tag() {
1686 let req = Request::Judge {
1687 model: "m",
1688 kind: "numeric",
1689 criterion: "polite",
1690 min: Some(0.0),
1691 max: Some(10.0),
1692 messages: &[],
1693 };
1694 let json = serde_json::to_string(&req).unwrap();
1695 assert!(json.contains("\"op\":\"judge\""));
1696 assert!(json.contains("\"kind\":\"numeric\""));
1697 }
1698
1699 #[test]
1700 fn respond_no_session_inlines_transcript_but_not_skill() {
1701 let messages = [
1704 Message::user("Hi"),
1705 Message::assistant("Hello"),
1706 Message::user("Again?"),
1707 ];
1708 let prompt = render_transcript_for_respond(&messages);
1709 assert!(prompt.contains("User: Hi"));
1710 assert!(prompt.contains("Assistant: Hello"));
1711 assert!(prompt.contains("User: Again?"));
1712 assert!(!prompt.contains("SKILL"));
1714 }
1715
1716 #[test]
1717 fn respond_with_session_sends_only_latest_user_message() {
1718 let messages = [
1719 Message::user("Hi"),
1720 Message::assistant("Hello"),
1721 Message::user("Again?"),
1722 ];
1723 assert_eq!(latest_user_message(&messages).as_deref(), Some("Again?"));
1724 }
1725
1726 #[test]
1727 fn extracts_json_from_fenced_or_prose_text() {
1728 assert_eq!(
1729 extract_json_object("```json\n{\"value\": true}\n```"),
1730 Some("{\"value\": true}")
1731 );
1732 assert_eq!(
1733 extract_json_object("Sure! {\"value\": 8, \"reason\": \"x\"} done"),
1734 Some("{\"value\": 8, \"reason\": \"x\"}")
1735 );
1736 assert_eq!(extract_json_object("no json here"), None);
1737 }
1738
1739 #[test]
1740 fn parses_boolean_and_numeric_verdicts() {
1741 let b = parse_verdict(JudgeKind::Boolean, "{\"value\": true, \"reason\": \"ok\"}").unwrap();
1742 assert!(matches!(b.value, JudgeValue::Bool(true)));
1743 assert_eq!(b.reason, "ok");
1744
1745 let n =
1746 parse_verdict(JudgeKind::Numeric, "{\"value\": 8.5, \"reason\": \"good\"}").unwrap();
1747 assert!(matches!(n.value, JudgeValue::Number(v) if (v - 8.5).abs() < f64::EPSILON));
1748 }
1749
1750 #[test]
1751 fn verdict_with_wrong_value_type_errors() {
1752 assert!(parse_verdict(JudgeKind::Boolean, "{\"value\": 3}").is_err());
1753 assert!(parse_verdict(JudgeKind::Numeric, "{\"value\": true}").is_err());
1754 assert!(parse_verdict(JudgeKind::Boolean, "no json").is_err());
1755 }
1756
1757 #[test]
1758 fn usage_accumulates_independently_per_field() {
1759 let mut total = Usage::default();
1760 total.add(&Usage {
1761 input_tokens: Some(10),
1762 output_tokens: None,
1763 cost_usd: Some(0.01),
1764 });
1765 total.add(&Usage {
1766 input_tokens: Some(5),
1767 output_tokens: Some(3),
1768 cost_usd: None,
1769 });
1770 assert_eq!(total.input_tokens, Some(15));
1771 assert_eq!(total.output_tokens, Some(3));
1772 assert!((total.cost_usd.unwrap() - 0.01).abs() < f64::EPSILON);
1773 assert!(!total.is_empty());
1774 }
1775
1776 #[test]
1777 fn reply_text_prefers_extracted_then_falls_back_to_stdout() {
1778 assert_eq!(
1780 select_reply_text(Some("clean reply".into()), "raw noise"),
1781 Some("clean reply".into())
1782 );
1783 assert_eq!(
1786 select_reply_text(None, "{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}"),
1787 Some("{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}".into())
1788 );
1789 assert_eq!(
1790 select_reply_text(Some(" ".into()), "fallback"),
1791 Some("fallback".into())
1792 );
1793 assert_eq!(select_reply_text(None, " \n"), None);
1795 assert_eq!(select_reply_text(Some(String::new()), ""), None);
1796 }
1797
1798 #[test]
1799 fn supports_resume_covers_known_harnesses() {
1800 assert!(supports_resume("claude-code"));
1801 assert!(supports_resume("opencode"));
1802 assert!(supports_resume("cursor"));
1803 assert!(!supports_resume("codex"));
1804 assert!(!supports_resume("goose"));
1805 }
1806
1807 fn api_config(vendor: ApiVendor) -> ApiJudgeConfig {
1808 ApiJudgeConfig {
1809 vendor,
1810 api_key_env: None,
1811 base_url: None,
1812 timeout_secs: 60,
1813 curl_bin: "curl".to_string(),
1814 strict_json: true,
1815 }
1816 }
1817
1818 #[test]
1819 fn api_judge_resolves_vendor_defaults() {
1820 let anthropic = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
1821 assert_eq!(anthropic.api_key_env, "ANTHROPIC_API_KEY");
1822 assert_eq!(anthropic.endpoint, "https://api.anthropic.com/v1/messages");
1823
1824 let openai = ApiJudgeProvider::new(&api_config(ApiVendor::Openai));
1825 assert_eq!(openai.api_key_env, "OPENAI_API_KEY");
1826 assert_eq!(
1827 openai.endpoint,
1828 "https://api.openai.com/v1/chat/completions"
1829 );
1830 }
1831
1832 #[test]
1833 fn api_judge_honors_overrides() {
1834 let provider = ApiJudgeProvider::new(&ApiJudgeConfig {
1835 vendor: ApiVendor::Openai,
1836 api_key_env: Some("MY_KEY".to_string()),
1837 base_url: Some("https://proxy.example/v1/chat/completions".to_string()),
1838 timeout_secs: 5,
1839 curl_bin: "curl".to_string(),
1840 strict_json: true,
1841 });
1842 assert_eq!(provider.api_key_env, "MY_KEY");
1843 assert_eq!(
1844 provider.endpoint,
1845 "https://proxy.example/v1/chat/completions"
1846 );
1847 }
1848
1849 #[test]
1850 fn build_chat_body_shapes_per_vendor() {
1851 let anthropic = build_chat_body(ApiVendor::Anthropic, "claude-x", "sys", "hi", None);
1852 assert_eq!(anthropic["model"], "claude-x");
1853 assert_eq!(anthropic["system"], "sys");
1854 assert_eq!(anthropic["messages"][0]["role"], "user");
1855 assert_eq!(anthropic["messages"].as_array().unwrap().len(), 1);
1857 assert!(anthropic.get("output_config").is_none());
1859
1860 let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", None);
1861 assert_eq!(openai["messages"][0]["role"], "system");
1862 assert_eq!(openai["messages"][1]["role"], "user");
1863 assert!(openai.get("system").is_none());
1864 assert!(openai.get("response_format").is_none());
1865 }
1866
1867 #[test]
1868 fn build_chat_body_attaches_strict_schema_per_vendor() {
1869 let schema = verdict_schema(JudgeKind::Boolean);
1870 let anthropic = build_chat_body(
1871 ApiVendor::Anthropic,
1872 "claude-x",
1873 "sys",
1874 "hi",
1875 Some(schema.clone()),
1876 );
1877 assert_eq!(anthropic["output_config"]["format"]["type"], "json_schema");
1879 assert_eq!(
1880 anthropic["output_config"]["format"]["schema"]["properties"]["value"]["type"],
1881 "boolean"
1882 );
1883
1884 let numeric = verdict_schema(JudgeKind::Numeric);
1885 let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", Some(numeric));
1886 assert_eq!(openai["response_format"]["type"], "json_schema");
1888 assert_eq!(openai["response_format"]["json_schema"]["strict"], true);
1889 assert_eq!(
1890 openai["response_format"]["json_schema"]["schema"]["properties"]["value"]["type"],
1891 "number"
1892 );
1893 }
1894
1895 #[test]
1896 fn verdict_schema_requires_value_and_reason_with_no_extras() {
1897 let schema = verdict_schema(JudgeKind::Numeric);
1898 assert_eq!(schema["additionalProperties"], false);
1899 let required: Vec<&str> = schema["required"]
1900 .as_array()
1901 .unwrap()
1902 .iter()
1903 .map(|v| v.as_str().unwrap())
1904 .collect();
1905 assert_eq!(required, ["value", "reason"]);
1906 }
1907
1908 #[test]
1909 fn parses_anthropic_success_with_usage() {
1910 let raw = r#"{"content":[{"type":"text","text":"{\"value\": true}"}],
1911 "stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":3}}"#;
1912 let outcome = parse_chat_response(ApiVendor::Anthropic, raw).unwrap();
1913 assert_eq!(outcome.text, "{\"value\": true}");
1914 let usage = outcome.usage.unwrap();
1915 assert_eq!(usage.input_tokens, Some(12));
1916 assert_eq!(usage.output_tokens, Some(3));
1917 assert!(usage.cost_usd.is_none());
1918 }
1919
1920 #[test]
1921 fn parses_openai_success_with_usage() {
1922 let raw = r#"{"choices":[{"message":{"content":"{\"value\": 8}"}}],
1923 "usage":{"prompt_tokens":20,"completion_tokens":4}}"#;
1924 let outcome = parse_chat_response(ApiVendor::Openai, raw).unwrap();
1925 assert_eq!(outcome.text, "{\"value\": 8}");
1926 let usage = outcome.usage.unwrap();
1927 assert_eq!(usage.input_tokens, Some(20));
1928 assert_eq!(usage.output_tokens, Some(4));
1929 }
1930
1931 #[test]
1932 fn parses_and_classifies_api_errors() {
1933 let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
1934 let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
1935 assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
1936
1937 let rate = r#"{"error":{"type":"rate_limit_exceeded","message":"slow down"}}"#;
1938 let err = parse_chat_response(ApiVendor::Openai, rate).unwrap_err();
1939 assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "rate_limit"));
1940 }
1941
1942 #[test]
1943 fn empty_reply_is_an_error() {
1944 let raw = r#"{"content":[],"stop_reason":"refusal"}"#;
1945 assert!(parse_chat_response(ApiVendor::Anthropic, raw).is_err());
1946 }
1947
1948 #[test]
1949 fn classify_api_error_maps_known_kinds() {
1950 assert_eq!(
1951 classify_api_error(Some("invalid_api_key")).as_deref(),
1952 Some("auth")
1953 );
1954 assert_eq!(
1955 classify_api_error(Some("insufficient_quota")).as_deref(),
1956 Some("quota")
1957 );
1958 assert_eq!(
1959 classify_api_error(Some("not_found_error")).as_deref(),
1960 Some("model_not_found")
1961 );
1962 assert_eq!(
1963 classify_api_error(Some("overloaded_error")).as_deref(),
1964 Some("overloaded")
1965 );
1966 assert_eq!(classify_api_error(Some("something_else")), None);
1967 assert_eq!(classify_api_error(None), None);
1968 }
1969
1970 #[test]
1971 fn retryable_covers_transient_errors_only() {
1972 let overloaded = r#"{"error":{"type":"overloaded_error","message":"busy"}}"#;
1973 let err = parse_chat_response(ApiVendor::Anthropic, overloaded).unwrap_err();
1974 assert!(is_retryable(&err), "overload should retry");
1975
1976 let rate = r#"{"error":{"type":"rate_limit_error","message":"slow"}}"#;
1977 let err = parse_chat_response(ApiVendor::Anthropic, rate).unwrap_err();
1978 assert!(is_retryable(&err), "rate limit should retry");
1979
1980 let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
1981 let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
1982 assert!(!is_retryable(&err), "auth must not retry");
1983 }
1984
1985 #[test]
1986 fn curl_escape_handles_quotes_and_backslashes() {
1987 assert_eq!(curl_escape(r#"a"b\c"#), r#"a\"b\\c"#);
1988 }
1989
1990 struct StubResponder;
1993
1994 impl Provider for StubResponder {
1995 fn respond(
1996 &self,
1997 _platform: &str,
1998 _model: &str,
1999 _skill: &SkillRef<'_>,
2000 _messages: &[Message],
2001 _session: Option<&str>,
2002 ) -> Result<AssistantTurn> {
2003 Ok(AssistantTurn {
2004 message: "stub reply".to_string(),
2005 ..Default::default()
2006 })
2007 }
2008
2009 fn simulate_user(
2010 &self,
2011 _model: &str,
2012 _persona: &str,
2013 _messages: &[Message],
2014 ) -> Result<UserTurn> {
2015 unreachable!("split provider routes user simulation to the judge")
2016 }
2017
2018 fn judge(
2019 &self,
2020 _model: &str,
2021 _query: &JudgeQuery<'_>,
2022 _messages: &[Message],
2023 ) -> Result<JudgeVerdict> {
2024 unreachable!("split provider routes judging to the judge")
2025 }
2026
2027 fn supports_resume(&self, platform: &str) -> bool {
2028 platform == "claude-code"
2029 }
2030 }
2031
2032 #[test]
2033 fn split_provider_delegates_respond_and_resume() {
2034 let split = SplitProvider::new(
2035 Box::new(StubResponder),
2036 ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic)),
2037 );
2038 assert!(split.supports_resume("claude-code"));
2040 assert!(!split.supports_resume("codex"));
2041 let skill = SkillRef {
2042 name: "s",
2043 dir: "/tmp/s",
2044 instructions: "do things",
2045 };
2046 let turn = split
2047 .respond("claude-code", "m", &skill, &[], None)
2048 .unwrap();
2049 assert_eq!(turn.message, "stub reply");
2050 }
2051
2052 #[test]
2053 fn api_judge_does_not_run_skills() {
2054 let provider = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
2055 let skill = SkillRef {
2056 name: "s",
2057 dir: "/tmp/s",
2058 instructions: "x",
2059 };
2060 assert!(provider.respond("p", "m", &skill, &[], None).is_err());
2061 }
2062
2063 #[cfg(unix)]
2072 mod subprocess {
2073 use super::*;
2076 use std::os::unix::fs::PermissionsExt as _;
2077 use std::path::PathBuf;
2078
2079 fn script(tag: &str, body: &str) -> PathBuf {
2082 use std::sync::atomic::{AtomicU64, Ordering};
2083 static N: AtomicU64 = AtomicU64::new(0);
2084 let dir = std::env::temp_dir().join(format!(
2085 "skilltest-prov-{}-{tag}-{}",
2086 std::process::id(),
2087 N.fetch_add(1, Ordering::Relaxed)
2088 ));
2089 std::fs::create_dir_all(&dir).unwrap();
2090 let path = dir.join("script.sh");
2091 let mut f = std::fs::File::create(&path).unwrap();
2092 f.write_all(format!("#!/bin/sh\n{body}").as_bytes())
2093 .unwrap();
2094 let mut perms = std::fs::metadata(&path).unwrap().permissions();
2095 perms.set_mode(0o755);
2096 std::fs::set_permissions(&path, perms).unwrap();
2097 path
2098 }
2099
2100 fn skill_ref() -> SkillRef<'static> {
2101 SkillRef {
2102 name: "greeter",
2103 dir: "/tmp/greeter",
2104 instructions: "Be nice.",
2105 }
2106 }
2107
2108 #[test]
2111 fn command_provider_respond_parses_response() {
2112 let bin = script(
2114 "respond",
2115 "cat >/dev/null\necho '{\"message\":\"hi there\",\"done\":true,\
2116 \"usage\":{\"input_tokens\":4,\"output_tokens\":2},\"session_id\":\"s1\"}'\n",
2117 );
2118 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2119 let turn = provider
2120 .respond("demo", "fake", &skill_ref(), &[Message::user("hi")], None)
2121 .unwrap();
2122 assert_eq!(turn.message, "hi there");
2123 assert!(turn.done);
2124 assert_eq!(turn.session_id.as_deref(), Some("s1"));
2125 assert_eq!(turn.usage.unwrap().input_tokens, Some(4));
2126 }
2127
2128 #[test]
2129 fn command_provider_user_and_judge_parse_responses() {
2130 let user_bin = script(
2131 "user",
2132 "cat >/dev/null\necho '{\"message\":\"more please\",\"stop\":true}'\n",
2133 );
2134 let user_provider =
2135 CommandProvider::new(vec![user_bin.to_string_lossy().into_owned()]).unwrap();
2136 let user = user_provider.simulate_user("m", "persona", &[]).unwrap();
2137 assert_eq!(user.message, "more please");
2138 assert!(user.stop);
2139
2140 let judge_bin = script(
2141 "judge",
2142 "cat >/dev/null\necho '{\"value\":7.5,\"reason\":\"ok\"}'\n",
2143 );
2144 let judge_provider =
2145 CommandProvider::new(vec![judge_bin.to_string_lossy().into_owned()]).unwrap();
2146 let query = JudgeQuery {
2147 kind: JudgeKind::Numeric,
2148 criterion: "polite",
2149 scale: Some((0.0, 10.0)),
2150 };
2151 let verdict = judge_provider.judge("m", &query, &[]).unwrap();
2152 assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 7.5).abs() < 1e-9));
2153 assert_eq!(verdict.reason, "ok");
2154 }
2155
2156 #[test]
2157 fn command_provider_surfaces_nonzero_exit() {
2158 let bin = script("fail", "cat >/dev/null\necho 'boom' 1>&2\nexit 2\n");
2159 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2160 let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2161 let msg = err.to_string();
2162 assert!(msg.contains("provider exited"), "got: {msg}");
2163 assert!(msg.contains("boom"), "stderr is surfaced: {msg}");
2164 }
2165
2166 #[test]
2167 fn command_provider_rejects_empty_and_bad_output() {
2168 let empty = script("empty", "cat >/dev/null\n");
2169 let provider =
2170 CommandProvider::new(vec![empty.to_string_lossy().into_owned()]).unwrap();
2171 assert!(provider
2172 .judge(
2173 "m",
2174 &JudgeQuery {
2175 kind: JudgeKind::Boolean,
2176 criterion: "x",
2177 scale: None
2178 },
2179 &[],
2180 )
2181 .unwrap_err()
2182 .to_string()
2183 .contains("no output"));
2184
2185 let garbage = script("garbage", "cat >/dev/null\necho 'not json'\n");
2186 let provider =
2187 CommandProvider::new(vec![garbage.to_string_lossy().into_owned()]).unwrap();
2188 assert!(provider
2189 .respond("demo", "m", &skill_ref(), &[], None)
2190 .unwrap_err()
2191 .to_string()
2192 .contains("not valid JSON"));
2193 }
2194
2195 #[test]
2196 fn command_provider_reports_missing_binary() {
2197 let provider =
2198 CommandProvider::new(vec!["/no/such/skilltest-provider-binary".to_string()])
2199 .unwrap();
2200 let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2201 assert!(err.to_string().contains("could not run provider"));
2202 }
2203
2204 #[test]
2205 fn command_provider_session_is_threaded_into_request() {
2206 let dir =
2209 std::env::temp_dir().join(format!("skilltest-prov-sess-{}", std::process::id()));
2210 std::fs::create_dir_all(&dir).unwrap();
2211 let seen = dir.join("seen.json");
2212 let bin = script(
2213 "session",
2214 &format!(
2215 "cat > '{}'\necho '{{\"message\":\"ok\"}}'\n",
2216 seen.display()
2217 ),
2218 );
2219 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2220 provider
2221 .respond(
2222 "demo",
2223 "m",
2224 &skill_ref(),
2225 &[Message::user("hi")],
2226 Some("session-xyz"),
2227 )
2228 .unwrap();
2229 let request = std::fs::read_to_string(&seen).unwrap();
2230 assert!(
2231 request.contains("\"session\":\"session-xyz\""),
2232 "got: {request}"
2233 );
2234 assert!(request.contains("\"op\":\"respond\""));
2235 }
2236
2237 fn oh_provider(bin: PathBuf) -> OneharnessProvider {
2240 OneharnessProvider::new(&OneharnessConfig {
2241 bin: bin.to_string_lossy().into_owned(),
2242 judge_harness: "claude-code".to_string(),
2243 timeout_secs: 30,
2244 })
2245 }
2246
2247 #[test]
2248 fn oneharness_respond_extracts_text_and_session() {
2249 let bin = script(
2250 "oh-ok",
2251 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2252 \"text\":\" hello back \",\"session_id\":\"oh1\",\
2253 \"usage\":{\"input_tokens\":5}}]}'\n",
2254 );
2255 let turn = oh_provider(bin)
2256 .respond(
2257 "claude-code",
2258 "sonnet",
2259 &skill_ref(),
2260 &[Message::user("hi")],
2261 None,
2262 )
2263 .unwrap();
2264 assert_eq!(turn.message, "hello back");
2265 assert_eq!(turn.session_id.as_deref(), Some("oh1"));
2266 assert_eq!(turn.usage.unwrap().input_tokens, Some(5));
2267 assert!(turn.events.is_empty());
2268 }
2269
2270 #[test]
2271 fn oneharness_respond_surfaces_normalized_events() {
2272 let bin = script(
2276 "oh-events",
2277 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2278 \"text\":\"done\",\"events\":[{\"kind\":\"tool_call\",\"name\":\"bash\",\
2279 \"input\":{\"command\":\"git commit -m x\"},\"output\":\"ok\",\"index\":0}]}]}'\n",
2280 );
2281 let turn = oh_provider(bin)
2282 .respond(
2283 "claude-code",
2284 "sonnet",
2285 &skill_ref(),
2286 &[Message::user("hi")],
2287 None,
2288 )
2289 .unwrap();
2290 assert_eq!(turn.events.len(), 1);
2291 assert_eq!(turn.events[0].kind, "tool_call");
2292 assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
2293 assert_eq!(
2294 turn.events[0].input,
2295 Some(serde_json::json!({"command": "git commit -m x"}))
2296 );
2297 assert_eq!(turn.events[0].output.as_deref(), Some("ok"));
2298 }
2299
2300 #[test]
2301 fn oneharness_respond_events_absent_is_empty_not_error() {
2302 let bin = script(
2305 "oh-noevents",
2306 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
2307 );
2308 let turn = oh_provider(bin)
2309 .respond("goose", "m", &skill_ref(), &[Message::user("hi")], None)
2310 .unwrap();
2311 assert!(turn.events.is_empty());
2312 }
2313
2314 #[test]
2315 fn oneharness_stream_forwards_events_then_parses_the_result() {
2316 let bin = script(
2321 "oh-stream",
2322 "cat >/dev/null\n\
2323 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2324 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":0}}'\n\
2325 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2326 \"text\":\" done \",\"session_id\":\"s1\",\"usage\":{\"input_tokens\":7}}]}}'\n",
2327 );
2328 let mut seen = Vec::new();
2329 let turn = oh_provider(bin)
2330 .respond_streaming(
2331 "claude-code",
2332 "sonnet",
2333 &skill_ref(),
2334 &[Message::user("hi")],
2335 None,
2336 &mut |event| {
2337 seen.push(event.name.clone());
2338 ControlFlow::Continue(())
2339 },
2340 )
2341 .unwrap();
2342 assert_eq!(seen, vec![Some("bash".to_string())]);
2344 assert_eq!(turn.message, "done");
2345 assert_eq!(turn.session_id.as_deref(), Some("s1"));
2346 assert_eq!(turn.usage.unwrap().input_tokens, Some(7));
2347 assert_eq!(turn.events.len(), 1);
2348 assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
2349 }
2350
2351 #[test]
2352 fn oneharness_stream_short_circuits_on_break() {
2353 let bin = script(
2357 "oh-stream-abort",
2358 "cat >/dev/null\n\
2359 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2360 \"name\":\"rm\",\"input\":{\"command\":\"rm -rf /\"},\"index\":0}}'\n\
2361 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2362 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":1}}'\n\
2363 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2364 \"text\":\"done\"}]}}'\n",
2365 );
2366 let mut seen = 0usize;
2367 let turn = oh_provider(bin)
2368 .respond_streaming(
2369 "claude-code",
2370 "sonnet",
2371 &skill_ref(),
2372 &[Message::user("hi")],
2373 None,
2374 &mut |event| {
2375 seen += 1;
2376 assert_eq!(event.name.as_deref(), Some("rm"));
2377 ControlFlow::Break(())
2378 },
2379 )
2380 .unwrap();
2381 assert_eq!(seen, 1, "aborted after the first event");
2382 assert_eq!(turn.events.len(), 1);
2383 assert_eq!(turn.events[0].name.as_deref(), Some("rm"));
2384 assert!(turn.message.is_empty());
2386 }
2387
2388 #[test]
2389 fn oneharness_stream_errors_when_no_result_line() {
2390 let bin = script(
2393 "oh-stream-noresult",
2394 "cat >/dev/null\n\
2395 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2396 \"name\":\"bash\",\"input\":{},\"index\":0}}'\n",
2397 );
2398 let err = oh_provider(bin)
2399 .respond_streaming(
2400 "claude-code",
2401 "sonnet",
2402 &skill_ref(),
2403 &[Message::user("hi")],
2404 None,
2405 &mut |_| ControlFlow::Continue(()),
2406 )
2407 .unwrap_err();
2408 assert!(
2409 matches!(err, Error::Provider { .. }),
2410 "expected a provider error, got: {err:?}"
2411 );
2412 }
2413
2414 #[test]
2415 fn oneharness_buffered_run_passes_events_and_omits_mode() {
2416 let bin = script(
2419 "oh-args",
2420 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
2421 cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
2422 );
2423 let dir = bin.parent().unwrap().to_path_buf();
2424 oh_provider(bin)
2425 .respond(
2426 "claude-code",
2427 "sonnet",
2428 &skill_ref(),
2429 &[Message::user("hi")],
2430 None,
2431 )
2432 .unwrap();
2433 let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
2434 .unwrap()
2435 .lines()
2436 .map(str::to_string)
2437 .collect();
2438 assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
2439 assert!(args.iter().any(|a| a == "--compact"), "got: {args:?}");
2440 assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
2441 assert!(!args.iter().any(|a| a == "--stream"), "got: {args:?}");
2442 }
2443
2444 #[test]
2445 fn oneharness_stream_run_passes_stream_and_omits_mode() {
2446 let bin = script(
2449 "oh-args-stream",
2450 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
2451 cat >/dev/null\n\
2452 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2453 \"text\":\"hi\"}]}}'\n",
2454 );
2455 let dir = bin.parent().unwrap().to_path_buf();
2456 oh_provider(bin)
2457 .respond_streaming(
2458 "claude-code",
2459 "sonnet",
2460 &skill_ref(),
2461 &[Message::user("hi")],
2462 None,
2463 &mut |_| ControlFlow::Continue(()),
2464 )
2465 .unwrap();
2466 let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
2467 .unwrap()
2468 .lines()
2469 .map(str::to_string)
2470 .collect();
2471 assert!(args.iter().any(|a| a == "--stream"), "got: {args:?}");
2472 assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
2473 assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
2474 assert!(!args.iter().any(|a| a == "--compact"), "got: {args:?}");
2475 }
2476
2477 #[test]
2478 fn oneharness_falls_back_to_stdout_when_text_null() {
2479 let bin = script(
2480 "oh-fallback",
2481 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2482 \"stdout\":\"raw reply\"}]}'\n",
2483 );
2484 let user = oh_provider(bin).simulate_user("m", "persona", &[]).unwrap();
2485 assert_eq!(user.message, "raw reply");
2486 }
2487
2488 #[test]
2489 fn oneharness_judge_parses_verdict() {
2490 let bin = script(
2491 "oh-judge",
2492 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2493 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"good\\\"}\"}]}'\n",
2494 );
2495 let query = JudgeQuery {
2496 kind: JudgeKind::Boolean,
2497 criterion: "polite",
2498 scale: None,
2499 };
2500 let verdict = oh_provider(bin).judge("m", &query, &[]).unwrap();
2501 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2502 assert_eq!(verdict.reason, "good");
2503 }
2504
2505 #[test]
2506 fn oneharness_classifies_failure_kind() {
2507 let bin = script(
2508 "oh-auth",
2509 "cat >/dev/null\necho '{\"results\":[{\"status\":\"error\",\
2510 \"failure_kind\":\"auth\",\"error\":\"no creds\"}]}'\n",
2511 );
2512 let err = oh_provider(bin)
2513 .respond("claude-code", "m", &skill_ref(), &[], None)
2514 .unwrap_err();
2515 assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
2516 }
2517
2518 #[test]
2519 fn oneharness_reports_status_without_failure_kind() {
2520 let bin = script(
2521 "oh-err",
2522 "cat >/dev/null\necho '{\"results\":[{\"status\":\"timeout\",\
2523 \"stderr\":\"deadline\"}]}'\n",
2524 );
2525 let err = oh_provider(bin).simulate_user("m", "p", &[]).unwrap_err();
2526 let msg = err.to_string();
2527 assert!(msg.contains("harness run failed"), "got: {msg}");
2528 assert!(msg.contains("deadline"));
2529 }
2530
2531 #[test]
2532 fn oneharness_errors_on_unparseable_output_and_no_results() {
2533 let garbage = script(
2534 "oh-garbage",
2535 "cat >/dev/null\necho 'not json' 1>&2\necho 'x'\n",
2536 );
2537 assert!(oh_provider(garbage)
2538 .simulate_user("m", "p", &[])
2539 .unwrap_err()
2540 .to_string()
2541 .contains("could not parse oneharness output"));
2542
2543 let empty = script("oh-empty", "cat >/dev/null\necho '{\"results\":[]}'\n");
2544 assert!(oh_provider(empty)
2545 .simulate_user("m", "p", &[])
2546 .unwrap_err()
2547 .to_string()
2548 .contains("no results"));
2549 }
2550
2551 #[test]
2552 fn oneharness_errors_when_no_text_or_stdout() {
2553 let bin = script(
2554 "oh-silent",
2555 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\"}]}'\n",
2556 );
2557 assert!(oh_provider(bin)
2558 .respond("claude-code", "m", &skill_ref(), &[], None)
2559 .unwrap_err()
2560 .to_string()
2561 .contains("neither extractable text nor stdout"));
2562 }
2563
2564 #[test]
2565 fn oneharness_respond_resume_sends_only_latest_message() {
2566 let dir =
2570 std::env::temp_dir().join(format!("skilltest-oh-resume-{}", std::process::id()));
2571 std::fs::create_dir_all(&dir).unwrap();
2572 let prompt_file = dir.join("prompt.txt");
2573 let argv_file = dir.join("argv.txt");
2574 let bin = script(
2575 "oh-resume",
2576 &format!(
2577 "echo \"$@\" > '{}'\ncat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
2578 argv_file.display(),
2579 prompt_file.display(),
2580 ),
2581 );
2582 let messages = [
2583 Message::user("first"),
2584 Message::assistant("reply"),
2585 Message::user("second"),
2586 ];
2587 oh_provider(bin.clone())
2588 .respond(
2589 "claude-code",
2590 "sonnet",
2591 &skill_ref(),
2592 &messages,
2593 Some("sess-1"),
2594 )
2595 .unwrap();
2596 let prompt = std::fs::read_to_string(&prompt_file).unwrap();
2597 assert_eq!(
2598 prompt.trim(),
2599 "second",
2600 "resume sends only the latest user message"
2601 );
2602 let argv = std::fs::read_to_string(&argv_file).unwrap();
2603 assert!(argv.contains("--resume sess-1"), "argv: {argv}");
2604 assert!(
2605 argv.contains("--system"),
2606 "skill is the system prompt: {argv}"
2607 );
2608 }
2609
2610 #[test]
2611 fn oneharness_omits_model_flag_when_model_empty() {
2612 let dir =
2613 std::env::temp_dir().join(format!("skilltest-oh-model-{}", std::process::id()));
2614 std::fs::create_dir_all(&dir).unwrap();
2615 let argv_file = dir.join("argv.txt");
2616 let bin = script(
2617 "oh-nomodel",
2618 &format!(
2619 "echo \"$@\" > '{}'\ncat >/dev/null\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
2620 argv_file.display(),
2621 ),
2622 );
2623 oh_provider(bin)
2624 .respond("cursor", "", &skill_ref(), &[Message::user("hi")], None)
2625 .unwrap();
2626 let argv = std::fs::read_to_string(&argv_file).unwrap();
2627 assert!(
2628 !argv.contains("--model"),
2629 "empty model omits the flag: {argv}"
2630 );
2631 }
2632
2633 #[test]
2634 fn oneharness_reports_missing_binary() {
2635 let provider = oh_provider(PathBuf::from("/no/such/oneharness-binary"));
2636 let err = provider
2637 .respond("claude-code", "m", &skill_ref(), &[], None)
2638 .unwrap_err();
2639 assert!(err.to_string().contains("could not run"));
2640 }
2641
2642 fn api_provider_with_curl(curl: PathBuf, vendor: ApiVendor) -> ApiJudgeProvider {
2645 ApiJudgeProvider::new(&ApiJudgeConfig {
2646 vendor,
2647 api_key_env: Some("SKILLTEST_TEST_API_KEY".to_string()),
2648 base_url: Some("https://example.invalid/v1".to_string()),
2649 timeout_secs: 5,
2650 curl_bin: curl.to_string_lossy().into_owned(),
2651 strict_json: true,
2652 })
2653 }
2654
2655 #[test]
2656 fn api_judge_judges_through_fake_curl() {
2657 let curl = script(
2659 "curl-ok",
2660 "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
2661 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"polite\\\"}\"}],\
2662 \"usage\":{\"input_tokens\":9,\"output_tokens\":3}}'\n",
2663 );
2664 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2665 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2666 let query = JudgeQuery {
2667 kind: JudgeKind::Boolean,
2668 criterion: "polite",
2669 scale: None,
2670 };
2671 let verdict = provider
2672 .judge("claude-x", &query, &[Message::user("hi")])
2673 .unwrap();
2674 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2675 assert_eq!(verdict.usage.unwrap().input_tokens, Some(9));
2676 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2677 }
2678
2679 #[test]
2680 fn api_judge_simulates_user_through_fake_curl() {
2681 let curl = script(
2682 "curl-user",
2683 "cat >/dev/null\necho '{\"choices\":[{\"message\":\
2684 {\"content\":\"sure, go on\"}}]}'\n",
2685 );
2686 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2687 let provider = api_provider_with_curl(curl, ApiVendor::Openai);
2688 let user = provider.simulate_user("gpt-x", "a patient", &[]).unwrap();
2689 assert_eq!(user.message, "sure, go on");
2690 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2691 }
2692
2693 #[test]
2694 fn api_judge_errors_when_key_absent() {
2695 let curl = script("curl-unused", "cat >/dev/null\necho '{}'\n");
2696 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2697 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2698 let err = provider
2699 .judge(
2700 "m",
2701 &JudgeQuery {
2702 kind: JudgeKind::Boolean,
2703 criterion: "x",
2704 scale: None,
2705 },
2706 &[],
2707 )
2708 .unwrap_err();
2709 assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "auth"));
2710 }
2711
2712 #[test]
2713 fn api_judge_surfaces_curl_failure() {
2714 let curl = script(
2715 "curl-fail",
2716 "cat >/dev/null\necho 'curl: (6) bad host' 1>&2\nexit 6\n",
2717 );
2718 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2719 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2720 let err = provider
2721 .judge(
2722 "m",
2723 &JudgeQuery {
2724 kind: JudgeKind::Boolean,
2725 criterion: "x",
2726 scale: None,
2727 },
2728 &[],
2729 )
2730 .unwrap_err();
2731 assert!(err.to_string().contains("curl failed"));
2732 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2733 }
2734
2735 #[test]
2736 fn write_curl_config_sets_private_mode_and_headers() {
2737 let dir = std::env::temp_dir().join(format!("skilltest-cfg-{}", std::process::id()));
2738 std::fs::create_dir_all(&dir).unwrap();
2739 let path = dir.join("c.cfg");
2740 write_curl_config(
2741 &path,
2742 "https://api.example/v1",
2743 &[("x-api-key".to_string(), "secret\"quote".to_string())],
2744 42,
2745 )
2746 .unwrap();
2747 let text = std::fs::read_to_string(&path).unwrap();
2748 assert!(text.contains("url = \"https://api.example/v1\""));
2749 assert!(text.contains("max-time = 42"));
2750 assert!(text.contains("secret\\\"quote"), "escaped header: {text}");
2752 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
2753 assert_eq!(mode & 0o777, 0o600, "config is private");
2754 }
2755
2756 #[test]
2757 fn api_judge_retries_a_transient_error_then_succeeds() {
2758 let dir = std::env::temp_dir().join(format!("skilltest-retry-{}", std::process::id()));
2761 std::fs::create_dir_all(&dir).unwrap();
2762 let counter = dir.join("n");
2763 let curl = script(
2764 "curl-retry",
2765 &format!(
2766 "cat >/dev/null\nif [ -f '{c}' ]; then \
2767 echo '{{\"content\":[{{\"type\":\"text\",\"text\":\"{{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}}\"}}]}}'; \
2768 else touch '{c}'; \
2769 echo '{{\"error\":{{\"type\":\"overloaded_error\",\"message\":\"busy\"}}}}'; \
2770 fi\n",
2771 c = counter.display(),
2772 ),
2773 );
2774 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2775 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2776 let verdict = provider
2777 .judge(
2778 "m",
2779 &JudgeQuery {
2780 kind: JudgeKind::Boolean,
2781 criterion: "x",
2782 scale: None,
2783 },
2784 &[],
2785 )
2786 .unwrap();
2787 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2788 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2789 }
2790
2791 #[test]
2792 fn api_judge_gives_up_after_max_retries() {
2793 let curl = script(
2795 "curl-busy",
2796 "cat >/dev/null\necho '{\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}'\n",
2797 );
2798 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2799 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
2800 let err = provider
2801 .judge(
2802 "m",
2803 &JudgeQuery {
2804 kind: JudgeKind::Boolean,
2805 criterion: "x",
2806 scale: None,
2807 },
2808 &[],
2809 )
2810 .unwrap_err();
2811 assert!(matches!(err, Error::Provider { kind: Some(k), .. } if k == "overloaded"));
2812 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2813 }
2814
2815 #[test]
2816 fn api_judge_reports_missing_curl_binary() {
2817 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2818 let provider =
2819 api_provider_with_curl(PathBuf::from("/no/such/curl-binary"), ApiVendor::Anthropic);
2820 let err = provider
2821 .judge(
2822 "m",
2823 &JudgeQuery {
2824 kind: JudgeKind::Boolean,
2825 criterion: "x",
2826 scale: None,
2827 },
2828 &[],
2829 )
2830 .unwrap_err();
2831 assert!(err.to_string().contains("could not run"));
2832 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2833 }
2834
2835 #[test]
2836 fn api_judge_surfaces_unparseable_response() {
2837 let curl = script("curl-garbage", "cat >/dev/null\necho 'not json at all'\n");
2838 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2839 let provider = api_provider_with_curl(curl, ApiVendor::Openai);
2840 let err = provider
2841 .judge(
2842 "m",
2843 &JudgeQuery {
2844 kind: JudgeKind::Boolean,
2845 criterion: "x",
2846 scale: None,
2847 },
2848 &[],
2849 )
2850 .unwrap_err();
2851 assert!(err.to_string().contains("could not parse API response"));
2852 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2853 }
2854
2855 #[test]
2856 fn split_provider_routes_judge_and_user_through_the_api() {
2857 let curl = script(
2860 "split-curl",
2861 "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
2862 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}\"}]}'\n",
2863 );
2864 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
2865 let judge = api_provider_with_curl(curl, ApiVendor::Anthropic);
2866 let split = SplitProvider::new(Box::new(super::StubResponder), judge);
2867 let verdict = split
2868 .judge(
2869 "m",
2870 &JudgeQuery {
2871 kind: JudgeKind::Boolean,
2872 criterion: "polite",
2873 scale: None,
2874 },
2875 &[],
2876 )
2877 .unwrap();
2878 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2879 std::env::remove_var("SKILLTEST_TEST_API_KEY");
2880 }
2881
2882 #[test]
2883 fn oneharness_numeric_judge_uses_numeric_prompt() {
2884 let dir = std::env::temp_dir().join(format!("skilltest-ohnum-{}", std::process::id()));
2887 std::fs::create_dir_all(&dir).unwrap();
2888 let prompt_file = dir.join("prompt.txt");
2889 let bin = script(
2890 "oh-numeric",
2891 &format!(
2892 "cat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"{{\\\"value\\\": 8.5, \\\"reason\\\": \\\"warm\\\"}}\"}}]}}'\n",
2893 prompt_file.display(),
2894 ),
2895 );
2896 let query = JudgeQuery {
2897 kind: JudgeKind::Numeric,
2898 criterion: "warmth",
2899 scale: Some((0.0, 10.0)),
2900 };
2901 let verdict = oh_provider(bin)
2902 .judge("m", &query, &[Message::assistant("hi")])
2903 .unwrap();
2904 assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 8.5).abs() < 1e-9));
2905 let prompt = std::fs::read_to_string(&prompt_file).unwrap();
2906 assert!(
2907 prompt.contains("scale from 0 to 10"),
2908 "numeric prompt: {prompt}"
2909 );
2910 }
2911
2912 #[test]
2913 fn supports_resume_method_matches_free_function() {
2914 let provider = oh_provider(PathBuf::from("/bin/true"));
2915 assert!(provider.supports_resume("claude-code"));
2916 assert!(!provider.supports_resume("codex"));
2917 }
2918 }
2919
2920 #[test]
2924 fn parse_verdict_rejects_missing_object_and_value() {
2925 assert!(parse_verdict(JudgeKind::Boolean, "just prose, no braces").is_err());
2927 assert!(parse_verdict(JudgeKind::Boolean, "{\"reason\": \"x\"}").is_err());
2929 assert!(parse_verdict(JudgeKind::Numeric, "{not: valid}").is_err());
2931 }
2932
2933 #[test]
2934 fn extract_json_object_handles_reversed_braces() {
2935 assert_eq!(extract_json_object("} then {"), None);
2937 }
2938
2939 #[test]
2940 fn unclassified_api_error_falls_back_to_plain_provider_error() {
2941 let raw = r#"{"error":{"type":"some_new_error","message":"odd"}}"#;
2943 let err = parse_chat_response(ApiVendor::Openai, raw).unwrap_err();
2944 assert!(matches!(err, Error::Provider { kind: None, .. }));
2945 assert!(err.to_string().contains("odd"));
2946 }
2947
2948 #[test]
2949 fn openai_empty_choice_text_is_an_error() {
2950 let raw = r#"{"choices":[]}"#;
2951 assert!(parse_chat_response(ApiVendor::Openai, raw).is_err());
2952 }
2953
2954 #[test]
2955 fn truncate_for_error_caps_length_on_a_char_boundary() {
2956 let long = "x".repeat(1000);
2957 assert_eq!(truncate_for_error(&long).chars().count(), 500);
2958 }
2959}