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, ProviderErrorKind, 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
769fn oh_failure(context: String, message: String, failure_kind: Option<&str>, status: &str) -> Error {
776 match oh_failure_kind(failure_kind, status) {
777 Some(kind) => Error::provider_classified(context, message, kind),
778 None => Error::provider(context, message),
779 }
780}
781
782fn oh_failure_kind(failure_kind: Option<&str>, status: &str) -> Option<ProviderErrorKind> {
785 if let Some(raw) = failure_kind.filter(|k| !k.is_empty()) {
786 return Some(ProviderErrorKind::classify(raw));
787 }
788 match status {
789 "timeout" => Some(ProviderErrorKind::Timeout),
790 _ => None,
791 }
792}
793
794impl OneharnessProvider {
795 #[must_use]
797 pub fn new(config: &OneharnessConfig) -> Self {
798 Self {
799 bin: config.bin.clone(),
800 judge_harness: config.judge_harness.clone(),
801 timeout_secs: config.timeout_secs,
802 }
803 }
804
805 fn run(&self, args: &RunArgs<'_>) -> Result<RunOutcome> {
809 let timeout = self.timeout_secs.to_string();
810 let mut cmd = Command::new(&self.bin);
811 cmd.args([
830 "run",
831 "--harness",
832 args.harness,
833 "--compact",
834 "--events",
835 "--timeout",
836 &timeout,
837 "--prompt-file",
838 "-",
839 ]);
840 if !args.model.is_empty() {
846 cmd.args(["--model", args.model]);
847 }
848 if let Some(system) = args.system {
849 cmd.args(["--system", system]);
850 }
851 if let Some(resume) = args.resume {
852 cmd.args(["--resume", resume]);
853 }
854 let mock_files = args.mocks.map(MockFiles::prepare).transpose()?;
858 if let Some(files) = &mock_files {
859 if let Some(rules) = &files.rules {
860 cmd.arg("--mock-rules");
861 cmd.arg(rules);
862 }
863 cmd.arg("--spy-file");
864 cmd.arg(&files.spy);
865 }
866
867 let mut child = cmd
868 .stdin(Stdio::piped())
869 .stdout(Stdio::piped())
870 .stderr(Stdio::piped())
871 .spawn()
872 .map_err(|e| {
873 Error::provider(
874 "oneharness",
875 format!(
876 "could not run `{}`: {e}. Is oneharness installed and on PATH?",
877 self.bin
878 ),
879 )
880 })?;
881
882 child
883 .stdin
884 .as_mut()
885 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?
886 .write_all(args.prompt.as_bytes())
887 .map_err(|e| Error::provider("oneharness", format!("could not write prompt: {e}")))?;
888
889 let output = child.wait_with_output().map_err(|e| {
890 Error::provider("oneharness", format!("oneharness did not complete: {e}"))
891 })?;
892
893 let stdout = String::from_utf8_lossy(&output.stdout);
894 let envelope: OhEnvelope = serde_json::from_str(stdout.trim()).map_err(|e| {
895 Error::provider(
896 "oneharness",
897 format!(
898 "could not parse oneharness output: {e}; stderr: {}",
899 String::from_utf8_lossy(&output.stderr).trim()
900 ),
901 )
902 })?;
903
904 let result = envelope
905 .results
906 .into_iter()
907 .next()
908 .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
909
910 if result.status != "ok" {
911 let detail = result
912 .error
913 .filter(|e| !e.is_empty())
914 .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
915 .unwrap_or_else(|| format!("status `{}`", result.status));
916 let context = format!("oneharness:{}", args.harness);
917 let message = format!("harness run failed: {detail}");
918 return Err(oh_failure(
919 context,
920 message,
921 result.failure_kind.as_deref(),
922 &result.status,
923 ));
924 }
925
926 let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
931 Error::provider(
932 format!("oneharness:{}", args.harness),
933 "harness produced neither extractable text nor stdout",
934 )
935 })?;
936 let mock_calls = mock_files.as_ref().map(MockFiles::records).transpose()?;
937 Ok(RunOutcome {
938 text,
939 session_id: result.session_id,
940 usage: result.usage,
941 events: result.events.unwrap_or_default(),
942 mock_calls,
943 })
944 }
945
946 fn run_streaming(
953 &self,
954 args: &RunArgs<'_>,
955 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
956 ) -> Result<RunOutcome> {
957 let timeout = self.timeout_secs.to_string();
958 let mut cmd = Command::new(&self.bin);
959 cmd.args([
964 "run",
965 "--harness",
966 args.harness,
967 "--stream",
968 "--events",
969 "--timeout",
970 &timeout,
971 "--prompt-file",
972 "-",
973 ]);
974 if !args.model.is_empty() {
975 cmd.args(["--model", args.model]);
976 }
977 if let Some(system) = args.system {
978 cmd.args(["--system", system]);
979 }
980 if let Some(resume) = args.resume {
981 cmd.args(["--resume", resume]);
982 }
983 let mock_files = args.mocks.map(MockFiles::prepare).transpose()?;
984 if let Some(files) = &mock_files {
985 if let Some(rules) = &files.rules {
986 cmd.arg("--mock-rules");
987 cmd.arg(rules);
988 }
989 cmd.arg("--spy-file");
990 cmd.arg(&files.spy);
991 }
992
993 let mut child = cmd
994 .stdin(Stdio::piped())
995 .stdout(Stdio::piped())
996 .stderr(Stdio::piped())
997 .spawn()
998 .map_err(|e| {
999 Error::provider(
1000 "oneharness",
1001 format!(
1002 "could not run `{}`: {e}. Is oneharness installed and on PATH?",
1003 self.bin
1004 ),
1005 )
1006 })?;
1007
1008 {
1012 let mut stdin = child
1013 .stdin
1014 .take()
1015 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?;
1016 stdin.write_all(args.prompt.as_bytes()).map_err(|e| {
1017 Error::provider("oneharness", format!("could not write prompt: {e}"))
1018 })?;
1019 }
1020
1021 let stdout = child
1022 .stdout
1023 .take()
1024 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdout"))?;
1025 let reader = BufReader::new(stdout);
1026
1027 let mut events: Vec<ToolEvent> = Vec::new();
1028 let mut result_env: Option<OhEnvelope> = None;
1029 let mut aborted = false;
1030
1031 for line in reader.lines() {
1032 let line = line.map_err(|e| {
1033 Error::provider("oneharness", format!("could not read stream: {e}"))
1034 })?;
1035 let trimmed = line.trim();
1036 if trimmed.is_empty() {
1037 continue;
1038 }
1039 let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1041 continue;
1042 };
1043 match value.get("type").and_then(serde_json::Value::as_str) {
1044 Some("event") => {
1045 if let Ok(event) = serde_json::from_value::<ToolEvent>(value["event"].clone()) {
1046 let flow = on_event(&event);
1047 events.push(event);
1048 if flow.is_break() {
1049 aborted = true;
1050 let _ = child.kill();
1051 break;
1052 }
1053 }
1054 }
1055 Some("result") => {
1056 if let Ok(env) = serde_json::from_value::<OhEnvelope>(value["report"].clone()) {
1057 result_env = Some(env);
1058 }
1059 }
1060 _ => {}
1061 }
1062 }
1063
1064 let output = child.wait_with_output().map_err(|e| {
1065 Error::provider("oneharness", format!("oneharness did not complete: {e}"))
1066 })?;
1067
1068 if aborted {
1069 return Ok(RunOutcome {
1073 text: String::new(),
1074 session_id: None,
1075 usage: None,
1076 events,
1077 mock_calls: None,
1078 });
1079 }
1080
1081 let envelope = result_env.ok_or_else(|| {
1082 Error::provider(
1083 "oneharness",
1084 format!(
1085 "oneharness stream produced no result; stderr: {}",
1086 String::from_utf8_lossy(&output.stderr).trim()
1087 ),
1088 )
1089 })?;
1090 let result = envelope
1091 .results
1092 .into_iter()
1093 .next()
1094 .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
1095 if result.status != "ok" {
1096 let detail = result
1097 .error
1098 .filter(|e| !e.is_empty())
1099 .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
1100 .unwrap_or_else(|| format!("status `{}`", result.status));
1101 let context = format!("oneharness:{}", args.harness);
1102 let message = format!("harness run failed: {detail}");
1103 return Err(oh_failure(
1104 context,
1105 message,
1106 result.failure_kind.as_deref(),
1107 &result.status,
1108 ));
1109 }
1110 let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
1111 Error::provider(
1112 format!("oneharness:{}", args.harness),
1113 "harness produced neither extractable text nor stdout",
1114 )
1115 })?;
1116 let events = if events.is_empty() {
1119 result.events.unwrap_or_default()
1120 } else {
1121 events
1122 };
1123 let mock_calls = mock_files.as_ref().map(MockFiles::records).transpose()?;
1124 Ok(RunOutcome {
1125 text,
1126 session_id: result.session_id,
1127 usage: result.usage,
1128 events,
1129 mock_calls,
1130 })
1131 }
1132}
1133
1134impl Provider for OneharnessProvider {
1135 fn respond(
1136 &self,
1137 platform: &str,
1138 model: &str,
1139 skill: &SkillRef<'_>,
1140 messages: &[Message],
1141 session: Option<&str>,
1142 ) -> Result<AssistantTurn> {
1143 self.respond_with_mocks(platform, model, skill, messages, session, None)
1144 }
1145
1146 fn respond_with_mocks(
1147 &self,
1148 platform: &str,
1149 model: &str,
1150 skill: &SkillRef<'_>,
1151 messages: &[Message],
1152 session: Option<&str>,
1153 mocks: Option<&MockPlan<'_>>,
1154 ) -> Result<AssistantTurn> {
1155 let prompt = if session.is_some() {
1160 latest_user_message(messages).unwrap_or_default()
1161 } else {
1162 render_transcript_for_respond(messages)
1163 };
1164 let outcome = self.run(&RunArgs {
1165 harness: platform,
1166 model,
1167 prompt: &prompt,
1168 system: Some(skill.instructions),
1169 resume: session,
1170 mocks,
1171 })?;
1172 Ok(AssistantTurn {
1173 message: outcome.text.trim().to_string(),
1174 done: false,
1175 usage: outcome.usage,
1176 session_id: outcome.session_id,
1177 events: outcome.events,
1178 mock_calls: outcome.mock_calls,
1179 })
1180 }
1181
1182 fn respond_streaming(
1183 &self,
1184 platform: &str,
1185 model: &str,
1186 skill: &SkillRef<'_>,
1187 messages: &[Message],
1188 session: Option<&str>,
1189 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1190 ) -> Result<AssistantTurn> {
1191 self.respond_streaming_with_mocks(platform, model, skill, messages, session, None, on_event)
1192 }
1193
1194 #[allow(clippy::too_many_arguments)]
1197 fn respond_streaming_with_mocks(
1198 &self,
1199 platform: &str,
1200 model: &str,
1201 skill: &SkillRef<'_>,
1202 messages: &[Message],
1203 session: Option<&str>,
1204 mocks: Option<&MockPlan<'_>>,
1205 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1206 ) -> Result<AssistantTurn> {
1207 let prompt = if session.is_some() {
1208 latest_user_message(messages).unwrap_or_default()
1209 } else {
1210 render_transcript_for_respond(messages)
1211 };
1212 let outcome = self.run_streaming(
1213 &RunArgs {
1214 harness: platform,
1215 model,
1216 prompt: &prompt,
1217 system: Some(skill.instructions),
1218 resume: session,
1219 mocks,
1220 },
1221 on_event,
1222 )?;
1223 Ok(AssistantTurn {
1224 message: outcome.text.trim().to_string(),
1225 done: false,
1226 usage: outcome.usage,
1227 session_id: outcome.session_id,
1228 events: outcome.events,
1229 mock_calls: outcome.mock_calls,
1230 })
1231 }
1232
1233 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1234 let prompt = build_user_prompt(persona, messages);
1235 let outcome = self.run(&RunArgs::plain(&self.judge_harness, model, &prompt))?;
1236 Ok(UserTurn {
1237 message: outcome.text.trim().to_string(),
1238 stop: false,
1239 usage: outcome.usage,
1240 })
1241 }
1242
1243 fn judge(
1244 &self,
1245 model: &str,
1246 query: &JudgeQuery<'_>,
1247 messages: &[Message],
1248 ) -> Result<JudgeVerdict> {
1249 let prompt = build_judge_prompt(query, messages);
1250 let outcome = self.run(&RunArgs::plain(&self.judge_harness, model, &prompt))?;
1251 let mut verdict = parse_verdict(query.kind, &outcome.text)?;
1252 verdict.usage = outcome.usage;
1253 Ok(verdict)
1254 }
1255
1256 fn supports_resume(&self, platform: &str) -> bool {
1257 supports_resume(platform)
1258 }
1259}
1260
1261#[must_use]
1266pub fn supports_resume(harness: &str) -> bool {
1267 matches!(harness, "claude-code" | "opencode" | "cursor")
1268}
1269
1270pub struct ApiJudgeProvider {
1292 vendor: ApiVendor,
1293 api_key_env: String,
1294 endpoint: String,
1295 timeout_secs: u64,
1296 curl_bin: String,
1297 strict_json: bool,
1298}
1299
1300const MAX_RETRIES: u32 = 2;
1303
1304#[derive(Debug)]
1306struct ChatOutcome {
1307 text: String,
1308 usage: Option<Usage>,
1309}
1310
1311const JUDGE_SYSTEM: &str =
1314 "Follow the user's instructions exactly and respond with only what they ask for.";
1315
1316impl ApiJudgeProvider {
1317 #[must_use]
1320 pub fn new(config: &ApiJudgeConfig) -> Self {
1321 let api_key_env = config
1322 .api_key_env
1323 .clone()
1324 .unwrap_or_else(|| match config.vendor {
1325 ApiVendor::Anthropic => "ANTHROPIC_API_KEY".to_string(),
1326 ApiVendor::Openai => "OPENAI_API_KEY".to_string(),
1327 });
1328 let endpoint = config
1329 .base_url
1330 .clone()
1331 .unwrap_or_else(|| match config.vendor {
1332 ApiVendor::Anthropic => "https://api.anthropic.com/v1/messages".to_string(),
1333 ApiVendor::Openai => "https://api.openai.com/v1/chat/completions".to_string(),
1334 });
1335 Self {
1336 vendor: config.vendor,
1337 api_key_env,
1338 endpoint,
1339 timeout_secs: config.timeout_secs,
1340 curl_bin: config.curl_bin.clone(),
1341 strict_json: config.strict_json,
1342 }
1343 }
1344
1345 fn chat(
1350 &self,
1351 model: &str,
1352 system: &str,
1353 user: &str,
1354 schema: Option<serde_json::Value>,
1355 ) -> Result<ChatOutcome> {
1356 let key = std::env::var(&self.api_key_env).map_err(|_| {
1357 Error::provider_classified(
1358 "api-judge",
1359 format!("API key env var `{}` is not set", self.api_key_env),
1360 ProviderErrorKind::Auth,
1361 )
1362 })?;
1363 let body = build_chat_body(self.vendor, model, system, user, schema);
1364 let payload = serde_json::to_vec(&body)
1365 .map_err(|e| Error::provider("api-judge", format!("could not encode request: {e}")))?;
1366
1367 let mut attempt = 0;
1368 loop {
1369 let result = self
1370 .run_curl(&key, &payload)
1371 .and_then(|raw| parse_chat_response(self.vendor, &raw));
1372 match result {
1373 Ok(outcome) => return Ok(outcome),
1374 Err(err) if attempt < MAX_RETRIES && is_retryable(&err) => {
1375 attempt += 1;
1376 std::thread::sleep(std::time::Duration::from_millis(500 * (1 << attempt)));
1377 }
1378 Err(err) => return Err(err),
1379 }
1380 }
1381 }
1382
1383 fn headers(&self, key: &str) -> Vec<(String, String)> {
1385 match self.vendor {
1386 ApiVendor::Anthropic => vec![
1387 ("x-api-key".to_string(), key.to_string()),
1388 ("anthropic-version".to_string(), "2023-06-01".to_string()),
1389 ("content-type".to_string(), "application/json".to_string()),
1390 ],
1391 ApiVendor::Openai => vec![
1392 ("authorization".to_string(), format!("Bearer {key}")),
1393 ("content-type".to_string(), "application/json".to_string()),
1394 ],
1395 }
1396 }
1397
1398 fn run_curl(&self, key: &str, body: &[u8]) -> Result<String> {
1401 let path = std::env::temp_dir().join(format!(
1402 "skilltest-judge-{}-{}.cfg",
1403 std::process::id(),
1404 curl_config_nonce()
1405 ));
1406 write_curl_config(&path, &self.endpoint, &self.headers(key), self.timeout_secs)?;
1407 let outcome = self.exec_curl(&path, body);
1408 let _ = std::fs::remove_file(&path);
1410 outcome
1411 }
1412
1413 fn exec_curl(&self, config_path: &std::path::Path, body: &[u8]) -> Result<String> {
1414 let mut child = Command::new(&self.curl_bin)
1415 .arg("--config")
1416 .arg(config_path)
1417 .arg("--data-binary")
1418 .arg("@-")
1419 .stdin(Stdio::piped())
1420 .stdout(Stdio::piped())
1421 .stderr(Stdio::piped())
1422 .spawn()
1423 .map_err(|e| {
1424 Error::provider(
1425 "api-judge",
1426 format!(
1427 "could not run `{}`: {e}. Is curl installed and on PATH?",
1428 self.curl_bin
1429 ),
1430 )
1431 })?;
1432
1433 child
1434 .stdin
1435 .as_mut()
1436 .ok_or_else(|| Error::provider("api-judge", "could not open curl stdin"))?
1437 .write_all(body)
1438 .map_err(|e| Error::provider("api-judge", format!("could not write request: {e}")))?;
1439
1440 let output = child
1441 .wait_with_output()
1442 .map_err(|e| Error::provider("api-judge", format!("curl did not complete: {e}")))?;
1443
1444 if !output.status.success() {
1445 let stderr = String::from_utf8_lossy(&output.stderr);
1446 let message = format!("curl failed ({}): {}", output.status, stderr.trim());
1447 return Err(match output.status.code() {
1450 Some(28) => {
1451 Error::provider_classified("api-judge", message, ProviderErrorKind::Timeout)
1452 }
1453 _ => Error::provider("api-judge", message),
1454 });
1455 }
1456 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1457 }
1458}
1459
1460impl Provider for ApiJudgeProvider {
1461 fn respond(
1462 &self,
1463 _platform: &str,
1464 _model: &str,
1465 _skill: &SkillRef<'_>,
1466 _messages: &[Message],
1467 _session: Option<&str>,
1468 ) -> Result<AssistantTurn> {
1469 Err(Error::provider(
1470 "api-judge",
1471 "the API judge does not run skills; use it as the judge in a SplitProvider",
1472 ))
1473 }
1474
1475 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1476 let prompt = build_user_prompt(persona, messages);
1477 let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, None)?;
1479 Ok(UserTurn {
1480 message: outcome.text.trim().to_string(),
1481 stop: false,
1482 usage: outcome.usage,
1483 })
1484 }
1485
1486 fn judge(
1487 &self,
1488 model: &str,
1489 query: &JudgeQuery<'_>,
1490 messages: &[Message],
1491 ) -> Result<JudgeVerdict> {
1492 let prompt = build_judge_prompt(query, messages);
1493 let schema = self.strict_json.then(|| verdict_schema(query.kind));
1496 let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, schema)?;
1497 let mut verdict = parse_verdict(query.kind, &outcome.text)?;
1498 verdict.usage = outcome.usage;
1499 Ok(verdict)
1500 }
1501}
1502
1503pub struct SplitProvider {
1509 responder: Box<dyn Provider>,
1510 judge: ApiJudgeProvider,
1511}
1512
1513impl SplitProvider {
1514 #[must_use]
1516 pub fn new(responder: Box<dyn Provider>, judge: ApiJudgeProvider) -> Self {
1517 Self { responder, judge }
1518 }
1519}
1520
1521impl Provider for SplitProvider {
1522 fn respond(
1523 &self,
1524 platform: &str,
1525 model: &str,
1526 skill: &SkillRef<'_>,
1527 messages: &[Message],
1528 session: Option<&str>,
1529 ) -> Result<AssistantTurn> {
1530 self.responder
1531 .respond(platform, model, skill, messages, session)
1532 }
1533
1534 fn respond_with_mocks(
1535 &self,
1536 platform: &str,
1537 model: &str,
1538 skill: &SkillRef<'_>,
1539 messages: &[Message],
1540 session: Option<&str>,
1541 mocks: Option<&MockPlan<'_>>,
1542 ) -> Result<AssistantTurn> {
1543 self.responder
1544 .respond_with_mocks(platform, model, skill, messages, session, mocks)
1545 }
1546
1547 #[allow(clippy::too_many_arguments)]
1550 fn respond_streaming_with_mocks(
1551 &self,
1552 platform: &str,
1553 model: &str,
1554 skill: &SkillRef<'_>,
1555 messages: &[Message],
1556 session: Option<&str>,
1557 mocks: Option<&MockPlan<'_>>,
1558 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1559 ) -> Result<AssistantTurn> {
1560 self.responder.respond_streaming_with_mocks(
1561 platform, model, skill, messages, session, mocks, on_event,
1562 )
1563 }
1564
1565 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1566 self.judge.simulate_user(model, persona, messages)
1567 }
1568
1569 fn judge(
1570 &self,
1571 model: &str,
1572 query: &JudgeQuery<'_>,
1573 messages: &[Message],
1574 ) -> Result<JudgeVerdict> {
1575 self.judge.judge(model, query, messages)
1576 }
1577
1578 fn supports_resume(&self, platform: &str) -> bool {
1579 self.responder.supports_resume(platform)
1580 }
1581}
1582
1583fn curl_config_nonce() -> u64 {
1586 use std::sync::atomic::{AtomicU64, Ordering};
1587 static COUNTER: AtomicU64 = AtomicU64::new(0);
1588 COUNTER.fetch_add(1, Ordering::Relaxed)
1589}
1590
1591fn curl_escape(value: &str) -> String {
1593 value.replace('\\', "\\\\").replace('"', "\\\"")
1594}
1595
1596fn write_curl_config(
1600 path: &std::path::Path,
1601 url: &str,
1602 headers: &[(String, String)],
1603 timeout_secs: u64,
1604) -> Result<()> {
1605 let mut config = String::new();
1606 config.push_str(&format!("url = \"{}\"\n", curl_escape(url)));
1607 config.push_str("request = \"POST\"\n");
1608 for (name, value) in headers {
1609 config.push_str(&format!("header = \"{}: {}\"\n", name, curl_escape(value)));
1610 }
1611 config.push_str(&format!("max-time = {timeout_secs}\n"));
1612 config.push_str("silent\nshow-error\n");
1613
1614 let mut options = std::fs::OpenOptions::new();
1615 options.write(true).create(true).truncate(true);
1616 #[cfg(unix)]
1617 {
1618 use std::os::unix::fs::OpenOptionsExt as _;
1619 options.mode(0o600);
1620 }
1621 let mut file = options
1622 .open(path)
1623 .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1624 file.write_all(config.as_bytes())
1625 .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1626 Ok(())
1627}
1628
1629fn verdict_schema(kind: JudgeKind) -> serde_json::Value {
1634 let value_type = match kind {
1635 JudgeKind::Boolean => "boolean",
1636 JudgeKind::Numeric => "number",
1637 };
1638 serde_json::json!({
1639 "type": "object",
1640 "properties": {
1641 "value": { "type": value_type },
1642 "reason": { "type": "string" },
1643 },
1644 "required": ["value", "reason"],
1645 "additionalProperties": false,
1646 })
1647}
1648
1649fn build_chat_body(
1654 vendor: ApiVendor,
1655 model: &str,
1656 system: &str,
1657 user: &str,
1658 schema: Option<serde_json::Value>,
1659) -> serde_json::Value {
1660 match vendor {
1661 ApiVendor::Anthropic => {
1662 let mut body = serde_json::json!({
1663 "model": model,
1664 "max_tokens": 1024,
1665 "system": system,
1666 "messages": [{ "role": "user", "content": user }],
1667 });
1668 if let Some(schema) = schema {
1669 body["output_config"] =
1670 serde_json::json!({ "format": { "type": "json_schema", "schema": schema } });
1671 }
1672 body
1673 }
1674 ApiVendor::Openai => {
1675 let mut body = serde_json::json!({
1676 "model": model,
1677 "max_tokens": 1024,
1678 "messages": [
1679 { "role": "system", "content": system },
1680 { "role": "user", "content": user },
1681 ],
1682 });
1683 if let Some(schema) = schema {
1684 body["response_format"] = serde_json::json!({
1685 "type": "json_schema",
1686 "json_schema": { "name": "verdict", "strict": true, "schema": schema },
1687 });
1688 }
1689 body
1690 }
1691 }
1692}
1693
1694fn is_retryable(err: &Error) -> bool {
1696 matches!(
1697 err,
1698 Error::Provider {
1699 kind: Some(ProviderErrorKind::RateLimit | ProviderErrorKind::Overloaded),
1700 ..
1701 }
1702 )
1703}
1704
1705#[derive(Deserialize)]
1709struct ApiErrorBody {
1710 #[serde(rename = "type", default)]
1711 kind: Option<String>,
1712 #[serde(default)]
1713 message: Option<String>,
1714}
1715
1716#[derive(Deserialize)]
1717struct AnthropicBlock {
1718 #[serde(rename = "type")]
1719 kind: String,
1720 #[serde(default)]
1721 text: Option<String>,
1722}
1723
1724#[derive(Deserialize)]
1725struct AnthropicUsage {
1726 #[serde(default)]
1727 input_tokens: Option<u64>,
1728 #[serde(default)]
1729 output_tokens: Option<u64>,
1730}
1731
1732#[derive(Deserialize)]
1733struct AnthropicResponse {
1734 #[serde(default)]
1735 content: Vec<AnthropicBlock>,
1736 #[serde(default)]
1737 usage: Option<AnthropicUsage>,
1738 #[serde(default)]
1739 stop_reason: Option<String>,
1740 #[serde(default)]
1741 error: Option<ApiErrorBody>,
1742}
1743
1744#[derive(Deserialize)]
1745struct OpenAiMessage {
1746 #[serde(default)]
1747 content: Option<String>,
1748}
1749
1750#[derive(Deserialize)]
1751struct OpenAiChoice {
1752 #[serde(default)]
1753 message: Option<OpenAiMessage>,
1754}
1755
1756#[derive(Deserialize)]
1757struct OpenAiUsage {
1758 #[serde(default)]
1759 prompt_tokens: Option<u64>,
1760 #[serde(default)]
1761 completion_tokens: Option<u64>,
1762}
1763
1764#[derive(Deserialize)]
1765struct OpenAiResponse {
1766 #[serde(default)]
1767 choices: Vec<OpenAiChoice>,
1768 #[serde(default)]
1769 usage: Option<OpenAiUsage>,
1770 #[serde(default)]
1771 error: Option<ApiErrorBody>,
1772}
1773
1774fn classify_api_error(kind: Option<&str>) -> Option<ProviderErrorKind> {
1778 match kind? {
1779 "authentication_error" | "invalid_api_key" | "permission_error" => {
1780 Some(ProviderErrorKind::Auth)
1781 }
1782 "rate_limit_error" | "rate_limit_exceeded" => Some(ProviderErrorKind::RateLimit),
1783 "insufficient_quota" | "billing_error" => Some(ProviderErrorKind::Quota),
1784 "not_found_error" => Some(ProviderErrorKind::ModelNotFound),
1785 "overloaded_error" | "api_error" | "server_error" | "service_unavailable" => {
1788 Some(ProviderErrorKind::Overloaded)
1789 }
1790 _ => None,
1791 }
1792}
1793
1794fn api_error(err: ApiErrorBody) -> Error {
1795 let message = err
1796 .message
1797 .unwrap_or_else(|| "API returned an error".to_string());
1798 match classify_api_error(err.kind.as_deref()) {
1799 Some(kind) => Error::provider_classified("api-judge", message, kind),
1800 None => Error::provider("api-judge", message),
1801 }
1802}
1803
1804fn truncate_for_error(raw: &str) -> String {
1806 raw.chars().take(500).collect()
1807}
1808
1809fn parse_chat_response(vendor: ApiVendor, raw: &str) -> Result<ChatOutcome> {
1811 match vendor {
1812 ApiVendor::Anthropic => {
1813 let resp: AnthropicResponse = serde_json::from_str(raw.trim()).map_err(|e| {
1814 Error::provider(
1815 "api-judge",
1816 format!(
1817 "could not parse API response: {e}; got: {}",
1818 truncate_for_error(raw)
1819 ),
1820 )
1821 })?;
1822 if let Some(err) = resp.error {
1823 return Err(api_error(err));
1824 }
1825 let text = resp
1826 .content
1827 .iter()
1828 .filter(|b| b.kind == "text")
1829 .filter_map(|b| b.text.as_deref())
1830 .collect::<String>();
1831 if text.trim().is_empty() {
1832 return Err(Error::provider(
1833 "api-judge",
1834 format!(
1835 "judge returned no text (stop_reason: {:?})",
1836 resp.stop_reason
1837 ),
1838 ));
1839 }
1840 let usage = resp.usage.map(|u| Usage {
1841 input_tokens: u.input_tokens,
1842 output_tokens: u.output_tokens,
1843 cost_usd: None,
1844 });
1845 Ok(ChatOutcome { text, usage })
1846 }
1847 ApiVendor::Openai => {
1848 let resp: OpenAiResponse = serde_json::from_str(raw.trim()).map_err(|e| {
1849 Error::provider(
1850 "api-judge",
1851 format!(
1852 "could not parse API response: {e}; got: {}",
1853 truncate_for_error(raw)
1854 ),
1855 )
1856 })?;
1857 if let Some(err) = resp.error {
1858 return Err(api_error(err));
1859 }
1860 let text = resp
1861 .choices
1862 .into_iter()
1863 .next()
1864 .and_then(|c| c.message)
1865 .and_then(|m| m.content)
1866 .unwrap_or_default();
1867 if text.trim().is_empty() {
1868 return Err(Error::provider("api-judge", "judge returned no text"));
1869 }
1870 let usage = resp.usage.map(|u| Usage {
1871 input_tokens: u.prompt_tokens,
1872 output_tokens: u.completion_tokens,
1873 cost_usd: None,
1874 });
1875 Ok(ChatOutcome { text, usage })
1876 }
1877 }
1878}
1879
1880fn render_transcript(messages: &[Message]) -> String {
1884 messages
1885 .iter()
1886 .map(|m| {
1887 let role = match m.role {
1888 Role::User => "User",
1889 Role::Assistant => "Assistant",
1890 Role::System => "System",
1891 };
1892 format!("{role}: {}", m.content)
1893 })
1894 .collect::<Vec<_>>()
1895 .join("\n")
1896}
1897
1898fn render_transcript_for_respond(messages: &[Message]) -> String {
1902 format!(
1903 "Conversation so far (most recent last):\n{}\n\n\
1904 Write only the assistant's next reply, following your system \
1905 instructions. Output the reply text and nothing else.",
1906 render_transcript(messages),
1907 )
1908}
1909
1910fn latest_user_message(messages: &[Message]) -> Option<String> {
1913 messages
1914 .iter()
1915 .rev()
1916 .find(|m| m.role == Role::User)
1917 .map(|m| m.content.clone())
1918}
1919
1920fn build_user_prompt(persona: &str, messages: &[Message]) -> String {
1921 format!(
1922 "You are role-playing the USER in a conversation with an AI assistant. \
1923 Stay in character:\n\n{persona}\n\n\
1924 Conversation so far (most recent last):\n{transcript}\n\n\
1925 Write only the user's next message. Output the message text and nothing \
1926 else.",
1927 transcript = render_transcript(messages),
1928 )
1929}
1930
1931fn build_judge_prompt(query: &JudgeQuery<'_>, messages: &[Message]) -> String {
1932 let transcript = render_transcript(messages);
1933 match query.kind {
1934 JudgeKind::Boolean => format!(
1935 "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
1936 Criterion: {criterion}\n\n\
1937 Transcript:\n{transcript}\n\n\
1938 Decide whether the criterion is satisfied. Respond with ONLY a \
1939 single-line JSON object and nothing else:\n\
1940 {{\"value\": true or false, \"reason\": \"<one short sentence>\"}}",
1941 criterion = query.criterion,
1942 ),
1943 JudgeKind::Numeric => {
1944 let (min, max) = query.scale.unwrap_or((0.0, 10.0));
1945 format!(
1946 "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
1947 Criterion: {criterion}\n\n\
1948 Transcript:\n{transcript}\n\n\
1949 Score how well the criterion is satisfied on a scale from {min} to \
1950 {max} (inclusive). Respond with ONLY a single-line JSON object and \
1951 nothing else:\n\
1952 {{\"value\": <number between {min} and {max}>, \"reason\": \"<one short sentence>\"}}",
1953 criterion = query.criterion,
1954 )
1955 }
1956 }
1957}
1958
1959fn extract_json_object(text: &str) -> Option<&str> {
1962 let start = text.find('{')?;
1963 let end = text.rfind('}')?;
1964 if end > start {
1965 Some(&text[start..=end])
1966 } else {
1967 None
1968 }
1969}
1970
1971fn parse_verdict(kind: JudgeKind, text: &str) -> Result<JudgeVerdict> {
1972 let json = extract_json_object(text).ok_or_else(|| {
1973 Error::provider(
1974 "oneharness:judge",
1975 format!("judge did not return a JSON object; got: {text}"),
1976 )
1977 })?;
1978 let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
1979 Error::provider(
1980 "oneharness:judge",
1981 format!("judge verdict was not valid JSON: {e}; got: {json}"),
1982 )
1983 })?;
1984 let reason = value
1985 .get("reason")
1986 .and_then(serde_json::Value::as_str)
1987 .unwrap_or("")
1988 .to_string();
1989 let raw = value
1990 .get("value")
1991 .ok_or_else(|| Error::provider("oneharness:judge", "judge verdict has no `value` field"))?;
1992
1993 let verdict_value = match kind {
1994 JudgeKind::Boolean => JudgeValue::Bool(raw.as_bool().ok_or_else(|| {
1995 Error::provider(
1996 "oneharness:judge",
1997 format!("boolean judge `value` was not a bool: {raw}"),
1998 )
1999 })?),
2000 JudgeKind::Numeric => JudgeValue::Number(raw.as_f64().ok_or_else(|| {
2001 Error::provider(
2002 "oneharness:judge",
2003 format!("numeric judge `value` was not a number: {raw}"),
2004 )
2005 })?),
2006 };
2007
2008 Ok(JudgeVerdict {
2009 value: verdict_value,
2010 reason,
2011 usage: None,
2012 })
2013}
2014
2015#[cfg(test)]
2016mod tests {
2017 use super::*;
2018
2019 #[test]
2020 fn empty_argv_is_rejected() {
2021 assert!(CommandProvider::new(vec![]).is_err());
2022 }
2023
2024 #[test]
2025 fn request_serializes_with_op_tag() {
2026 let req = Request::Judge {
2027 model: "m",
2028 kind: "numeric",
2029 criterion: "polite",
2030 min: Some(0.0),
2031 max: Some(10.0),
2032 messages: &[],
2033 };
2034 let json = serde_json::to_string(&req).unwrap();
2035 assert!(json.contains("\"op\":\"judge\""));
2036 assert!(json.contains("\"kind\":\"numeric\""));
2037 }
2038
2039 #[test]
2040 fn respond_no_session_inlines_transcript_but_not_skill() {
2041 let messages = [
2044 Message::user("Hi"),
2045 Message::assistant("Hello"),
2046 Message::user("Again?"),
2047 ];
2048 let prompt = render_transcript_for_respond(&messages);
2049 assert!(prompt.contains("User: Hi"));
2050 assert!(prompt.contains("Assistant: Hello"));
2051 assert!(prompt.contains("User: Again?"));
2052 assert!(!prompt.contains("SKILL"));
2054 }
2055
2056 #[test]
2057 fn respond_with_session_sends_only_latest_user_message() {
2058 let messages = [
2059 Message::user("Hi"),
2060 Message::assistant("Hello"),
2061 Message::user("Again?"),
2062 ];
2063 assert_eq!(latest_user_message(&messages).as_deref(), Some("Again?"));
2064 }
2065
2066 #[test]
2067 fn extracts_json_from_fenced_or_prose_text() {
2068 assert_eq!(
2069 extract_json_object("```json\n{\"value\": true}\n```"),
2070 Some("{\"value\": true}")
2071 );
2072 assert_eq!(
2073 extract_json_object("Sure! {\"value\": 8, \"reason\": \"x\"} done"),
2074 Some("{\"value\": 8, \"reason\": \"x\"}")
2075 );
2076 assert_eq!(extract_json_object("no json here"), None);
2077 }
2078
2079 #[test]
2080 fn parses_boolean_and_numeric_verdicts() {
2081 let b = parse_verdict(JudgeKind::Boolean, "{\"value\": true, \"reason\": \"ok\"}").unwrap();
2082 assert!(matches!(b.value, JudgeValue::Bool(true)));
2083 assert_eq!(b.reason, "ok");
2084
2085 let n =
2086 parse_verdict(JudgeKind::Numeric, "{\"value\": 8.5, \"reason\": \"good\"}").unwrap();
2087 assert!(matches!(n.value, JudgeValue::Number(v) if (v - 8.5).abs() < f64::EPSILON));
2088 }
2089
2090 #[test]
2091 fn verdict_with_wrong_value_type_errors() {
2092 assert!(parse_verdict(JudgeKind::Boolean, "{\"value\": 3}").is_err());
2093 assert!(parse_verdict(JudgeKind::Numeric, "{\"value\": true}").is_err());
2094 assert!(parse_verdict(JudgeKind::Boolean, "no json").is_err());
2095 }
2096
2097 #[test]
2098 fn usage_accumulates_independently_per_field() {
2099 let mut total = Usage::default();
2100 total.add(&Usage {
2101 input_tokens: Some(10),
2102 output_tokens: None,
2103 cost_usd: Some(0.01),
2104 });
2105 total.add(&Usage {
2106 input_tokens: Some(5),
2107 output_tokens: Some(3),
2108 cost_usd: None,
2109 });
2110 assert_eq!(total.input_tokens, Some(15));
2111 assert_eq!(total.output_tokens, Some(3));
2112 assert!((total.cost_usd.unwrap() - 0.01).abs() < f64::EPSILON);
2113 assert!(!total.is_empty());
2114 }
2115
2116 #[test]
2117 fn reply_text_prefers_extracted_then_falls_back_to_stdout() {
2118 assert_eq!(
2120 select_reply_text(Some("clean reply".into()), "raw noise"),
2121 Some("clean reply".into())
2122 );
2123 assert_eq!(
2126 select_reply_text(None, "{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}"),
2127 Some("{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}".into())
2128 );
2129 assert_eq!(
2130 select_reply_text(Some(" ".into()), "fallback"),
2131 Some("fallback".into())
2132 );
2133 assert_eq!(select_reply_text(None, " \n"), None);
2135 assert_eq!(select_reply_text(Some(String::new()), ""), None);
2136 }
2137
2138 #[test]
2139 fn supports_resume_covers_known_harnesses() {
2140 assert!(supports_resume("claude-code"));
2141 assert!(supports_resume("opencode"));
2142 assert!(supports_resume("cursor"));
2143 assert!(!supports_resume("codex"));
2144 assert!(!supports_resume("goose"));
2145 }
2146
2147 fn api_config(vendor: ApiVendor) -> ApiJudgeConfig {
2148 ApiJudgeConfig {
2149 vendor,
2150 api_key_env: None,
2151 base_url: None,
2152 timeout_secs: 60,
2153 curl_bin: "curl".to_string(),
2154 strict_json: true,
2155 }
2156 }
2157
2158 #[test]
2159 fn api_judge_resolves_vendor_defaults() {
2160 let anthropic = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
2161 assert_eq!(anthropic.api_key_env, "ANTHROPIC_API_KEY");
2162 assert_eq!(anthropic.endpoint, "https://api.anthropic.com/v1/messages");
2163
2164 let openai = ApiJudgeProvider::new(&api_config(ApiVendor::Openai));
2165 assert_eq!(openai.api_key_env, "OPENAI_API_KEY");
2166 assert_eq!(
2167 openai.endpoint,
2168 "https://api.openai.com/v1/chat/completions"
2169 );
2170 }
2171
2172 #[test]
2173 fn api_judge_honors_overrides() {
2174 let provider = ApiJudgeProvider::new(&ApiJudgeConfig {
2175 vendor: ApiVendor::Openai,
2176 api_key_env: Some("MY_KEY".to_string()),
2177 base_url: Some("https://proxy.example/v1/chat/completions".to_string()),
2178 timeout_secs: 5,
2179 curl_bin: "curl".to_string(),
2180 strict_json: true,
2181 });
2182 assert_eq!(provider.api_key_env, "MY_KEY");
2183 assert_eq!(
2184 provider.endpoint,
2185 "https://proxy.example/v1/chat/completions"
2186 );
2187 }
2188
2189 #[test]
2190 fn build_chat_body_shapes_per_vendor() {
2191 let anthropic = build_chat_body(ApiVendor::Anthropic, "claude-x", "sys", "hi", None);
2192 assert_eq!(anthropic["model"], "claude-x");
2193 assert_eq!(anthropic["system"], "sys");
2194 assert_eq!(anthropic["messages"][0]["role"], "user");
2195 assert_eq!(anthropic["messages"].as_array().unwrap().len(), 1);
2197 assert!(anthropic.get("output_config").is_none());
2199
2200 let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", None);
2201 assert_eq!(openai["messages"][0]["role"], "system");
2202 assert_eq!(openai["messages"][1]["role"], "user");
2203 assert!(openai.get("system").is_none());
2204 assert!(openai.get("response_format").is_none());
2205 }
2206
2207 #[test]
2208 fn build_chat_body_attaches_strict_schema_per_vendor() {
2209 let schema = verdict_schema(JudgeKind::Boolean);
2210 let anthropic = build_chat_body(
2211 ApiVendor::Anthropic,
2212 "claude-x",
2213 "sys",
2214 "hi",
2215 Some(schema.clone()),
2216 );
2217 assert_eq!(anthropic["output_config"]["format"]["type"], "json_schema");
2219 assert_eq!(
2220 anthropic["output_config"]["format"]["schema"]["properties"]["value"]["type"],
2221 "boolean"
2222 );
2223
2224 let numeric = verdict_schema(JudgeKind::Numeric);
2225 let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", Some(numeric));
2226 assert_eq!(openai["response_format"]["type"], "json_schema");
2228 assert_eq!(openai["response_format"]["json_schema"]["strict"], true);
2229 assert_eq!(
2230 openai["response_format"]["json_schema"]["schema"]["properties"]["value"]["type"],
2231 "number"
2232 );
2233 }
2234
2235 #[test]
2236 fn verdict_schema_requires_value_and_reason_with_no_extras() {
2237 let schema = verdict_schema(JudgeKind::Numeric);
2238 assert_eq!(schema["additionalProperties"], false);
2239 let required: Vec<&str> = schema["required"]
2240 .as_array()
2241 .unwrap()
2242 .iter()
2243 .map(|v| v.as_str().unwrap())
2244 .collect();
2245 assert_eq!(required, ["value", "reason"]);
2246 }
2247
2248 #[test]
2249 fn parses_anthropic_success_with_usage() {
2250 let raw = r#"{"content":[{"type":"text","text":"{\"value\": true}"}],
2251 "stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":3}}"#;
2252 let outcome = parse_chat_response(ApiVendor::Anthropic, raw).unwrap();
2253 assert_eq!(outcome.text, "{\"value\": true}");
2254 let usage = outcome.usage.unwrap();
2255 assert_eq!(usage.input_tokens, Some(12));
2256 assert_eq!(usage.output_tokens, Some(3));
2257 assert!(usage.cost_usd.is_none());
2258 }
2259
2260 #[test]
2261 fn parses_openai_success_with_usage() {
2262 let raw = r#"{"choices":[{"message":{"content":"{\"value\": 8}"}}],
2263 "usage":{"prompt_tokens":20,"completion_tokens":4}}"#;
2264 let outcome = parse_chat_response(ApiVendor::Openai, raw).unwrap();
2265 assert_eq!(outcome.text, "{\"value\": 8}");
2266 let usage = outcome.usage.unwrap();
2267 assert_eq!(usage.input_tokens, Some(20));
2268 assert_eq!(usage.output_tokens, Some(4));
2269 }
2270
2271 #[test]
2272 fn parses_and_classifies_api_errors() {
2273 let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
2274 let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
2275 assert!(matches!(
2276 err,
2277 Error::Provider {
2278 kind: Some(ProviderErrorKind::Auth),
2279 ..
2280 }
2281 ));
2282
2283 let rate = r#"{"error":{"type":"rate_limit_exceeded","message":"slow down"}}"#;
2284 let err = parse_chat_response(ApiVendor::Openai, rate).unwrap_err();
2285 assert!(matches!(
2286 err,
2287 Error::Provider {
2288 kind: Some(ProviderErrorKind::RateLimit),
2289 ..
2290 }
2291 ));
2292 }
2293
2294 #[test]
2295 fn empty_reply_is_an_error() {
2296 let raw = r#"{"content":[],"stop_reason":"refusal"}"#;
2297 assert!(parse_chat_response(ApiVendor::Anthropic, raw).is_err());
2298 }
2299
2300 #[test]
2301 fn classify_api_error_maps_known_kinds() {
2302 assert_eq!(
2303 classify_api_error(Some("invalid_api_key")),
2304 Some(ProviderErrorKind::Auth)
2305 );
2306 assert_eq!(
2307 classify_api_error(Some("insufficient_quota")),
2308 Some(ProviderErrorKind::Quota)
2309 );
2310 assert_eq!(
2311 classify_api_error(Some("not_found_error")),
2312 Some(ProviderErrorKind::ModelNotFound)
2313 );
2314 assert_eq!(
2315 classify_api_error(Some("overloaded_error")),
2316 Some(ProviderErrorKind::Overloaded)
2317 );
2318 assert_eq!(classify_api_error(Some("something_else")), None);
2319 assert_eq!(classify_api_error(None), None);
2320 }
2321
2322 #[test]
2323 fn retryable_covers_transient_errors_only() {
2324 let overloaded = r#"{"error":{"type":"overloaded_error","message":"busy"}}"#;
2325 let err = parse_chat_response(ApiVendor::Anthropic, overloaded).unwrap_err();
2326 assert!(is_retryable(&err), "overload should retry");
2327
2328 let rate = r#"{"error":{"type":"rate_limit_error","message":"slow"}}"#;
2329 let err = parse_chat_response(ApiVendor::Anthropic, rate).unwrap_err();
2330 assert!(is_retryable(&err), "rate limit should retry");
2331
2332 let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
2333 let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
2334 assert!(!is_retryable(&err), "auth must not retry");
2335 }
2336
2337 #[test]
2338 fn curl_escape_handles_quotes_and_backslashes() {
2339 assert_eq!(curl_escape(r#"a"b\c"#), r#"a\"b\\c"#);
2340 }
2341
2342 struct StubResponder;
2345
2346 impl Provider for StubResponder {
2347 fn respond(
2348 &self,
2349 _platform: &str,
2350 _model: &str,
2351 _skill: &SkillRef<'_>,
2352 _messages: &[Message],
2353 _session: Option<&str>,
2354 ) -> Result<AssistantTurn> {
2355 Ok(AssistantTurn {
2356 message: "stub reply".to_string(),
2357 ..Default::default()
2358 })
2359 }
2360
2361 fn simulate_user(
2362 &self,
2363 _model: &str,
2364 _persona: &str,
2365 _messages: &[Message],
2366 ) -> Result<UserTurn> {
2367 unreachable!("split provider routes user simulation to the judge")
2368 }
2369
2370 fn judge(
2371 &self,
2372 _model: &str,
2373 _query: &JudgeQuery<'_>,
2374 _messages: &[Message],
2375 ) -> Result<JudgeVerdict> {
2376 unreachable!("split provider routes judging to the judge")
2377 }
2378
2379 fn supports_resume(&self, platform: &str) -> bool {
2380 platform == "claude-code"
2381 }
2382 }
2383
2384 #[test]
2385 fn split_provider_delegates_respond_and_resume() {
2386 let split = SplitProvider::new(
2387 Box::new(StubResponder),
2388 ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic)),
2389 );
2390 assert!(split.supports_resume("claude-code"));
2392 assert!(!split.supports_resume("codex"));
2393 let skill = SkillRef {
2394 name: "s",
2395 dir: "/tmp/s",
2396 instructions: "do things",
2397 };
2398 let turn = split
2399 .respond("claude-code", "m", &skill, &[], None)
2400 .unwrap();
2401 assert_eq!(turn.message, "stub reply");
2402 }
2403
2404 #[test]
2405 fn api_judge_does_not_run_skills() {
2406 let provider = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
2407 let skill = SkillRef {
2408 name: "s",
2409 dir: "/tmp/s",
2410 instructions: "x",
2411 };
2412 assert!(provider.respond("p", "m", &skill, &[], None).is_err());
2413 }
2414
2415 #[cfg(unix)]
2424 mod subprocess {
2425 use super::*;
2428 use std::os::unix::fs::PermissionsExt as _;
2429 use std::path::PathBuf;
2430
2431 fn script(tag: &str, body: &str) -> PathBuf {
2434 use std::sync::atomic::{AtomicU64, Ordering};
2435 static N: AtomicU64 = AtomicU64::new(0);
2436 let dir = std::env::temp_dir().join(format!(
2437 "skilltest-prov-{}-{tag}-{}",
2438 std::process::id(),
2439 N.fetch_add(1, Ordering::Relaxed)
2440 ));
2441 std::fs::create_dir_all(&dir).unwrap();
2442 let path = dir.join("script.sh");
2443 let mut f = std::fs::File::create(&path).unwrap();
2444 f.write_all(format!("#!/bin/sh\n{body}").as_bytes())
2445 .unwrap();
2446 let mut perms = std::fs::metadata(&path).unwrap().permissions();
2447 perms.set_mode(0o755);
2448 std::fs::set_permissions(&path, perms).unwrap();
2449 path
2450 }
2451
2452 fn skill_ref() -> SkillRef<'static> {
2453 SkillRef {
2454 name: "greeter",
2455 dir: "/tmp/greeter",
2456 instructions: "Be nice.",
2457 }
2458 }
2459
2460 #[test]
2463 fn command_provider_respond_parses_response() {
2464 let bin = script(
2466 "respond",
2467 "cat >/dev/null\necho '{\"message\":\"hi there\",\"done\":true,\
2468 \"usage\":{\"input_tokens\":4,\"output_tokens\":2},\"session_id\":\"s1\"}'\n",
2469 );
2470 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2471 let turn = provider
2472 .respond("demo", "fake", &skill_ref(), &[Message::user("hi")], None)
2473 .unwrap();
2474 assert_eq!(turn.message, "hi there");
2475 assert!(turn.done);
2476 assert_eq!(turn.session_id.as_deref(), Some("s1"));
2477 assert_eq!(turn.usage.unwrap().input_tokens, Some(4));
2478 }
2479
2480 #[test]
2481 fn command_provider_user_and_judge_parse_responses() {
2482 let user_bin = script(
2483 "user",
2484 "cat >/dev/null\necho '{\"message\":\"more please\",\"stop\":true}'\n",
2485 );
2486 let user_provider =
2487 CommandProvider::new(vec![user_bin.to_string_lossy().into_owned()]).unwrap();
2488 let user = user_provider.simulate_user("m", "persona", &[]).unwrap();
2489 assert_eq!(user.message, "more please");
2490 assert!(user.stop);
2491
2492 let judge_bin = script(
2493 "judge",
2494 "cat >/dev/null\necho '{\"value\":7.5,\"reason\":\"ok\"}'\n",
2495 );
2496 let judge_provider =
2497 CommandProvider::new(vec![judge_bin.to_string_lossy().into_owned()]).unwrap();
2498 let query = JudgeQuery {
2499 kind: JudgeKind::Numeric,
2500 criterion: "polite",
2501 scale: Some((0.0, 10.0)),
2502 };
2503 let verdict = judge_provider.judge("m", &query, &[]).unwrap();
2504 assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 7.5).abs() < 1e-9));
2505 assert_eq!(verdict.reason, "ok");
2506 }
2507
2508 #[test]
2509 fn command_provider_surfaces_nonzero_exit() {
2510 let bin = script("fail", "cat >/dev/null\necho 'boom' 1>&2\nexit 2\n");
2511 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2512 let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2513 let msg = err.to_string();
2514 assert!(msg.contains("provider exited"), "got: {msg}");
2515 assert!(msg.contains("boom"), "stderr is surfaced: {msg}");
2516 }
2517
2518 #[test]
2519 fn command_provider_rejects_empty_and_bad_output() {
2520 let empty = script("empty", "cat >/dev/null\n");
2521 let provider =
2522 CommandProvider::new(vec![empty.to_string_lossy().into_owned()]).unwrap();
2523 assert!(provider
2524 .judge(
2525 "m",
2526 &JudgeQuery {
2527 kind: JudgeKind::Boolean,
2528 criterion: "x",
2529 scale: None
2530 },
2531 &[],
2532 )
2533 .unwrap_err()
2534 .to_string()
2535 .contains("no output"));
2536
2537 let garbage = script("garbage", "cat >/dev/null\necho 'not json'\n");
2538 let provider =
2539 CommandProvider::new(vec![garbage.to_string_lossy().into_owned()]).unwrap();
2540 assert!(provider
2541 .respond("demo", "m", &skill_ref(), &[], None)
2542 .unwrap_err()
2543 .to_string()
2544 .contains("not valid JSON"));
2545 }
2546
2547 #[test]
2548 fn command_provider_reports_missing_binary() {
2549 let provider =
2550 CommandProvider::new(vec!["/no/such/skilltest-provider-binary".to_string()])
2551 .unwrap();
2552 let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2553 assert!(err.to_string().contains("could not run provider"));
2554 }
2555
2556 #[test]
2557 fn command_provider_session_is_threaded_into_request() {
2558 let dir =
2561 std::env::temp_dir().join(format!("skilltest-prov-sess-{}", std::process::id()));
2562 std::fs::create_dir_all(&dir).unwrap();
2563 let seen = dir.join("seen.json");
2564 let bin = script(
2565 "session",
2566 &format!(
2567 "cat > '{}'\necho '{{\"message\":\"ok\"}}'\n",
2568 seen.display()
2569 ),
2570 );
2571 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2572 provider
2573 .respond(
2574 "demo",
2575 "m",
2576 &skill_ref(),
2577 &[Message::user("hi")],
2578 Some("session-xyz"),
2579 )
2580 .unwrap();
2581 let request = std::fs::read_to_string(&seen).unwrap();
2582 assert!(
2583 request.contains("\"session\":\"session-xyz\""),
2584 "got: {request}"
2585 );
2586 assert!(request.contains("\"op\":\"respond\""));
2587 }
2588
2589 #[test]
2590 fn command_provider_threads_mocks_and_parses_records() {
2591 let dir =
2593 std::env::temp_dir().join(format!("skilltest-prov-mocks-{}", std::process::id()));
2594 std::fs::create_dir_all(&dir).unwrap();
2595 let seen = dir.join("seen.json");
2596 let bin = script(
2597 "mocks",
2598 &format!(
2599 "cat > '{}'\necho '{{\"message\":\"ok\",\"mock_calls\":[{{\"tool\":\"bash\", \"input\":{{\"command\":\"git push\"}},\"action\":\"stub\",\"rule\":0}}]}}'\n",
2600 seen.display()
2601 ),
2602 );
2603 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2604 let rules = serde_json::json!({ "rules": [] });
2605 let plan = MockPlan {
2606 rules: Some(&rules),
2607 };
2608 let turn = provider
2609 .respond_with_mocks("demo", "m", &skill_ref(), &[], None, Some(&plan))
2610 .unwrap();
2611 let records = turn.mock_calls.expect("channel was on");
2612 assert_eq!(records.len(), 1);
2613 assert_eq!(records[0].action, "stub");
2614 assert_eq!(records[0].rule, Some(0));
2615 let request = std::fs::read_to_string(&seen).unwrap();
2617 assert!(
2618 request.contains("\"mocks\":{\"rules\":{\"rules\":[]}}"),
2619 "got: {request}"
2620 );
2621 }
2622
2623 #[test]
2624 fn command_provider_ignoring_mocks_is_loud() {
2625 let bin = script(
2628 "mocks-ignored",
2629 "cat >/dev/null\necho '{\"message\":\"ok\"}'\n",
2630 );
2631 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2632 let plan = MockPlan { rules: None };
2633 let err = provider
2634 .respond_with_mocks("demo", "m", &skill_ref(), &[], None, Some(&plan))
2635 .unwrap_err();
2636 assert!(
2637 err.to_string().contains("ignored the request's `mocks`"),
2638 "{err}"
2639 );
2640 }
2641
2642 #[test]
2643 fn default_provider_rejects_mocks_loudly() {
2644 let plan = MockPlan { rules: None };
2647 let err = super::StubResponder
2648 .respond_with_mocks("p", "m", &skill_ref(), &[], None, Some(&plan))
2649 .unwrap_err();
2650 assert!(
2651 err.to_string().contains("does not support tool mocking"),
2652 "{err}"
2653 );
2654 let turn = super::StubResponder
2656 .respond_with_mocks("p", "m", &skill_ref(), &[], None, None)
2657 .unwrap();
2658 assert_eq!(turn.message, "stub reply");
2659 }
2660
2661 #[test]
2662 fn default_streaming_rejects_mocks_and_delegates_without() {
2663 let plan = MockPlan { rules: None };
2666 let err = super::StubResponder
2667 .respond_streaming_with_mocks(
2668 "p",
2669 "m",
2670 &skill_ref(),
2671 &[],
2672 None,
2673 Some(&plan),
2674 &mut |_| ControlFlow::Continue(()),
2675 )
2676 .unwrap_err();
2677 assert!(err.to_string().contains("does not support tool mocking"));
2678 let turn = super::StubResponder
2679 .respond_streaming_with_mocks("p", "m", &skill_ref(), &[], None, None, &mut |_| {
2680 ControlFlow::Continue(())
2681 })
2682 .unwrap();
2683 assert_eq!(turn.message, "stub reply");
2684 }
2685
2686 #[test]
2687 fn command_provider_streaming_with_mocks_replays_events() {
2688 let bin = script(
2691 "mocks-stream",
2692 "cat >/dev/null\necho '{\"message\":\"ok\",\"events\":[{\"kind\":\"tool_call\",\"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":0}],\"mock_calls\":[]}'\n",
2693 );
2694 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2695 let plan = MockPlan { rules: None };
2696 let mut seen = 0usize;
2697 let turn = provider
2698 .respond_streaming_with_mocks(
2699 "demo",
2700 "m",
2701 &skill_ref(),
2702 &[],
2703 None,
2704 Some(&plan),
2705 &mut |event| {
2706 seen += 1;
2707 assert_eq!(event.name.as_deref(), Some("bash"));
2708 ControlFlow::Break(())
2709 },
2710 )
2711 .unwrap();
2712 assert_eq!(seen, 1);
2713 assert_eq!(turn.mock_calls, Some(Vec::new()));
2714 }
2715
2716 fn oh_provider(bin: PathBuf) -> OneharnessProvider {
2719 OneharnessProvider::new(&OneharnessConfig {
2720 bin: bin.to_string_lossy().into_owned(),
2721 judge_harness: "claude-code".to_string(),
2722 timeout_secs: 30,
2723 })
2724 }
2725
2726 #[test]
2727 fn oneharness_respond_extracts_text_and_session() {
2728 let bin = script(
2729 "oh-ok",
2730 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2731 \"text\":\" hello back \",\"session_id\":\"oh1\",\
2732 \"usage\":{\"input_tokens\":5}}]}'\n",
2733 );
2734 let turn = oh_provider(bin)
2735 .respond(
2736 "claude-code",
2737 "sonnet",
2738 &skill_ref(),
2739 &[Message::user("hi")],
2740 None,
2741 )
2742 .unwrap();
2743 assert_eq!(turn.message, "hello back");
2744 assert_eq!(turn.session_id.as_deref(), Some("oh1"));
2745 assert_eq!(turn.usage.unwrap().input_tokens, Some(5));
2746 assert!(turn.events.is_empty());
2747 }
2748
2749 #[test]
2750 fn oneharness_respond_surfaces_normalized_events() {
2751 let bin = script(
2755 "oh-events",
2756 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2757 \"text\":\"done\",\"events\":[{\"kind\":\"tool_call\",\"name\":\"bash\",\
2758 \"input\":{\"command\":\"git commit -m x\"},\"output\":\"ok\",\"index\":0}]}]}'\n",
2759 );
2760 let turn = oh_provider(bin)
2761 .respond(
2762 "claude-code",
2763 "sonnet",
2764 &skill_ref(),
2765 &[Message::user("hi")],
2766 None,
2767 )
2768 .unwrap();
2769 assert_eq!(turn.events.len(), 1);
2770 assert_eq!(turn.events[0].kind, "tool_call");
2771 assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
2772 assert_eq!(
2773 turn.events[0].input,
2774 Some(serde_json::json!({"command": "git commit -m x"}))
2775 );
2776 assert_eq!(turn.events[0].output.as_deref(), Some("ok"));
2777 }
2778
2779 #[test]
2780 fn oneharness_respond_events_absent_is_empty_not_error() {
2781 let bin = script(
2784 "oh-noevents",
2785 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
2786 );
2787 let turn = oh_provider(bin)
2788 .respond("goose", "m", &skill_ref(), &[Message::user("hi")], None)
2789 .unwrap();
2790 assert!(turn.events.is_empty());
2791 }
2792
2793 #[test]
2794 fn oneharness_stream_forwards_events_then_parses_the_result() {
2795 let bin = script(
2800 "oh-stream",
2801 "cat >/dev/null\n\
2802 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2803 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":0}}'\n\
2804 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2805 \"text\":\" done \",\"session_id\":\"s1\",\"usage\":{\"input_tokens\":7}}]}}'\n",
2806 );
2807 let mut seen = Vec::new();
2808 let turn = oh_provider(bin)
2809 .respond_streaming(
2810 "claude-code",
2811 "sonnet",
2812 &skill_ref(),
2813 &[Message::user("hi")],
2814 None,
2815 &mut |event| {
2816 seen.push(event.name.clone());
2817 ControlFlow::Continue(())
2818 },
2819 )
2820 .unwrap();
2821 assert_eq!(seen, vec![Some("bash".to_string())]);
2823 assert_eq!(turn.message, "done");
2824 assert_eq!(turn.session_id.as_deref(), Some("s1"));
2825 assert_eq!(turn.usage.unwrap().input_tokens, Some(7));
2826 assert_eq!(turn.events.len(), 1);
2827 assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
2828 }
2829
2830 #[test]
2831 fn oneharness_stream_short_circuits_on_break() {
2832 let bin = script(
2836 "oh-stream-abort",
2837 "cat >/dev/null\n\
2838 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2839 \"name\":\"rm\",\"input\":{\"command\":\"rm -rf /\"},\"index\":0}}'\n\
2840 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2841 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":1}}'\n\
2842 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2843 \"text\":\"done\"}]}}'\n",
2844 );
2845 let mut seen = 0usize;
2846 let turn = oh_provider(bin)
2847 .respond_streaming(
2848 "claude-code",
2849 "sonnet",
2850 &skill_ref(),
2851 &[Message::user("hi")],
2852 None,
2853 &mut |event| {
2854 seen += 1;
2855 assert_eq!(event.name.as_deref(), Some("rm"));
2856 ControlFlow::Break(())
2857 },
2858 )
2859 .unwrap();
2860 assert_eq!(seen, 1, "aborted after the first event");
2861 assert_eq!(turn.events.len(), 1);
2862 assert_eq!(turn.events[0].name.as_deref(), Some("rm"));
2863 assert!(turn.message.is_empty());
2865 }
2866
2867 #[test]
2868 fn oneharness_stream_errors_when_no_result_line() {
2869 let bin = script(
2872 "oh-stream-noresult",
2873 "cat >/dev/null\n\
2874 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
2875 \"name\":\"bash\",\"input\":{},\"index\":0}}'\n",
2876 );
2877 let err = oh_provider(bin)
2878 .respond_streaming(
2879 "claude-code",
2880 "sonnet",
2881 &skill_ref(),
2882 &[Message::user("hi")],
2883 None,
2884 &mut |_| ControlFlow::Continue(()),
2885 )
2886 .unwrap_err();
2887 assert!(
2888 matches!(err, Error::Provider { .. }),
2889 "expected a provider error, got: {err:?}"
2890 );
2891 }
2892
2893 #[test]
2894 fn oneharness_buffered_run_passes_events_and_omits_mode() {
2895 let bin = script(
2898 "oh-args",
2899 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
2900 cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
2901 );
2902 let dir = bin.parent().unwrap().to_path_buf();
2903 oh_provider(bin)
2904 .respond(
2905 "claude-code",
2906 "sonnet",
2907 &skill_ref(),
2908 &[Message::user("hi")],
2909 None,
2910 )
2911 .unwrap();
2912 let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
2913 .unwrap()
2914 .lines()
2915 .map(str::to_string)
2916 .collect();
2917 assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
2918 assert!(args.iter().any(|a| a == "--compact"), "got: {args:?}");
2919 assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
2920 assert!(!args.iter().any(|a| a == "--stream"), "got: {args:?}");
2921 }
2922
2923 #[test]
2924 fn oneharness_stream_run_passes_stream_and_omits_mode() {
2925 let bin = script(
2928 "oh-args-stream",
2929 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
2930 cat >/dev/null\n\
2931 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
2932 \"text\":\"hi\"}]}}'\n",
2933 );
2934 let dir = bin.parent().unwrap().to_path_buf();
2935 oh_provider(bin)
2936 .respond_streaming(
2937 "claude-code",
2938 "sonnet",
2939 &skill_ref(),
2940 &[Message::user("hi")],
2941 None,
2942 &mut |_| ControlFlow::Continue(()),
2943 )
2944 .unwrap();
2945 let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
2946 .unwrap()
2947 .lines()
2948 .map(str::to_string)
2949 .collect();
2950 assert!(args.iter().any(|a| a == "--stream"), "got: {args:?}");
2951 assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
2952 assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
2953 assert!(!args.iter().any(|a| a == "--compact"), "got: {args:?}");
2954 }
2955
2956 #[test]
2957 fn oneharness_falls_back_to_stdout_when_text_null() {
2958 let bin = script(
2959 "oh-fallback",
2960 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2961 \"stdout\":\"raw reply\"}]}'\n",
2962 );
2963 let user = oh_provider(bin).simulate_user("m", "persona", &[]).unwrap();
2964 assert_eq!(user.message, "raw reply");
2965 }
2966
2967 #[test]
2968 fn oneharness_judge_parses_verdict() {
2969 let bin = script(
2970 "oh-judge",
2971 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
2972 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"good\\\"}\"}]}'\n",
2973 );
2974 let query = JudgeQuery {
2975 kind: JudgeKind::Boolean,
2976 criterion: "polite",
2977 scale: None,
2978 };
2979 let verdict = oh_provider(bin).judge("m", &query, &[]).unwrap();
2980 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
2981 assert_eq!(verdict.reason, "good");
2982 }
2983
2984 #[test]
2985 fn oneharness_classifies_failure_kind() {
2986 let bin = script(
2987 "oh-auth",
2988 "cat >/dev/null\necho '{\"results\":[{\"status\":\"error\",\
2989 \"failure_kind\":\"auth\",\"error\":\"no creds\"}]}'\n",
2990 );
2991 let err = oh_provider(bin)
2992 .respond("claude-code", "m", &skill_ref(), &[], None)
2993 .unwrap_err();
2994 assert!(matches!(
2995 err,
2996 Error::Provider {
2997 kind: Some(ProviderErrorKind::Auth),
2998 ..
2999 }
3000 ));
3001 }
3002
3003 #[test]
3004 fn oneharness_classifies_timeout_status_without_failure_kind() {
3005 let bin = script(
3010 "oh-err",
3011 "cat >/dev/null\necho '{\"results\":[{\"status\":\"timeout\",\
3012 \"stderr\":\"deadline\"}]}'\n",
3013 );
3014 let err = oh_provider(bin).simulate_user("m", "p", &[]).unwrap_err();
3015 let msg = err.to_string();
3016 assert!(msg.contains("harness run failed"), "got: {msg}");
3017 assert!(msg.contains("deadline"));
3018 assert!(matches!(
3019 err,
3020 Error::Provider {
3021 kind: Some(ProviderErrorKind::Timeout),
3022 ..
3023 }
3024 ));
3025 }
3026
3027 #[test]
3028 fn oneharness_failure_without_classifiable_signal_is_unclassified() {
3029 let bin = script(
3033 "oh-plain",
3034 "cat >/dev/null\necho '{\"results\":[{\"status\":\"error\",\
3035 \"error\":\"something broke\"}]}'\n",
3036 );
3037 let err = oh_provider(bin)
3038 .respond("claude-code", "m", &skill_ref(), &[], None)
3039 .unwrap_err();
3040 assert!(matches!(err, Error::Provider { kind: None, .. }));
3041 assert!(err.to_string().contains("something broke"));
3042 }
3043
3044 #[test]
3045 fn oneharness_errors_on_unparseable_output_and_no_results() {
3046 let garbage = script(
3047 "oh-garbage",
3048 "cat >/dev/null\necho 'not json' 1>&2\necho 'x'\n",
3049 );
3050 assert!(oh_provider(garbage)
3051 .simulate_user("m", "p", &[])
3052 .unwrap_err()
3053 .to_string()
3054 .contains("could not parse oneharness output"));
3055
3056 let empty = script("oh-empty", "cat >/dev/null\necho '{\"results\":[]}'\n");
3057 assert!(oh_provider(empty)
3058 .simulate_user("m", "p", &[])
3059 .unwrap_err()
3060 .to_string()
3061 .contains("no results"));
3062 }
3063
3064 #[test]
3065 fn oneharness_errors_when_no_text_or_stdout() {
3066 let bin = script(
3067 "oh-silent",
3068 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\"}]}'\n",
3069 );
3070 assert!(oh_provider(bin)
3071 .respond("claude-code", "m", &skill_ref(), &[], None)
3072 .unwrap_err()
3073 .to_string()
3074 .contains("neither extractable text nor stdout"));
3075 }
3076
3077 #[test]
3078 fn oneharness_respond_resume_sends_only_latest_message() {
3079 let dir =
3083 std::env::temp_dir().join(format!("skilltest-oh-resume-{}", std::process::id()));
3084 std::fs::create_dir_all(&dir).unwrap();
3085 let prompt_file = dir.join("prompt.txt");
3086 let argv_file = dir.join("argv.txt");
3087 let bin = script(
3088 "oh-resume",
3089 &format!(
3090 "echo \"$@\" > '{}'\ncat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3091 argv_file.display(),
3092 prompt_file.display(),
3093 ),
3094 );
3095 let messages = [
3096 Message::user("first"),
3097 Message::assistant("reply"),
3098 Message::user("second"),
3099 ];
3100 oh_provider(bin.clone())
3101 .respond(
3102 "claude-code",
3103 "sonnet",
3104 &skill_ref(),
3105 &messages,
3106 Some("sess-1"),
3107 )
3108 .unwrap();
3109 let prompt = std::fs::read_to_string(&prompt_file).unwrap();
3110 assert_eq!(
3111 prompt.trim(),
3112 "second",
3113 "resume sends only the latest user message"
3114 );
3115 let argv = std::fs::read_to_string(&argv_file).unwrap();
3116 assert!(argv.contains("--resume sess-1"), "argv: {argv}");
3117 assert!(
3118 argv.contains("--system"),
3119 "skill is the system prompt: {argv}"
3120 );
3121 }
3122
3123 #[test]
3124 fn oneharness_omits_model_flag_when_model_empty() {
3125 let dir =
3126 std::env::temp_dir().join(format!("skilltest-oh-model-{}", std::process::id()));
3127 std::fs::create_dir_all(&dir).unwrap();
3128 let argv_file = dir.join("argv.txt");
3129 let bin = script(
3130 "oh-nomodel",
3131 &format!(
3132 "echo \"$@\" > '{}'\ncat >/dev/null\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3133 argv_file.display(),
3134 ),
3135 );
3136 oh_provider(bin)
3137 .respond("cursor", "", &skill_ref(), &[Message::user("hi")], None)
3138 .unwrap();
3139 let argv = std::fs::read_to_string(&argv_file).unwrap();
3140 assert!(
3141 !argv.contains("--model"),
3142 "empty model omits the flag: {argv}"
3143 );
3144 }
3145
3146 #[test]
3147 fn oneharness_reports_missing_binary() {
3148 let provider = oh_provider(PathBuf::from("/no/such/oneharness-binary"));
3149 let err = provider
3150 .respond("claude-code", "m", &skill_ref(), &[], None)
3151 .unwrap_err();
3152 assert!(err.to_string().contains("could not run"));
3153 }
3154
3155 #[test]
3156 fn oneharness_respond_with_mocks_passes_flags_and_reads_spy_log() {
3157 let dir =
3161 std::env::temp_dir().join(format!("skilltest-oh-mocks-{}", std::process::id()));
3162 std::fs::create_dir_all(&dir).unwrap();
3163 let rules_seen = dir.join("rules-seen.json");
3164 let bin = script(
3165 "oh-mocks",
3166 &format!(
3167 r#"rules=""; spy=""
3168while [ $# -gt 0 ]; do
3169 [ "$1" = "--mock-rules" ] && rules="$2"
3170 [ "$1" = "--spy-file" ] && spy="$2"
3171 shift
3172done
3173cat >/dev/null
3174cp "$rules" '{seen}'
3175printf '%s
3176' '{{"harness":"claude-code","event":{{"tool_name":"Bash","tool_input":{{"command":"git push"}}}},"action":"stub","rule":0}}' >> "$spy"
3177printf '%s
3178' '{{"harness":"claude-code","event":{{"tool_name":"Bash","tool_input":{{"command":"ls"}}}},"action":"allow","rule":null}}' >> "$spy"
3179echo '{{"results":[{{"status":"ok","text":"done"}}]}}'
3180"#,
3181 seen = rules_seen.display(),
3182 ),
3183 );
3184 let rules = serde_json::json!({ "rules": [
3185 { "match": { "event_contains": "git push" },
3186 "action": { "stub": { "output": "up-to-date", "exit_code": 0 } } }
3187 ]});
3188 let plan = MockPlan {
3189 rules: Some(&rules),
3190 };
3191 let turn = oh_provider(bin)
3192 .respond_with_mocks(
3193 "claude-code",
3194 "sonnet",
3195 &skill_ref(),
3196 &[Message::user("hi")],
3197 None,
3198 Some(&plan),
3199 )
3200 .unwrap();
3201 let seen: serde_json::Value =
3203 serde_json::from_str(&std::fs::read_to_string(&rules_seen).unwrap()).unwrap();
3204 assert_eq!(seen, rules);
3205 let records = turn.mock_calls.expect("channel was on");
3207 assert_eq!(records.len(), 2);
3208 assert_eq!(records[0].action, "stub");
3209 assert_eq!(records[0].rule, Some(0));
3210 assert_eq!(records[0].input.as_ref().unwrap()["command"], "git push");
3211 assert_eq!(records[1].action, "allow");
3212 }
3213
3214 #[test]
3215 fn oneharness_spy_only_plan_omits_rules_flag_and_missing_log_is_empty() {
3216 let dir =
3220 std::env::temp_dir().join(format!("skilltest-oh-spyonly-{}", std::process::id()));
3221 std::fs::create_dir_all(&dir).unwrap();
3222 let argv_file = dir.join("argv.txt");
3223 let bin = script(
3224 "oh-spyonly",
3225 &format!(
3226 "echo \"$@\" > '{}'\ncat >/dev/null\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3227 argv_file.display(),
3228 ),
3229 );
3230 let plan = MockPlan { rules: None };
3231 let turn = oh_provider(bin)
3232 .respond_with_mocks(
3233 "claude-code",
3234 "sonnet",
3235 &skill_ref(),
3236 &[Message::user("hi")],
3237 None,
3238 Some(&plan),
3239 )
3240 .unwrap();
3241 assert_eq!(turn.mock_calls, Some(Vec::new()));
3242 let argv = std::fs::read_to_string(&argv_file).unwrap();
3243 assert!(argv.contains("--spy-file"), "argv: {argv}");
3244 assert!(!argv.contains("--mock-rules"), "argv: {argv}");
3245 }
3246
3247 fn api_provider_with_curl(curl: PathBuf, vendor: ApiVendor) -> ApiJudgeProvider {
3250 ApiJudgeProvider::new(&ApiJudgeConfig {
3251 vendor,
3252 api_key_env: Some("SKILLTEST_TEST_API_KEY".to_string()),
3253 base_url: Some("https://example.invalid/v1".to_string()),
3254 timeout_secs: 5,
3255 curl_bin: curl.to_string_lossy().into_owned(),
3256 strict_json: true,
3257 })
3258 }
3259
3260 #[test]
3261 fn api_judge_judges_through_fake_curl() {
3262 let curl = script(
3264 "curl-ok",
3265 "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
3266 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"polite\\\"}\"}],\
3267 \"usage\":{\"input_tokens\":9,\"output_tokens\":3}}'\n",
3268 );
3269 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3270 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3271 let query = JudgeQuery {
3272 kind: JudgeKind::Boolean,
3273 criterion: "polite",
3274 scale: None,
3275 };
3276 let verdict = provider
3277 .judge("claude-x", &query, &[Message::user("hi")])
3278 .unwrap();
3279 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3280 assert_eq!(verdict.usage.unwrap().input_tokens, Some(9));
3281 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3282 }
3283
3284 #[test]
3285 fn api_judge_simulates_user_through_fake_curl() {
3286 let curl = script(
3287 "curl-user",
3288 "cat >/dev/null\necho '{\"choices\":[{\"message\":\
3289 {\"content\":\"sure, go on\"}}]}'\n",
3290 );
3291 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3292 let provider = api_provider_with_curl(curl, ApiVendor::Openai);
3293 let user = provider.simulate_user("gpt-x", "a patient", &[]).unwrap();
3294 assert_eq!(user.message, "sure, go on");
3295 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3296 }
3297
3298 #[test]
3299 fn api_judge_errors_when_key_absent() {
3300 let curl = script("curl-unused", "cat >/dev/null\necho '{}'\n");
3301 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3302 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3303 let err = provider
3304 .judge(
3305 "m",
3306 &JudgeQuery {
3307 kind: JudgeKind::Boolean,
3308 criterion: "x",
3309 scale: None,
3310 },
3311 &[],
3312 )
3313 .unwrap_err();
3314 assert!(matches!(
3315 err,
3316 Error::Provider {
3317 kind: Some(ProviderErrorKind::Auth),
3318 ..
3319 }
3320 ));
3321 }
3322
3323 #[test]
3324 fn api_judge_surfaces_curl_failure() {
3325 let curl = script(
3326 "curl-fail",
3327 "cat >/dev/null\necho 'curl: (6) bad host' 1>&2\nexit 6\n",
3328 );
3329 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3330 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3331 let err = provider
3332 .judge(
3333 "m",
3334 &JudgeQuery {
3335 kind: JudgeKind::Boolean,
3336 criterion: "x",
3337 scale: None,
3338 },
3339 &[],
3340 )
3341 .unwrap_err();
3342 assert!(err.to_string().contains("curl failed"));
3343 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3344 }
3345
3346 #[test]
3347 fn write_curl_config_sets_private_mode_and_headers() {
3348 let dir = std::env::temp_dir().join(format!("skilltest-cfg-{}", std::process::id()));
3349 std::fs::create_dir_all(&dir).unwrap();
3350 let path = dir.join("c.cfg");
3351 write_curl_config(
3352 &path,
3353 "https://api.example/v1",
3354 &[("x-api-key".to_string(), "secret\"quote".to_string())],
3355 42,
3356 )
3357 .unwrap();
3358 let text = std::fs::read_to_string(&path).unwrap();
3359 assert!(text.contains("url = \"https://api.example/v1\""));
3360 assert!(text.contains("max-time = 42"));
3361 assert!(text.contains("secret\\\"quote"), "escaped header: {text}");
3363 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3364 assert_eq!(mode & 0o777, 0o600, "config is private");
3365 }
3366
3367 #[test]
3368 fn api_judge_retries_a_transient_error_then_succeeds() {
3369 let dir = std::env::temp_dir().join(format!("skilltest-retry-{}", std::process::id()));
3372 std::fs::create_dir_all(&dir).unwrap();
3373 let counter = dir.join("n");
3374 let curl = script(
3375 "curl-retry",
3376 &format!(
3377 "cat >/dev/null\nif [ -f '{c}' ]; then \
3378 echo '{{\"content\":[{{\"type\":\"text\",\"text\":\"{{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}}\"}}]}}'; \
3379 else touch '{c}'; \
3380 echo '{{\"error\":{{\"type\":\"overloaded_error\",\"message\":\"busy\"}}}}'; \
3381 fi\n",
3382 c = counter.display(),
3383 ),
3384 );
3385 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3386 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3387 let verdict = provider
3388 .judge(
3389 "m",
3390 &JudgeQuery {
3391 kind: JudgeKind::Boolean,
3392 criterion: "x",
3393 scale: None,
3394 },
3395 &[],
3396 )
3397 .unwrap();
3398 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3399 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3400 }
3401
3402 #[test]
3403 fn api_judge_gives_up_after_max_retries() {
3404 let curl = script(
3406 "curl-busy",
3407 "cat >/dev/null\necho '{\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}'\n",
3408 );
3409 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3410 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3411 let err = provider
3412 .judge(
3413 "m",
3414 &JudgeQuery {
3415 kind: JudgeKind::Boolean,
3416 criterion: "x",
3417 scale: None,
3418 },
3419 &[],
3420 )
3421 .unwrap_err();
3422 assert!(matches!(
3423 err,
3424 Error::Provider {
3425 kind: Some(ProviderErrorKind::Overloaded),
3426 ..
3427 }
3428 ));
3429 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3430 }
3431
3432 #[test]
3433 fn api_judge_classifies_curl_timeout() {
3434 let curl = script(
3437 "curl-timeout",
3438 "cat >/dev/null\necho 'curl: (28) Operation timed out' 1>&2\nexit 28\n",
3439 );
3440 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3441 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3442 let err = provider
3443 .judge(
3444 "m",
3445 &JudgeQuery {
3446 kind: JudgeKind::Boolean,
3447 criterion: "x",
3448 scale: None,
3449 },
3450 &[],
3451 )
3452 .unwrap_err();
3453 assert!(matches!(
3454 err,
3455 Error::Provider {
3456 kind: Some(ProviderErrorKind::Timeout),
3457 ..
3458 }
3459 ));
3460 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3461 }
3462
3463 #[test]
3464 fn api_judge_reports_missing_curl_binary() {
3465 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3466 let provider =
3467 api_provider_with_curl(PathBuf::from("/no/such/curl-binary"), ApiVendor::Anthropic);
3468 let err = provider
3469 .judge(
3470 "m",
3471 &JudgeQuery {
3472 kind: JudgeKind::Boolean,
3473 criterion: "x",
3474 scale: None,
3475 },
3476 &[],
3477 )
3478 .unwrap_err();
3479 assert!(err.to_string().contains("could not run"));
3480 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3481 }
3482
3483 #[test]
3484 fn api_judge_surfaces_unparseable_response() {
3485 let curl = script("curl-garbage", "cat >/dev/null\necho 'not json at all'\n");
3486 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3487 let provider = api_provider_with_curl(curl, ApiVendor::Openai);
3488 let err = provider
3489 .judge(
3490 "m",
3491 &JudgeQuery {
3492 kind: JudgeKind::Boolean,
3493 criterion: "x",
3494 scale: None,
3495 },
3496 &[],
3497 )
3498 .unwrap_err();
3499 assert!(err.to_string().contains("could not parse API response"));
3500 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3501 }
3502
3503 #[test]
3504 fn split_provider_routes_judge_and_user_through_the_api() {
3505 let curl = script(
3508 "split-curl",
3509 "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
3510 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}\"}]}'\n",
3511 );
3512 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3513 let judge = api_provider_with_curl(curl, ApiVendor::Anthropic);
3514 let split = SplitProvider::new(Box::new(super::StubResponder), judge);
3515 let verdict = split
3516 .judge(
3517 "m",
3518 &JudgeQuery {
3519 kind: JudgeKind::Boolean,
3520 criterion: "polite",
3521 scale: None,
3522 },
3523 &[],
3524 )
3525 .unwrap();
3526 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3527 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3528 }
3529
3530 #[test]
3531 fn oneharness_numeric_judge_uses_numeric_prompt() {
3532 let dir = std::env::temp_dir().join(format!("skilltest-ohnum-{}", std::process::id()));
3535 std::fs::create_dir_all(&dir).unwrap();
3536 let prompt_file = dir.join("prompt.txt");
3537 let bin = script(
3538 "oh-numeric",
3539 &format!(
3540 "cat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"{{\\\"value\\\": 8.5, \\\"reason\\\": \\\"warm\\\"}}\"}}]}}'\n",
3541 prompt_file.display(),
3542 ),
3543 );
3544 let query = JudgeQuery {
3545 kind: JudgeKind::Numeric,
3546 criterion: "warmth",
3547 scale: Some((0.0, 10.0)),
3548 };
3549 let verdict = oh_provider(bin)
3550 .judge("m", &query, &[Message::assistant("hi")])
3551 .unwrap();
3552 assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 8.5).abs() < 1e-9));
3553 let prompt = std::fs::read_to_string(&prompt_file).unwrap();
3554 assert!(
3555 prompt.contains("scale from 0 to 10"),
3556 "numeric prompt: {prompt}"
3557 );
3558 }
3559
3560 #[test]
3561 fn supports_resume_method_matches_free_function() {
3562 let provider = oh_provider(PathBuf::from("/bin/true"));
3563 assert!(provider.supports_resume("claude-code"));
3564 assert!(!provider.supports_resume("codex"));
3565 }
3566 }
3567
3568 #[test]
3572 fn parse_verdict_rejects_missing_object_and_value() {
3573 assert!(parse_verdict(JudgeKind::Boolean, "just prose, no braces").is_err());
3575 assert!(parse_verdict(JudgeKind::Boolean, "{\"reason\": \"x\"}").is_err());
3577 assert!(parse_verdict(JudgeKind::Numeric, "{not: valid}").is_err());
3579 }
3580
3581 #[test]
3582 fn extract_json_object_handles_reversed_braces() {
3583 assert_eq!(extract_json_object("} then {"), None);
3585 }
3586
3587 #[test]
3588 fn unclassified_api_error_falls_back_to_plain_provider_error() {
3589 let raw = r#"{"error":{"type":"some_new_error","message":"odd"}}"#;
3591 let err = parse_chat_response(ApiVendor::Openai, raw).unwrap_err();
3592 assert!(matches!(err, Error::Provider { kind: None, .. }));
3593 assert!(err.to_string().contains("odd"));
3594 }
3595
3596 #[test]
3597 fn openai_empty_choice_text_is_an_error() {
3598 let raw = r#"{"choices":[]}"#;
3599 assert!(parse_chat_response(ApiVendor::Openai, raw).is_err());
3600 }
3601
3602 #[test]
3603 fn truncate_for_error_caps_length_on_a_char_boundary() {
3604 let long = "x".repeat(1000);
3605 assert_eq!(truncate_for_error(&long).chars().count(), 500);
3606 }
3607}