1use std::io::{BufRead as _, BufReader, Write as _};
14use std::ops::ControlFlow;
15use std::path::{Path, PathBuf};
16use std::process::{Command, Stdio};
17
18use serde::{Deserialize, Serialize};
19
20use crate::config::{ApiJudgeConfig, ApiVendor, OneharnessConfig};
21use crate::conversation::{Message, Role, ToolEvent};
22use crate::error::{Error, ProviderErrorKind, Result};
23use crate::eval::JudgeValue;
24use crate::mock::{parse_spy_log, MockCall, MockPlan};
25
26pub struct SkillRef<'a> {
28 pub name: &'a str,
29 pub dir: &'a str,
30 pub instructions: &'a str,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum JudgeKind {
36 Boolean,
37 Numeric,
38}
39
40impl JudgeKind {
41 fn as_str(self) -> &'static str {
42 match self {
43 JudgeKind::Boolean => "boolean",
44 JudgeKind::Numeric => "numeric",
45 }
46 }
47}
48
49pub struct JudgeQuery<'a> {
51 pub kind: JudgeKind,
52 pub criterion: &'a str,
53 pub scale: Option<(f64, f64)>,
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
63pub struct Usage {
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub input_tokens: Option<u64>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub output_tokens: Option<u64>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub cost_usd: Option<f64>,
70}
71
72impl Usage {
73 #[must_use]
75 pub fn is_empty(&self) -> bool {
76 self.input_tokens.is_none() && self.output_tokens.is_none() && self.cost_usd.is_none()
77 }
78
79 pub fn add(&mut self, other: &Usage) {
82 if let Some(v) = other.input_tokens {
83 self.input_tokens = Some(self.input_tokens.unwrap_or(0) + v);
84 }
85 if let Some(v) = other.output_tokens {
86 self.output_tokens = Some(self.output_tokens.unwrap_or(0) + v);
87 }
88 if let Some(v) = other.cost_usd {
89 self.cost_usd = Some(self.cost_usd.unwrap_or(0.0) + v);
90 }
91 }
92}
93
94#[derive(Debug, Clone, Default)]
96pub struct AssistantTurn {
97 pub message: String,
98 pub done: bool,
100 pub usage: Option<Usage>,
102 pub session_id: Option<String>,
106 pub events: Vec<ToolEvent>,
111 pub mock_calls: Option<Vec<MockCall>>,
117 pub history_command: Option<String>,
124}
125
126#[derive(Debug, Clone, Default)]
128pub struct UserTurn {
129 pub message: String,
130 pub stop: bool,
132 pub usage: Option<Usage>,
133}
134
135#[derive(Debug, Clone)]
137pub struct JudgeVerdict {
138 pub value: JudgeValue,
139 pub reason: String,
140 pub usage: Option<Usage>,
141}
142
143pub trait Provider {
145 fn respond(
154 &self,
155 platform: &str,
156 model: &str,
157 skill: &SkillRef<'_>,
158 messages: &[Message],
159 session: Option<&str>,
160 ) -> Result<AssistantTurn>;
161
162 fn respond_streaming(
175 &self,
176 platform: &str,
177 model: &str,
178 skill: &SkillRef<'_>,
179 messages: &[Message],
180 session: Option<&str>,
181 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
182 ) -> Result<AssistantTurn> {
183 let turn = self.respond(platform, model, skill, messages, session)?;
184 for event in &turn.events {
185 if on_event(event).is_break() {
186 break;
187 }
188 }
189 Ok(turn)
190 }
191
192 fn respond_with_mocks(
205 &self,
206 platform: &str,
207 model: &str,
208 skill: &SkillRef<'_>,
209 messages: &[Message],
210 session: Option<&str>,
211 mocks: Option<&MockPlan<'_>>,
212 ) -> Result<AssistantTurn> {
213 if mocks.is_some() {
214 return Err(Error::provider(
215 "mocks",
216 "this provider does not support tool mocking/spying; remove the `mocks` \
217 declarations or use the oneharness/command provider",
218 ));
219 }
220 self.respond(platform, model, skill, messages, session)
221 }
222
223 #[allow(clippy::too_many_arguments)]
232 fn respond_streaming_with_mocks(
233 &self,
234 platform: &str,
235 model: &str,
236 skill: &SkillRef<'_>,
237 messages: &[Message],
238 session: Option<&str>,
239 mocks: Option<&MockPlan<'_>>,
240 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
241 ) -> Result<AssistantTurn> {
242 if mocks.is_some() {
243 return Err(Error::provider(
244 "mocks",
245 "this provider does not support tool mocking/spying; remove the `mocks` \
246 declarations or use the oneharness/command provider",
247 ));
248 }
249 self.respond_streaming(platform, model, skill, messages, session, on_event)
250 }
251
252 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn>;
257
258 fn judge(
263 &self,
264 model: &str,
265 query: &JudgeQuery<'_>,
266 messages: &[Message],
267 ) -> Result<JudgeVerdict>;
268
269 fn supports_resume(&self, _platform: &str) -> bool {
274 false
275 }
276}
277
278#[derive(Serialize)]
283struct SkillPayload<'a> {
284 name: &'a str,
285 path: &'a str,
286 instructions: &'a str,
287}
288
289#[derive(Serialize)]
293struct MocksPayload<'a> {
294 rules: Option<&'a serde_json::Value>,
295}
296
297#[derive(Serialize)]
298#[serde(tag = "op", rename_all = "lowercase")]
299enum Request<'a> {
300 Respond {
301 platform: &'a str,
302 model: &'a str,
303 skill: SkillPayload<'a>,
304 messages: &'a [Message],
305 #[serde(skip_serializing_if = "Option::is_none")]
306 session: Option<&'a str>,
307 #[serde(skip_serializing_if = "Option::is_none")]
308 mocks: Option<MocksPayload<'a>>,
309 },
310 User {
311 model: &'a str,
312 persona: &'a str,
313 messages: &'a [Message],
314 },
315 Judge {
316 model: &'a str,
317 kind: &'a str,
318 criterion: &'a str,
319 #[serde(skip_serializing_if = "Option::is_none")]
320 min: Option<f64>,
321 #[serde(skip_serializing_if = "Option::is_none")]
322 max: Option<f64>,
323 messages: &'a [Message],
324 },
325}
326
327#[derive(Deserialize)]
328struct RespondPayload {
329 message: String,
330 #[serde(default)]
331 done: bool,
332 #[serde(default)]
333 usage: Option<Usage>,
334 #[serde(default)]
335 session_id: Option<String>,
336 #[serde(default)]
339 events: Option<Vec<ToolEvent>>,
340 #[serde(default)]
343 mock_calls: Option<Vec<MockCall>>,
344}
345
346#[derive(Deserialize)]
347struct UserPayload {
348 message: String,
349 #[serde(default)]
350 stop: bool,
351 #[serde(default)]
352 usage: Option<Usage>,
353}
354
355#[derive(Deserialize)]
356struct JudgePayload {
357 value: JudgeValue,
358 #[serde(default)]
359 reason: String,
360 #[serde(default)]
361 usage: Option<Usage>,
362}
363
364pub struct CommandProvider {
370 argv: Vec<String>,
371}
372
373impl CommandProvider {
374 pub fn new(argv: Vec<String>) -> Result<Self> {
380 if argv.is_empty() {
381 return Err(Error::Invalid("provider command is empty".into()));
382 }
383 Ok(Self { argv })
384 }
385
386 fn call<T: for<'de> Deserialize<'de>>(&self, request: &Request<'_>, op: &str) -> Result<T> {
388 let payload = serde_json::to_vec(request).map_err(|e| {
389 Error::provider(op.to_string(), format!("could not encode request: {e}"))
390 })?;
391
392 let mut child = Command::new(&self.argv[0])
393 .args(&self.argv[1..])
394 .stdin(Stdio::piped())
395 .stdout(Stdio::piped())
396 .stderr(Stdio::piped())
397 .spawn()
398 .map_err(|e| {
399 Error::provider(
400 op.to_string(),
401 format!(
402 "could not run provider `{}`: {e}. Is it installed and on PATH?",
403 self.argv[0]
404 ),
405 )
406 })?;
407
408 {
411 let stdin = child
412 .stdin
413 .as_mut()
414 .ok_or_else(|| Error::provider(op.to_string(), "could not open provider stdin"))?;
415 stdin
416 .write_all(&payload)
417 .and_then(|()| stdin.write_all(b"\n"))
418 .map_err(|e| {
419 Error::provider(op.to_string(), format!("could not write request: {e}"))
420 })?;
421 }
422
423 let output = child.wait_with_output().map_err(|e| {
424 Error::provider(op.to_string(), format!("provider did not complete: {e}"))
425 })?;
426
427 if !output.status.success() {
428 let stderr = String::from_utf8_lossy(&output.stderr);
429 return Err(Error::provider(
430 op.to_string(),
431 format!("provider exited with {}: {}", output.status, stderr.trim()),
432 ));
433 }
434
435 let stdout = String::from_utf8_lossy(&output.stdout);
436 let line = stdout.trim();
437 if line.is_empty() {
438 return Err(Error::provider(
439 op.to_string(),
440 "provider produced no output (expected one JSON response object)",
441 ));
442 }
443 serde_json::from_str(line).map_err(|e| {
444 Error::provider(
445 op.to_string(),
446 format!("provider response was not valid JSON for `{op}`: {e}; got: {line}"),
447 )
448 })
449 }
450}
451
452impl CommandProvider {
453 fn respond_impl(
458 &self,
459 platform: &str,
460 model: &str,
461 skill: &SkillRef<'_>,
462 messages: &[Message],
463 session: Option<&str>,
464 mocks: Option<&MockPlan<'_>>,
465 ) -> Result<AssistantTurn> {
466 let request = Request::Respond {
467 platform,
468 model,
469 skill: SkillPayload {
470 name: skill.name,
471 path: skill.dir,
472 instructions: skill.instructions,
473 },
474 messages,
475 session,
476 mocks: mocks.map(|plan| MocksPayload { rules: plan.rules }),
477 };
478 let payload: RespondPayload = self.call(&request, "respond")?;
479 if mocks.is_some() && payload.mock_calls.is_none() {
480 return Err(Error::provider(
481 "respond",
482 "the provider ignored the request's `mocks` block (no `mock_calls` in its \
483 response); it does not support tool mocking/spying",
484 ));
485 }
486 Ok(AssistantTurn {
487 message: payload.message,
488 done: payload.done,
489 usage: payload.usage,
490 session_id: payload.session_id,
491 events: payload.events.unwrap_or_default(),
492 mock_calls: payload.mock_calls,
493 history_command: None,
496 })
497 }
498}
499
500impl Provider for CommandProvider {
501 fn respond(
502 &self,
503 platform: &str,
504 model: &str,
505 skill: &SkillRef<'_>,
506 messages: &[Message],
507 session: Option<&str>,
508 ) -> Result<AssistantTurn> {
509 self.respond_impl(platform, model, skill, messages, session, None)
510 }
511
512 fn respond_with_mocks(
513 &self,
514 platform: &str,
515 model: &str,
516 skill: &SkillRef<'_>,
517 messages: &[Message],
518 session: Option<&str>,
519 mocks: Option<&MockPlan<'_>>,
520 ) -> Result<AssistantTurn> {
521 self.respond_impl(platform, model, skill, messages, session, mocks)
522 }
523
524 #[allow(clippy::too_many_arguments)]
527 fn respond_streaming_with_mocks(
528 &self,
529 platform: &str,
530 model: &str,
531 skill: &SkillRef<'_>,
532 messages: &[Message],
533 session: Option<&str>,
534 mocks: Option<&MockPlan<'_>>,
535 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
536 ) -> Result<AssistantTurn> {
537 let turn = self.respond_impl(platform, model, skill, messages, session, mocks)?;
541 for event in &turn.events {
542 if on_event(event).is_break() {
543 break;
544 }
545 }
546 Ok(turn)
547 }
548
549 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
550 let request = Request::User {
551 model,
552 persona,
553 messages,
554 };
555 let payload: UserPayload = self.call(&request, "user")?;
556 Ok(UserTurn {
557 message: payload.message,
558 stop: payload.stop,
559 usage: payload.usage,
560 })
561 }
562
563 fn judge(
564 &self,
565 model: &str,
566 query: &JudgeQuery<'_>,
567 messages: &[Message],
568 ) -> Result<JudgeVerdict> {
569 let (min, max) = match query.scale {
570 Some((lo, hi)) => (Some(lo), Some(hi)),
571 None => (None, None),
572 };
573 let request = Request::Judge {
574 model,
575 kind: query.kind.as_str(),
576 criterion: query.criterion,
577 min,
578 max,
579 messages,
580 };
581 let payload: JudgePayload = self.call(&request, "judge")?;
582 Ok(JudgeVerdict {
583 value: payload.value,
584 reason: payload.reason,
585 usage: payload.usage,
586 })
587 }
588}
589
590pub struct OneharnessProvider {
635 bin: String,
636 judge_harness: String,
637 timeout_secs: u64,
638 history: bool,
640 history_dir: PathBuf,
642}
643
644#[derive(Deserialize)]
646struct OhEnvelope {
647 results: Vec<OhResult>,
648 #[serde(default)]
655 history_file: Option<String>,
656}
657
658#[derive(Deserialize)]
659struct OhResult {
660 status: String,
661 #[serde(default)]
662 text: Option<String>,
663 #[serde(default)]
670 stdout: String,
671 #[serde(default)]
672 stderr: String,
673 #[serde(default)]
674 error: Option<String>,
675 #[serde(default)]
676 session_id: Option<String>,
677 #[serde(default)]
678 usage: Option<Usage>,
679 #[serde(default)]
682 events: Option<Vec<ToolEvent>>,
683 #[serde(default)]
684 failure_kind: Option<String>,
685}
686
687struct RunArgs<'a> {
689 harness: &'a str,
690 model: &'a str,
691 prompt: &'a str,
692 system: Option<&'a str>,
695 resume: Option<&'a str>,
698 mocks: Option<&'a MockPlan<'a>>,
702 history: Option<HistoryArgs<'a>>,
706}
707
708struct HistoryArgs<'a> {
710 dir: &'a Path,
712 name: &'a str,
715}
716
717impl<'a> RunArgs<'a> {
718 fn plain(harness: &'a str, model: &'a str, prompt: &'a str) -> Self {
720 RunArgs {
721 harness,
722 model,
723 prompt,
724 system: None,
725 resume: None,
726 mocks: None,
727 history: None,
728 }
729 }
730}
731
732struct RunOutcome {
734 text: String,
735 session_id: Option<String>,
736 usage: Option<Usage>,
737 events: Vec<ToolEvent>,
738 mock_calls: Option<Vec<MockCall>>,
741 history_file: Option<String>,
744}
745
746struct MockFiles {
750 dir: std::path::PathBuf,
751 rules: Option<std::path::PathBuf>,
752 spy: std::path::PathBuf,
753}
754
755impl MockFiles {
756 fn prepare(plan: &MockPlan<'_>) -> Result<MockFiles> {
758 let dir = std::env::temp_dir().join(format!(
759 "skilltest-mocks-{}-{}",
760 std::process::id(),
761 curl_config_nonce()
762 ));
763 std::fs::create_dir_all(&dir).map_err(|e| {
764 Error::provider("oneharness", format!("could not create mock temp dir: {e}"))
765 })?;
766 let rules = match plan.rules {
767 Some(rules) => {
768 let path = dir.join("rules.json");
769 std::fs::write(&path, rules.to_string()).map_err(|e| {
770 Error::provider("oneharness", format!("could not write mock rules: {e}"))
771 })?;
772 Some(path)
773 }
774 None => None,
775 };
776 Ok(MockFiles {
777 spy: dir.join("spy.jsonl"),
778 rules,
779 dir,
780 })
781 }
782
783 fn records(&self) -> Result<Vec<MockCall>> {
787 match std::fs::read_to_string(&self.spy) {
788 Ok(text) => parse_spy_log(&text),
789 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
790 Err(e) => Err(Error::provider(
791 "oneharness",
792 format!("could not read spy log `{}`: {e}", self.spy.display()),
793 )),
794 }
795 }
796}
797
798impl Drop for MockFiles {
799 fn drop(&mut self) {
800 let _ = std::fs::remove_dir_all(&self.dir);
801 }
802}
803
804fn select_reply_text(text: Option<String>, stdout: &str) -> Option<String> {
812 text.filter(|t| !t.trim().is_empty())
813 .or_else(|| (!stdout.trim().is_empty()).then(|| stdout.to_string()))
814}
815
816fn oh_failure(context: String, message: String, failure_kind: Option<&str>, status: &str) -> Error {
823 match oh_failure_kind(failure_kind, status) {
824 Some(kind) => Error::provider_classified(context, message, kind),
825 None => Error::provider(context, message),
826 }
827}
828
829fn oh_failure_kind(failure_kind: Option<&str>, status: &str) -> Option<ProviderErrorKind> {
832 if let Some(raw) = failure_kind.filter(|k| !k.is_empty()) {
833 return Some(ProviderErrorKind::classify(raw));
834 }
835 match status {
836 "timeout" => Some(ProviderErrorKind::Timeout),
837 _ => None,
838 }
839}
840
841impl OneharnessProvider {
842 #[must_use]
846 pub fn new(config: &OneharnessConfig) -> Self {
847 Self {
848 bin: config.bin.clone(),
849 judge_harness: config.judge_harness.clone(),
850 timeout_secs: config.timeout_secs,
851 history: config.history,
852 history_dir: config
853 .history_dir
854 .as_ref()
855 .map_or_else(default_history_dir, PathBuf::from),
856 }
857 }
858
859 fn history_name(&self, platform: &str, model: &str, messages: &[Message]) -> Option<String> {
864 self.history
865 .then(|| history_session_name(platform, model, messages))
866 }
867
868 fn history_command(&self, name: Option<&str>, outcome: &RunOutcome) -> Option<String> {
873 match (name, &outcome.history_file) {
874 (Some(name), Some(_)) => Some(history_view_command(&self.bin, &self.history_dir, name)),
875 _ => None,
876 }
877 }
878
879 fn run(&self, args: &RunArgs<'_>) -> Result<RunOutcome> {
883 let timeout = self.timeout_secs.to_string();
884 let mut cmd = Command::new(&self.bin);
885 cmd.args([
904 "run",
905 "--harness",
906 args.harness,
907 "--compact",
908 "--events",
909 "--timeout",
910 &timeout,
911 "--prompt-file",
912 "-",
913 ]);
914 if !args.model.is_empty() {
920 cmd.args(["--model", args.model]);
921 }
922 if let Some(system) = args.system {
923 cmd.args(["--system", system]);
924 }
925 if let Some(resume) = args.resume {
926 cmd.args(["--resume", resume]);
927 }
928 push_history_args(&mut cmd, args.history.as_ref());
931 let mock_files = args.mocks.map(MockFiles::prepare).transpose()?;
935 if let Some(files) = &mock_files {
936 if let Some(rules) = &files.rules {
937 cmd.arg("--mock-rules");
938 cmd.arg(rules);
939 }
940 cmd.arg("--spy-file");
941 cmd.arg(&files.spy);
942 }
943
944 let mut child = cmd
945 .stdin(Stdio::piped())
946 .stdout(Stdio::piped())
947 .stderr(Stdio::piped())
948 .spawn()
949 .map_err(|e| {
950 Error::provider(
951 "oneharness",
952 format!(
953 "could not run `{}`: {e}. Is oneharness installed and on PATH?",
954 self.bin
955 ),
956 )
957 })?;
958
959 child
960 .stdin
961 .as_mut()
962 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?
963 .write_all(args.prompt.as_bytes())
964 .map_err(|e| Error::provider("oneharness", format!("could not write prompt: {e}")))?;
965
966 let output = child.wait_with_output().map_err(|e| {
967 Error::provider("oneharness", format!("oneharness did not complete: {e}"))
968 })?;
969
970 let stdout = String::from_utf8_lossy(&output.stdout);
971 let envelope: OhEnvelope = serde_json::from_str(stdout.trim()).map_err(|e| {
972 Error::provider(
973 "oneharness",
974 format!(
975 "could not parse oneharness output: {e}; stderr: {}",
976 String::from_utf8_lossy(&output.stderr).trim()
977 ),
978 )
979 })?;
980
981 let history_file = envelope.history_file;
984 let result = envelope
985 .results
986 .into_iter()
987 .next()
988 .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
989
990 if result.status != "ok" {
991 let detail = result
992 .error
993 .filter(|e| !e.is_empty())
994 .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
995 .unwrap_or_else(|| format!("status `{}`", result.status));
996 let context = format!("oneharness:{}", args.harness);
997 let message = format!("harness run failed: {detail}");
998 return Err(oh_failure(
999 context,
1000 message,
1001 result.failure_kind.as_deref(),
1002 &result.status,
1003 ));
1004 }
1005
1006 let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
1011 Error::provider(
1012 format!("oneharness:{}", args.harness),
1013 "harness produced neither extractable text nor stdout",
1014 )
1015 })?;
1016 let mock_calls = mock_files.as_ref().map(MockFiles::records).transpose()?;
1017 Ok(RunOutcome {
1018 text,
1019 session_id: result.session_id,
1020 usage: result.usage,
1021 events: result.events.unwrap_or_default(),
1022 mock_calls,
1023 history_file,
1024 })
1025 }
1026
1027 fn run_streaming(
1034 &self,
1035 args: &RunArgs<'_>,
1036 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1037 ) -> Result<RunOutcome> {
1038 let timeout = self.timeout_secs.to_string();
1039 let mut cmd = Command::new(&self.bin);
1040 cmd.args([
1045 "run",
1046 "--harness",
1047 args.harness,
1048 "--stream",
1049 "--events",
1050 "--timeout",
1051 &timeout,
1052 "--prompt-file",
1053 "-",
1054 ]);
1055 if !args.model.is_empty() {
1056 cmd.args(["--model", args.model]);
1057 }
1058 if let Some(system) = args.system {
1059 cmd.args(["--system", system]);
1060 }
1061 if let Some(resume) = args.resume {
1062 cmd.args(["--resume", resume]);
1063 }
1064 push_history_args(&mut cmd, args.history.as_ref());
1065 let mock_files = args.mocks.map(MockFiles::prepare).transpose()?;
1066 if let Some(files) = &mock_files {
1067 if let Some(rules) = &files.rules {
1068 cmd.arg("--mock-rules");
1069 cmd.arg(rules);
1070 }
1071 cmd.arg("--spy-file");
1072 cmd.arg(&files.spy);
1073 }
1074
1075 let mut child = cmd
1076 .stdin(Stdio::piped())
1077 .stdout(Stdio::piped())
1078 .stderr(Stdio::piped())
1079 .spawn()
1080 .map_err(|e| {
1081 Error::provider(
1082 "oneharness",
1083 format!(
1084 "could not run `{}`: {e}. Is oneharness installed and on PATH?",
1085 self.bin
1086 ),
1087 )
1088 })?;
1089
1090 {
1094 let mut stdin = child
1095 .stdin
1096 .take()
1097 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdin"))?;
1098 stdin.write_all(args.prompt.as_bytes()).map_err(|e| {
1099 Error::provider("oneharness", format!("could not write prompt: {e}"))
1100 })?;
1101 }
1102
1103 let stdout = child
1104 .stdout
1105 .take()
1106 .ok_or_else(|| Error::provider("oneharness", "could not open oneharness stdout"))?;
1107 let reader = BufReader::new(stdout);
1108
1109 let mut events: Vec<ToolEvent> = Vec::new();
1110 let mut result_env: Option<OhEnvelope> = None;
1111 let mut aborted = false;
1112
1113 for line in reader.lines() {
1114 let line = line.map_err(|e| {
1115 Error::provider("oneharness", format!("could not read stream: {e}"))
1116 })?;
1117 let trimmed = line.trim();
1118 if trimmed.is_empty() {
1119 continue;
1120 }
1121 let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) else {
1123 continue;
1124 };
1125 match value.get("type").and_then(serde_json::Value::as_str) {
1126 Some("event") => {
1127 if let Ok(event) = serde_json::from_value::<ToolEvent>(value["event"].clone()) {
1128 let flow = on_event(&event);
1129 events.push(event);
1130 if flow.is_break() {
1131 aborted = true;
1132 let _ = child.kill();
1133 break;
1134 }
1135 }
1136 }
1137 Some("result") => {
1138 if let Ok(env) = serde_json::from_value::<OhEnvelope>(value["report"].clone()) {
1139 result_env = Some(env);
1140 }
1141 }
1142 _ => {}
1143 }
1144 }
1145
1146 let output = child.wait_with_output().map_err(|e| {
1147 Error::provider("oneharness", format!("oneharness did not complete: {e}"))
1148 })?;
1149
1150 if aborted {
1151 return Ok(RunOutcome {
1155 text: String::new(),
1156 session_id: None,
1157 usage: None,
1158 events,
1159 mock_calls: None,
1160 history_file: None,
1162 });
1163 }
1164
1165 let envelope = result_env.ok_or_else(|| {
1166 Error::provider(
1167 "oneharness",
1168 format!(
1169 "oneharness stream produced no result; stderr: {}",
1170 String::from_utf8_lossy(&output.stderr).trim()
1171 ),
1172 )
1173 })?;
1174 let history_file = envelope.history_file;
1177 let result = envelope
1178 .results
1179 .into_iter()
1180 .next()
1181 .ok_or_else(|| Error::provider("oneharness", "oneharness returned no results"))?;
1182 if result.status != "ok" {
1183 let detail = result
1184 .error
1185 .filter(|e| !e.is_empty())
1186 .or_else(|| Some(result.stderr.clone()).filter(|s| !s.is_empty()))
1187 .unwrap_or_else(|| format!("status `{}`", result.status));
1188 let context = format!("oneharness:{}", args.harness);
1189 let message = format!("harness run failed: {detail}");
1190 return Err(oh_failure(
1191 context,
1192 message,
1193 result.failure_kind.as_deref(),
1194 &result.status,
1195 ));
1196 }
1197 let text = select_reply_text(result.text, &result.stdout).ok_or_else(|| {
1198 Error::provider(
1199 format!("oneharness:{}", args.harness),
1200 "harness produced neither extractable text nor stdout",
1201 )
1202 })?;
1203 let events = if events.is_empty() {
1206 result.events.unwrap_or_default()
1207 } else {
1208 events
1209 };
1210 let mock_calls = mock_files.as_ref().map(MockFiles::records).transpose()?;
1211 Ok(RunOutcome {
1212 text,
1213 session_id: result.session_id,
1214 usage: result.usage,
1215 events,
1216 mock_calls,
1217 history_file,
1218 })
1219 }
1220}
1221
1222impl Provider for OneharnessProvider {
1223 fn respond(
1224 &self,
1225 platform: &str,
1226 model: &str,
1227 skill: &SkillRef<'_>,
1228 messages: &[Message],
1229 session: Option<&str>,
1230 ) -> Result<AssistantTurn> {
1231 self.respond_with_mocks(platform, model, skill, messages, session, None)
1232 }
1233
1234 fn respond_with_mocks(
1235 &self,
1236 platform: &str,
1237 model: &str,
1238 skill: &SkillRef<'_>,
1239 messages: &[Message],
1240 session: Option<&str>,
1241 mocks: Option<&MockPlan<'_>>,
1242 ) -> Result<AssistantTurn> {
1243 let prompt = if session.is_some() {
1248 latest_user_message(messages).unwrap_or_default()
1249 } else {
1250 render_transcript_for_respond(messages)
1251 };
1252 let history_name = self.history_name(platform, model, messages);
1253 let outcome = self.run(&RunArgs {
1254 harness: platform,
1255 model,
1256 prompt: &prompt,
1257 system: Some(skill.instructions),
1258 resume: session,
1259 mocks,
1260 history: history_name.as_deref().map(|name| HistoryArgs {
1261 dir: &self.history_dir,
1262 name,
1263 }),
1264 })?;
1265 let history_command = self.history_command(history_name.as_deref(), &outcome);
1266 Ok(AssistantTurn {
1267 message: outcome.text.trim().to_string(),
1268 done: false,
1269 usage: outcome.usage,
1270 session_id: outcome.session_id,
1271 events: outcome.events,
1272 mock_calls: outcome.mock_calls,
1273 history_command,
1274 })
1275 }
1276
1277 fn respond_streaming(
1278 &self,
1279 platform: &str,
1280 model: &str,
1281 skill: &SkillRef<'_>,
1282 messages: &[Message],
1283 session: Option<&str>,
1284 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1285 ) -> Result<AssistantTurn> {
1286 self.respond_streaming_with_mocks(platform, model, skill, messages, session, None, on_event)
1287 }
1288
1289 #[allow(clippy::too_many_arguments)]
1292 fn respond_streaming_with_mocks(
1293 &self,
1294 platform: &str,
1295 model: &str,
1296 skill: &SkillRef<'_>,
1297 messages: &[Message],
1298 session: Option<&str>,
1299 mocks: Option<&MockPlan<'_>>,
1300 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1301 ) -> Result<AssistantTurn> {
1302 let prompt = if session.is_some() {
1303 latest_user_message(messages).unwrap_or_default()
1304 } else {
1305 render_transcript_for_respond(messages)
1306 };
1307 let history_name = self.history_name(platform, model, messages);
1308 let outcome = self.run_streaming(
1309 &RunArgs {
1310 harness: platform,
1311 model,
1312 prompt: &prompt,
1313 system: Some(skill.instructions),
1314 resume: session,
1315 mocks,
1316 history: history_name.as_deref().map(|name| HistoryArgs {
1317 dir: &self.history_dir,
1318 name,
1319 }),
1320 },
1321 on_event,
1322 )?;
1323 let history_command = self.history_command(history_name.as_deref(), &outcome);
1324 Ok(AssistantTurn {
1325 message: outcome.text.trim().to_string(),
1326 done: false,
1327 usage: outcome.usage,
1328 session_id: outcome.session_id,
1329 events: outcome.events,
1330 mock_calls: outcome.mock_calls,
1331 history_command,
1332 })
1333 }
1334
1335 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1336 let prompt = build_user_prompt(persona, messages);
1337 let outcome = self.run(&RunArgs::plain(&self.judge_harness, model, &prompt))?;
1338 Ok(UserTurn {
1339 message: outcome.text.trim().to_string(),
1340 stop: false,
1341 usage: outcome.usage,
1342 })
1343 }
1344
1345 fn judge(
1346 &self,
1347 model: &str,
1348 query: &JudgeQuery<'_>,
1349 messages: &[Message],
1350 ) -> Result<JudgeVerdict> {
1351 let prompt = build_judge_prompt(query, messages);
1352 let outcome = self.run(&RunArgs::plain(&self.judge_harness, model, &prompt))?;
1353 let mut verdict = parse_verdict(query.kind, &outcome.text)?;
1354 verdict.usage = outcome.usage;
1355 Ok(verdict)
1356 }
1357
1358 fn supports_resume(&self, platform: &str) -> bool {
1359 supports_resume(platform)
1360 }
1361}
1362
1363#[must_use]
1368pub fn supports_resume(harness: &str) -> bool {
1369 matches!(harness, "claude-code" | "opencode" | "cursor")
1370}
1371
1372#[must_use]
1386pub fn default_history_dir() -> PathBuf {
1387 resolve_history_dir(
1388 std::env::var_os("SKILLTEST_HISTORY_DIR"),
1389 std::env::var_os("XDG_STATE_HOME"),
1390 std::env::var_os("HOME"),
1391 )
1392}
1393
1394fn resolve_history_dir(
1397 override_dir: Option<std::ffi::OsString>,
1398 xdg_state: Option<std::ffi::OsString>,
1399 home: Option<std::ffi::OsString>,
1400) -> PathBuf {
1401 if let Some(dir) = override_dir.filter(|d| !d.is_empty()) {
1402 return PathBuf::from(dir);
1403 }
1404 let state = xdg_state
1405 .filter(|d| !d.is_empty())
1406 .map(PathBuf::from)
1407 .or_else(|| {
1408 home.filter(|h| !h.is_empty())
1409 .map(|home| PathBuf::from(home).join(".local").join("state"))
1410 })
1411 .unwrap_or_else(std::env::temp_dir);
1412 state.join("skilltest").join("oneharness-history")
1413}
1414
1415fn push_history_args(cmd: &mut Command, history: Option<&HistoryArgs<'_>>) {
1417 if let Some(h) = history {
1418 cmd.arg("--history");
1419 cmd.arg("--history-dir");
1420 cmd.arg(h.dir);
1421 cmd.arg("--history-name");
1422 cmd.arg(h.name);
1423 }
1424}
1425
1426fn history_session_name(platform: &str, model: &str, messages: &[Message]) -> String {
1436 let seed = messages
1437 .iter()
1438 .find(|m| m.role == Role::User)
1439 .map_or("", |m| m.content.as_str());
1440 let model = if model.is_empty() { "default" } else { model };
1441 format!(
1442 "skilltest-{}-{}-{}-{:08x}",
1443 slug(platform),
1444 slug(model),
1445 slug(seed),
1446 (fnv1a(seed.as_bytes()) & 0xFFFF_FFFF) as u32,
1447 )
1448}
1449
1450fn history_view_command(bin: &str, dir: &Path, name: &str) -> String {
1454 format!(
1455 "{} history show {name} --history-dir {}",
1456 shell_quote(bin),
1457 shell_quote(&dir.to_string_lossy()),
1458 )
1459}
1460
1461fn slug(s: &str) -> String {
1465 let mut out = String::with_capacity(s.len().min(32));
1466 let mut dash = false;
1467 for c in s.chars() {
1468 if c.is_ascii_alphanumeric() {
1469 out.push(c.to_ascii_lowercase());
1470 dash = false;
1471 } else if !out.is_empty() && !dash {
1472 out.push('-');
1473 dash = true;
1474 }
1475 if out.len() >= 32 {
1476 break;
1477 }
1478 }
1479 out.truncate(32);
1480 let trimmed = out.trim_matches('-');
1481 if trimmed.is_empty() {
1482 "x".to_string()
1483 } else {
1484 trimmed.to_string()
1485 }
1486}
1487
1488fn fnv1a(bytes: &[u8]) -> u64 {
1491 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1492 for &b in bytes {
1493 hash ^= u64::from(b);
1494 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
1495 }
1496 hash
1497}
1498
1499fn shell_quote(s: &str) -> String {
1503 let safe = !s.is_empty()
1504 && s.chars().all(|c| {
1505 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '/' | '.' | ':' | '@' | '+' | '=')
1506 });
1507 if safe {
1508 return s.to_string();
1509 }
1510 let mut out = String::with_capacity(s.len() + 2);
1511 out.push('\'');
1512 for c in s.chars() {
1513 if c == '\'' {
1514 out.push_str("'\\''");
1515 } else {
1516 out.push(c);
1517 }
1518 }
1519 out.push('\'');
1520 out
1521}
1522
1523pub struct ApiJudgeProvider {
1545 vendor: ApiVendor,
1546 api_key_env: String,
1547 endpoint: String,
1548 timeout_secs: u64,
1549 curl_bin: String,
1550 strict_json: bool,
1551}
1552
1553const MAX_RETRIES: u32 = 2;
1556
1557#[derive(Debug)]
1559struct ChatOutcome {
1560 text: String,
1561 usage: Option<Usage>,
1562}
1563
1564const JUDGE_SYSTEM: &str =
1567 "Follow the user's instructions exactly and respond with only what they ask for.";
1568
1569impl ApiJudgeProvider {
1570 #[must_use]
1573 pub fn new(config: &ApiJudgeConfig) -> Self {
1574 let api_key_env = config
1575 .api_key_env
1576 .clone()
1577 .unwrap_or_else(|| match config.vendor {
1578 ApiVendor::Anthropic => "ANTHROPIC_API_KEY".to_string(),
1579 ApiVendor::Openai => "OPENAI_API_KEY".to_string(),
1580 });
1581 let endpoint = config
1582 .base_url
1583 .clone()
1584 .unwrap_or_else(|| match config.vendor {
1585 ApiVendor::Anthropic => "https://api.anthropic.com/v1/messages".to_string(),
1586 ApiVendor::Openai => "https://api.openai.com/v1/chat/completions".to_string(),
1587 });
1588 Self {
1589 vendor: config.vendor,
1590 api_key_env,
1591 endpoint,
1592 timeout_secs: config.timeout_secs,
1593 curl_bin: config.curl_bin.clone(),
1594 strict_json: config.strict_json,
1595 }
1596 }
1597
1598 fn chat(
1603 &self,
1604 model: &str,
1605 system: &str,
1606 user: &str,
1607 schema: Option<serde_json::Value>,
1608 ) -> Result<ChatOutcome> {
1609 let key = std::env::var(&self.api_key_env).map_err(|_| {
1610 Error::provider_classified(
1611 "api-judge",
1612 format!("API key env var `{}` is not set", self.api_key_env),
1613 ProviderErrorKind::Auth,
1614 )
1615 })?;
1616 let body = build_chat_body(self.vendor, model, system, user, schema);
1617 let payload = serde_json::to_vec(&body)
1618 .map_err(|e| Error::provider("api-judge", format!("could not encode request: {e}")))?;
1619
1620 let mut attempt = 0;
1621 loop {
1622 let result = self
1623 .run_curl(&key, &payload)
1624 .and_then(|raw| parse_chat_response(self.vendor, &raw));
1625 match result {
1626 Ok(outcome) => return Ok(outcome),
1627 Err(err) if attempt < MAX_RETRIES && is_retryable(&err) => {
1628 attempt += 1;
1629 std::thread::sleep(std::time::Duration::from_millis(500 * (1 << attempt)));
1630 }
1631 Err(err) => return Err(err),
1632 }
1633 }
1634 }
1635
1636 fn headers(&self, key: &str) -> Vec<(String, String)> {
1638 match self.vendor {
1639 ApiVendor::Anthropic => vec![
1640 ("x-api-key".to_string(), key.to_string()),
1641 ("anthropic-version".to_string(), "2023-06-01".to_string()),
1642 ("content-type".to_string(), "application/json".to_string()),
1643 ],
1644 ApiVendor::Openai => vec![
1645 ("authorization".to_string(), format!("Bearer {key}")),
1646 ("content-type".to_string(), "application/json".to_string()),
1647 ],
1648 }
1649 }
1650
1651 fn run_curl(&self, key: &str, body: &[u8]) -> Result<String> {
1654 let path = std::env::temp_dir().join(format!(
1655 "skilltest-judge-{}-{}.cfg",
1656 std::process::id(),
1657 curl_config_nonce()
1658 ));
1659 write_curl_config(&path, &self.endpoint, &self.headers(key), self.timeout_secs)?;
1660 let outcome = self.exec_curl(&path, body);
1661 let _ = std::fs::remove_file(&path);
1663 outcome
1664 }
1665
1666 fn exec_curl(&self, config_path: &std::path::Path, body: &[u8]) -> Result<String> {
1667 let mut child = Command::new(&self.curl_bin)
1668 .arg("--config")
1669 .arg(config_path)
1670 .arg("--data-binary")
1671 .arg("@-")
1672 .stdin(Stdio::piped())
1673 .stdout(Stdio::piped())
1674 .stderr(Stdio::piped())
1675 .spawn()
1676 .map_err(|e| {
1677 Error::provider(
1678 "api-judge",
1679 format!(
1680 "could not run `{}`: {e}. Is curl installed and on PATH?",
1681 self.curl_bin
1682 ),
1683 )
1684 })?;
1685
1686 child
1687 .stdin
1688 .as_mut()
1689 .ok_or_else(|| Error::provider("api-judge", "could not open curl stdin"))?
1690 .write_all(body)
1691 .map_err(|e| Error::provider("api-judge", format!("could not write request: {e}")))?;
1692
1693 let output = child
1694 .wait_with_output()
1695 .map_err(|e| Error::provider("api-judge", format!("curl did not complete: {e}")))?;
1696
1697 if !output.status.success() {
1698 let stderr = String::from_utf8_lossy(&output.stderr);
1699 let message = format!("curl failed ({}): {}", output.status, stderr.trim());
1700 return Err(match output.status.code() {
1703 Some(28) => {
1704 Error::provider_classified("api-judge", message, ProviderErrorKind::Timeout)
1705 }
1706 _ => Error::provider("api-judge", message),
1707 });
1708 }
1709 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1710 }
1711}
1712
1713impl Provider for ApiJudgeProvider {
1714 fn respond(
1715 &self,
1716 _platform: &str,
1717 _model: &str,
1718 _skill: &SkillRef<'_>,
1719 _messages: &[Message],
1720 _session: Option<&str>,
1721 ) -> Result<AssistantTurn> {
1722 Err(Error::provider(
1723 "api-judge",
1724 "the API judge does not run skills; use it as the judge in a SplitProvider",
1725 ))
1726 }
1727
1728 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1729 let prompt = build_user_prompt(persona, messages);
1730 let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, None)?;
1732 Ok(UserTurn {
1733 message: outcome.text.trim().to_string(),
1734 stop: false,
1735 usage: outcome.usage,
1736 })
1737 }
1738
1739 fn judge(
1740 &self,
1741 model: &str,
1742 query: &JudgeQuery<'_>,
1743 messages: &[Message],
1744 ) -> Result<JudgeVerdict> {
1745 let prompt = build_judge_prompt(query, messages);
1746 let schema = self.strict_json.then(|| verdict_schema(query.kind));
1749 let outcome = self.chat(model, JUDGE_SYSTEM, &prompt, schema)?;
1750 let mut verdict = parse_verdict(query.kind, &outcome.text)?;
1751 verdict.usage = outcome.usage;
1752 Ok(verdict)
1753 }
1754}
1755
1756pub struct SplitProvider {
1762 responder: Box<dyn Provider>,
1763 judge: ApiJudgeProvider,
1764}
1765
1766impl SplitProvider {
1767 #[must_use]
1769 pub fn new(responder: Box<dyn Provider>, judge: ApiJudgeProvider) -> Self {
1770 Self { responder, judge }
1771 }
1772}
1773
1774impl Provider for SplitProvider {
1775 fn respond(
1776 &self,
1777 platform: &str,
1778 model: &str,
1779 skill: &SkillRef<'_>,
1780 messages: &[Message],
1781 session: Option<&str>,
1782 ) -> Result<AssistantTurn> {
1783 self.responder
1784 .respond(platform, model, skill, messages, session)
1785 }
1786
1787 fn respond_with_mocks(
1788 &self,
1789 platform: &str,
1790 model: &str,
1791 skill: &SkillRef<'_>,
1792 messages: &[Message],
1793 session: Option<&str>,
1794 mocks: Option<&MockPlan<'_>>,
1795 ) -> Result<AssistantTurn> {
1796 self.responder
1797 .respond_with_mocks(platform, model, skill, messages, session, mocks)
1798 }
1799
1800 #[allow(clippy::too_many_arguments)]
1803 fn respond_streaming_with_mocks(
1804 &self,
1805 platform: &str,
1806 model: &str,
1807 skill: &SkillRef<'_>,
1808 messages: &[Message],
1809 session: Option<&str>,
1810 mocks: Option<&MockPlan<'_>>,
1811 on_event: &mut dyn FnMut(&ToolEvent) -> ControlFlow<()>,
1812 ) -> Result<AssistantTurn> {
1813 self.responder.respond_streaming_with_mocks(
1814 platform, model, skill, messages, session, mocks, on_event,
1815 )
1816 }
1817
1818 fn simulate_user(&self, model: &str, persona: &str, messages: &[Message]) -> Result<UserTurn> {
1819 self.judge.simulate_user(model, persona, messages)
1820 }
1821
1822 fn judge(
1823 &self,
1824 model: &str,
1825 query: &JudgeQuery<'_>,
1826 messages: &[Message],
1827 ) -> Result<JudgeVerdict> {
1828 self.judge.judge(model, query, messages)
1829 }
1830
1831 fn supports_resume(&self, platform: &str) -> bool {
1832 self.responder.supports_resume(platform)
1833 }
1834}
1835
1836fn curl_config_nonce() -> u64 {
1839 use std::sync::atomic::{AtomicU64, Ordering};
1840 static COUNTER: AtomicU64 = AtomicU64::new(0);
1841 COUNTER.fetch_add(1, Ordering::Relaxed)
1842}
1843
1844fn curl_escape(value: &str) -> String {
1846 value.replace('\\', "\\\\").replace('"', "\\\"")
1847}
1848
1849fn write_curl_config(
1853 path: &std::path::Path,
1854 url: &str,
1855 headers: &[(String, String)],
1856 timeout_secs: u64,
1857) -> Result<()> {
1858 let mut config = String::new();
1859 config.push_str(&format!("url = \"{}\"\n", curl_escape(url)));
1860 config.push_str("request = \"POST\"\n");
1861 for (name, value) in headers {
1862 config.push_str(&format!("header = \"{}: {}\"\n", name, curl_escape(value)));
1863 }
1864 config.push_str(&format!("max-time = {timeout_secs}\n"));
1865 config.push_str("silent\nshow-error\n");
1866
1867 let mut options = std::fs::OpenOptions::new();
1868 options.write(true).create(true).truncate(true);
1869 #[cfg(unix)]
1870 {
1871 use std::os::unix::fs::OpenOptionsExt as _;
1872 options.mode(0o600);
1873 }
1874 let mut file = options
1875 .open(path)
1876 .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1877 file.write_all(config.as_bytes())
1878 .map_err(|e| Error::provider("api-judge", format!("could not write curl config: {e}")))?;
1879 Ok(())
1880}
1881
1882fn verdict_schema(kind: JudgeKind) -> serde_json::Value {
1887 let value_type = match kind {
1888 JudgeKind::Boolean => "boolean",
1889 JudgeKind::Numeric => "number",
1890 };
1891 serde_json::json!({
1892 "type": "object",
1893 "properties": {
1894 "value": { "type": value_type },
1895 "reason": { "type": "string" },
1896 },
1897 "required": ["value", "reason"],
1898 "additionalProperties": false,
1899 })
1900}
1901
1902fn build_chat_body(
1907 vendor: ApiVendor,
1908 model: &str,
1909 system: &str,
1910 user: &str,
1911 schema: Option<serde_json::Value>,
1912) -> serde_json::Value {
1913 match vendor {
1914 ApiVendor::Anthropic => {
1915 let mut body = serde_json::json!({
1916 "model": model,
1917 "max_tokens": 1024,
1918 "system": system,
1919 "messages": [{ "role": "user", "content": user }],
1920 });
1921 if let Some(schema) = schema {
1922 body["output_config"] =
1923 serde_json::json!({ "format": { "type": "json_schema", "schema": schema } });
1924 }
1925 body
1926 }
1927 ApiVendor::Openai => {
1928 let mut body = serde_json::json!({
1929 "model": model,
1930 "max_tokens": 1024,
1931 "messages": [
1932 { "role": "system", "content": system },
1933 { "role": "user", "content": user },
1934 ],
1935 });
1936 if let Some(schema) = schema {
1937 body["response_format"] = serde_json::json!({
1938 "type": "json_schema",
1939 "json_schema": { "name": "verdict", "strict": true, "schema": schema },
1940 });
1941 }
1942 body
1943 }
1944 }
1945}
1946
1947fn is_retryable(err: &Error) -> bool {
1949 matches!(
1950 err,
1951 Error::Provider {
1952 kind: Some(ProviderErrorKind::RateLimit | ProviderErrorKind::Overloaded),
1953 ..
1954 }
1955 )
1956}
1957
1958#[derive(Deserialize)]
1962struct ApiErrorBody {
1963 #[serde(rename = "type", default)]
1964 kind: Option<String>,
1965 #[serde(default)]
1966 message: Option<String>,
1967}
1968
1969#[derive(Deserialize)]
1970struct AnthropicBlock {
1971 #[serde(rename = "type")]
1972 kind: String,
1973 #[serde(default)]
1974 text: Option<String>,
1975}
1976
1977#[derive(Deserialize)]
1978struct AnthropicUsage {
1979 #[serde(default)]
1980 input_tokens: Option<u64>,
1981 #[serde(default)]
1982 output_tokens: Option<u64>,
1983}
1984
1985#[derive(Deserialize)]
1986struct AnthropicResponse {
1987 #[serde(default)]
1988 content: Vec<AnthropicBlock>,
1989 #[serde(default)]
1990 usage: Option<AnthropicUsage>,
1991 #[serde(default)]
1992 stop_reason: Option<String>,
1993 #[serde(default)]
1994 error: Option<ApiErrorBody>,
1995}
1996
1997#[derive(Deserialize)]
1998struct OpenAiMessage {
1999 #[serde(default)]
2000 content: Option<String>,
2001}
2002
2003#[derive(Deserialize)]
2004struct OpenAiChoice {
2005 #[serde(default)]
2006 message: Option<OpenAiMessage>,
2007}
2008
2009#[derive(Deserialize)]
2010struct OpenAiUsage {
2011 #[serde(default)]
2012 prompt_tokens: Option<u64>,
2013 #[serde(default)]
2014 completion_tokens: Option<u64>,
2015}
2016
2017#[derive(Deserialize)]
2018struct OpenAiResponse {
2019 #[serde(default)]
2020 choices: Vec<OpenAiChoice>,
2021 #[serde(default)]
2022 usage: Option<OpenAiUsage>,
2023 #[serde(default)]
2024 error: Option<ApiErrorBody>,
2025}
2026
2027fn classify_api_error(kind: Option<&str>) -> Option<ProviderErrorKind> {
2031 match kind? {
2032 "authentication_error" | "invalid_api_key" | "permission_error" => {
2033 Some(ProviderErrorKind::Auth)
2034 }
2035 "rate_limit_error" | "rate_limit_exceeded" => Some(ProviderErrorKind::RateLimit),
2036 "insufficient_quota" | "billing_error" => Some(ProviderErrorKind::Quota),
2037 "not_found_error" => Some(ProviderErrorKind::ModelNotFound),
2038 "overloaded_error" | "api_error" | "server_error" | "service_unavailable" => {
2041 Some(ProviderErrorKind::Overloaded)
2042 }
2043 _ => None,
2044 }
2045}
2046
2047fn api_error(err: ApiErrorBody) -> Error {
2048 let message = err
2049 .message
2050 .unwrap_or_else(|| "API returned an error".to_string());
2051 match classify_api_error(err.kind.as_deref()) {
2052 Some(kind) => Error::provider_classified("api-judge", message, kind),
2053 None => Error::provider("api-judge", message),
2054 }
2055}
2056
2057fn truncate_for_error(raw: &str) -> String {
2059 raw.chars().take(500).collect()
2060}
2061
2062fn parse_chat_response(vendor: ApiVendor, raw: &str) -> Result<ChatOutcome> {
2064 match vendor {
2065 ApiVendor::Anthropic => {
2066 let resp: AnthropicResponse = serde_json::from_str(raw.trim()).map_err(|e| {
2067 Error::provider(
2068 "api-judge",
2069 format!(
2070 "could not parse API response: {e}; got: {}",
2071 truncate_for_error(raw)
2072 ),
2073 )
2074 })?;
2075 if let Some(err) = resp.error {
2076 return Err(api_error(err));
2077 }
2078 let text = resp
2079 .content
2080 .iter()
2081 .filter(|b| b.kind == "text")
2082 .filter_map(|b| b.text.as_deref())
2083 .collect::<String>();
2084 if text.trim().is_empty() {
2085 return Err(Error::provider(
2086 "api-judge",
2087 format!(
2088 "judge returned no text (stop_reason: {:?})",
2089 resp.stop_reason
2090 ),
2091 ));
2092 }
2093 let usage = resp.usage.map(|u| Usage {
2094 input_tokens: u.input_tokens,
2095 output_tokens: u.output_tokens,
2096 cost_usd: None,
2097 });
2098 Ok(ChatOutcome { text, usage })
2099 }
2100 ApiVendor::Openai => {
2101 let resp: OpenAiResponse = serde_json::from_str(raw.trim()).map_err(|e| {
2102 Error::provider(
2103 "api-judge",
2104 format!(
2105 "could not parse API response: {e}; got: {}",
2106 truncate_for_error(raw)
2107 ),
2108 )
2109 })?;
2110 if let Some(err) = resp.error {
2111 return Err(api_error(err));
2112 }
2113 let text = resp
2114 .choices
2115 .into_iter()
2116 .next()
2117 .and_then(|c| c.message)
2118 .and_then(|m| m.content)
2119 .unwrap_or_default();
2120 if text.trim().is_empty() {
2121 return Err(Error::provider("api-judge", "judge returned no text"));
2122 }
2123 let usage = resp.usage.map(|u| Usage {
2124 input_tokens: u.prompt_tokens,
2125 output_tokens: u.completion_tokens,
2126 cost_usd: None,
2127 });
2128 Ok(ChatOutcome { text, usage })
2129 }
2130 }
2131}
2132
2133fn render_transcript(messages: &[Message]) -> String {
2137 messages
2138 .iter()
2139 .map(|m| {
2140 let role = match m.role {
2141 Role::User => "User",
2142 Role::Assistant => "Assistant",
2143 Role::System => "System",
2144 };
2145 format!("{role}: {}", m.content)
2146 })
2147 .collect::<Vec<_>>()
2148 .join("\n")
2149}
2150
2151fn render_transcript_for_respond(messages: &[Message]) -> String {
2155 format!(
2156 "Conversation so far (most recent last):\n{}\n\n\
2157 Write only the assistant's next reply, following your system \
2158 instructions. Output the reply text and nothing else.",
2159 render_transcript(messages),
2160 )
2161}
2162
2163fn latest_user_message(messages: &[Message]) -> Option<String> {
2166 messages
2167 .iter()
2168 .rev()
2169 .find(|m| m.role == Role::User)
2170 .map(|m| m.content.clone())
2171}
2172
2173fn build_user_prompt(persona: &str, messages: &[Message]) -> String {
2174 format!(
2175 "You are role-playing the USER in a conversation with an AI assistant. \
2176 Stay in character:\n\n{persona}\n\n\
2177 Conversation so far (most recent last):\n{transcript}\n\n\
2178 Write only the user's next message. Output the message text and nothing \
2179 else.",
2180 transcript = render_transcript(messages),
2181 )
2182}
2183
2184fn build_judge_prompt(query: &JudgeQuery<'_>, messages: &[Message]) -> String {
2185 let transcript = render_transcript(messages);
2186 match query.kind {
2187 JudgeKind::Boolean => format!(
2188 "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
2189 Criterion: {criterion}\n\n\
2190 Transcript:\n{transcript}\n\n\
2191 Decide whether the criterion is satisfied. Respond with ONLY a \
2192 single-line JSON object and nothing else:\n\
2193 {{\"value\": true or false, \"reason\": \"<one short sentence>\"}}",
2194 criterion = query.criterion,
2195 ),
2196 JudgeKind::Numeric => {
2197 let (min, max) = query.scale.unwrap_or((0.0, 10.0));
2198 format!(
2199 "You are a strict, careful evaluator of an AI assistant's behavior.\n\n\
2200 Criterion: {criterion}\n\n\
2201 Transcript:\n{transcript}\n\n\
2202 Score how well the criterion is satisfied on a scale from {min} to \
2203 {max} (inclusive). Respond with ONLY a single-line JSON object and \
2204 nothing else:\n\
2205 {{\"value\": <number between {min} and {max}>, \"reason\": \"<one short sentence>\"}}",
2206 criterion = query.criterion,
2207 )
2208 }
2209 }
2210}
2211
2212fn extract_json_object(text: &str) -> Option<&str> {
2215 let start = text.find('{')?;
2216 let end = text.rfind('}')?;
2217 if end > start {
2218 Some(&text[start..=end])
2219 } else {
2220 None
2221 }
2222}
2223
2224fn parse_verdict(kind: JudgeKind, text: &str) -> Result<JudgeVerdict> {
2225 let json = extract_json_object(text).ok_or_else(|| {
2226 Error::provider(
2227 "oneharness:judge",
2228 format!("judge did not return a JSON object; got: {text}"),
2229 )
2230 })?;
2231 let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
2232 Error::provider(
2233 "oneharness:judge",
2234 format!("judge verdict was not valid JSON: {e}; got: {json}"),
2235 )
2236 })?;
2237 let reason = value
2238 .get("reason")
2239 .and_then(serde_json::Value::as_str)
2240 .unwrap_or("")
2241 .to_string();
2242 let raw = value
2243 .get("value")
2244 .ok_or_else(|| Error::provider("oneharness:judge", "judge verdict has no `value` field"))?;
2245
2246 let verdict_value = match kind {
2247 JudgeKind::Boolean => JudgeValue::Bool(raw.as_bool().ok_or_else(|| {
2248 Error::provider(
2249 "oneharness:judge",
2250 format!("boolean judge `value` was not a bool: {raw}"),
2251 )
2252 })?),
2253 JudgeKind::Numeric => JudgeValue::Number(raw.as_f64().ok_or_else(|| {
2254 Error::provider(
2255 "oneharness:judge",
2256 format!("numeric judge `value` was not a number: {raw}"),
2257 )
2258 })?),
2259 };
2260
2261 Ok(JudgeVerdict {
2262 value: verdict_value,
2263 reason,
2264 usage: None,
2265 })
2266}
2267
2268#[cfg(test)]
2269mod tests {
2270 use super::*;
2271
2272 #[test]
2273 fn empty_argv_is_rejected() {
2274 assert!(CommandProvider::new(vec![]).is_err());
2275 }
2276
2277 #[test]
2278 fn request_serializes_with_op_tag() {
2279 let req = Request::Judge {
2280 model: "m",
2281 kind: "numeric",
2282 criterion: "polite",
2283 min: Some(0.0),
2284 max: Some(10.0),
2285 messages: &[],
2286 };
2287 let json = serde_json::to_string(&req).unwrap();
2288 assert!(json.contains("\"op\":\"judge\""));
2289 assert!(json.contains("\"kind\":\"numeric\""));
2290 }
2291
2292 #[test]
2293 fn respond_no_session_inlines_transcript_but_not_skill() {
2294 let messages = [
2297 Message::user("Hi"),
2298 Message::assistant("Hello"),
2299 Message::user("Again?"),
2300 ];
2301 let prompt = render_transcript_for_respond(&messages);
2302 assert!(prompt.contains("User: Hi"));
2303 assert!(prompt.contains("Assistant: Hello"));
2304 assert!(prompt.contains("User: Again?"));
2305 assert!(!prompt.contains("SKILL"));
2307 }
2308
2309 #[test]
2310 fn respond_with_session_sends_only_latest_user_message() {
2311 let messages = [
2312 Message::user("Hi"),
2313 Message::assistant("Hello"),
2314 Message::user("Again?"),
2315 ];
2316 assert_eq!(latest_user_message(&messages).as_deref(), Some("Again?"));
2317 }
2318
2319 #[test]
2320 fn extracts_json_from_fenced_or_prose_text() {
2321 assert_eq!(
2322 extract_json_object("```json\n{\"value\": true}\n```"),
2323 Some("{\"value\": true}")
2324 );
2325 assert_eq!(
2326 extract_json_object("Sure! {\"value\": 8, \"reason\": \"x\"} done"),
2327 Some("{\"value\": 8, \"reason\": \"x\"}")
2328 );
2329 assert_eq!(extract_json_object("no json here"), None);
2330 }
2331
2332 #[test]
2333 fn parses_boolean_and_numeric_verdicts() {
2334 let b = parse_verdict(JudgeKind::Boolean, "{\"value\": true, \"reason\": \"ok\"}").unwrap();
2335 assert!(matches!(b.value, JudgeValue::Bool(true)));
2336 assert_eq!(b.reason, "ok");
2337
2338 let n =
2339 parse_verdict(JudgeKind::Numeric, "{\"value\": 8.5, \"reason\": \"good\"}").unwrap();
2340 assert!(matches!(n.value, JudgeValue::Number(v) if (v - 8.5).abs() < f64::EPSILON));
2341 }
2342
2343 #[test]
2344 fn verdict_with_wrong_value_type_errors() {
2345 assert!(parse_verdict(JudgeKind::Boolean, "{\"value\": 3}").is_err());
2346 assert!(parse_verdict(JudgeKind::Numeric, "{\"value\": true}").is_err());
2347 assert!(parse_verdict(JudgeKind::Boolean, "no json").is_err());
2348 }
2349
2350 #[test]
2351 fn usage_accumulates_independently_per_field() {
2352 let mut total = Usage::default();
2353 total.add(&Usage {
2354 input_tokens: Some(10),
2355 output_tokens: None,
2356 cost_usd: Some(0.01),
2357 });
2358 total.add(&Usage {
2359 input_tokens: Some(5),
2360 output_tokens: Some(3),
2361 cost_usd: None,
2362 });
2363 assert_eq!(total.input_tokens, Some(15));
2364 assert_eq!(total.output_tokens, Some(3));
2365 assert!((total.cost_usd.unwrap() - 0.01).abs() < f64::EPSILON);
2366 assert!(!total.is_empty());
2367 }
2368
2369 #[test]
2370 fn reply_text_prefers_extracted_then_falls_back_to_stdout() {
2371 assert_eq!(
2373 select_reply_text(Some("clean reply".into()), "raw noise"),
2374 Some("clean reply".into())
2375 );
2376 assert_eq!(
2379 select_reply_text(None, "{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}"),
2380 Some("{\"type\":\"text\",\"part\":{\"text\":\"pong\"}}".into())
2381 );
2382 assert_eq!(
2383 select_reply_text(Some(" ".into()), "fallback"),
2384 Some("fallback".into())
2385 );
2386 assert_eq!(select_reply_text(None, " \n"), None);
2388 assert_eq!(select_reply_text(Some(String::new()), ""), None);
2389 }
2390
2391 #[test]
2392 fn supports_resume_covers_known_harnesses() {
2393 assert!(supports_resume("claude-code"));
2394 assert!(supports_resume("opencode"));
2395 assert!(supports_resume("cursor"));
2396 assert!(!supports_resume("codex"));
2397 assert!(!supports_resume("goose"));
2398 }
2399
2400 #[test]
2401 fn slug_is_lowercase_hyphenated_and_bounded() {
2402 assert_eq!(slug("Greet Dr. Smith!"), "greet-dr-smith");
2403 assert_eq!(slug(" --weird__name-- "), "weird-name");
2404 assert_eq!(slug(""), "x");
2406 assert_eq!(slug("!!!"), "x");
2407 let long = slug(&"a b ".repeat(40));
2409 assert!(long.len() <= 32, "len {}", long.len());
2410 assert!(!long.ends_with('-'));
2411 }
2412
2413 #[test]
2414 fn history_session_name_is_stable_and_case_distinguishing() {
2415 let a = [Message::user("Greet Dr. Smith")];
2416 let b = [Message::user("Book an appointment")];
2417 assert_eq!(
2419 history_session_name("claude-code", "sonnet", &a),
2420 history_session_name("claude-code", "sonnet", &a),
2421 );
2422 assert_ne!(
2425 history_session_name("claude-code", "sonnet", &a),
2426 history_session_name("claude-code", "sonnet", &b),
2427 );
2428 let name = history_session_name("claude-code", "sonnet", &a);
2429 assert!(name.starts_with("skilltest-claude-code-sonnet-greet-dr-smith-"));
2430 assert!(name
2432 .chars()
2433 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
2434 assert!(history_session_name("cursor", "", &a).contains("-default-"));
2436 }
2437
2438 #[test]
2439 fn shell_quote_leaves_safe_tokens_and_wraps_the_rest() {
2440 assert_eq!(shell_quote("oneharness"), "oneharness");
2441 assert_eq!(shell_quote("/a/b-c.d"), "/a/b-c.d");
2442 assert_eq!(shell_quote("has space"), "'has space'");
2443 assert_eq!(shell_quote("a'b"), "'a'\\''b'");
2445 assert_eq!(shell_quote(""), "''");
2446 }
2447
2448 #[test]
2449 fn history_view_command_is_runnable() {
2450 let cmd = history_view_command(
2451 "oneharness",
2452 Path::new("/home/u/.local/state/skilltest/oneharness-history"),
2453 "skilltest-claude-code-sonnet-greet-1a2b3c4d",
2454 );
2455 assert_eq!(
2456 cmd,
2457 "oneharness history show skilltest-claude-code-sonnet-greet-1a2b3c4d \
2458 --history-dir /home/u/.local/state/skilltest/oneharness-history",
2459 );
2460 let spaced = history_view_command("oneharness", Path::new("/tmp/my runs"), "n");
2462 assert!(spaced.contains("--history-dir '/tmp/my runs'"), "{spaced}");
2463 }
2464
2465 #[test]
2466 fn new_resolves_default_history_dir_when_config_unset() {
2467 let provider = OneharnessProvider::new(&OneharnessConfig::default());
2470 assert!(provider.history);
2471 assert!(!provider.history_dir.as_os_str().is_empty());
2472
2473 let provider = OneharnessProvider::new(&OneharnessConfig {
2475 history_dir: Some("/shared/hist".to_string()),
2476 ..OneharnessConfig::default()
2477 });
2478 assert_eq!(provider.history_dir, PathBuf::from("/shared/hist"));
2479 }
2480
2481 #[test]
2482 fn resolve_history_dir_prefers_override_then_xdg_then_home() {
2483 use std::ffi::OsString;
2484 let os = |s: &str| Some(OsString::from(s));
2485
2486 assert_eq!(
2488 resolve_history_dir(os("/shared/history"), os("/xdg"), os("/home/u")),
2489 PathBuf::from("/shared/history"),
2490 );
2491 assert_eq!(
2493 resolve_history_dir(None, os("/xdg/state"), os("/home/u")),
2494 PathBuf::from("/xdg/state/skilltest/oneharness-history"),
2495 );
2496 assert_eq!(
2498 resolve_history_dir(None, None, os("/home/u")),
2499 PathBuf::from("/home/u/.local/state/skilltest/oneharness-history"),
2500 );
2501 assert_eq!(
2503 resolve_history_dir(os(""), Some(OsString::new()), os("/home/u")),
2504 PathBuf::from("/home/u/.local/state/skilltest/oneharness-history"),
2505 );
2506 assert!(resolve_history_dir(None, None, None).ends_with("skilltest/oneharness-history"));
2508 }
2509
2510 fn api_config(vendor: ApiVendor) -> ApiJudgeConfig {
2511 ApiJudgeConfig {
2512 vendor,
2513 api_key_env: None,
2514 base_url: None,
2515 timeout_secs: 60,
2516 curl_bin: "curl".to_string(),
2517 strict_json: true,
2518 }
2519 }
2520
2521 #[test]
2522 fn api_judge_resolves_vendor_defaults() {
2523 let anthropic = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
2524 assert_eq!(anthropic.api_key_env, "ANTHROPIC_API_KEY");
2525 assert_eq!(anthropic.endpoint, "https://api.anthropic.com/v1/messages");
2526
2527 let openai = ApiJudgeProvider::new(&api_config(ApiVendor::Openai));
2528 assert_eq!(openai.api_key_env, "OPENAI_API_KEY");
2529 assert_eq!(
2530 openai.endpoint,
2531 "https://api.openai.com/v1/chat/completions"
2532 );
2533 }
2534
2535 #[test]
2536 fn api_judge_honors_overrides() {
2537 let provider = ApiJudgeProvider::new(&ApiJudgeConfig {
2538 vendor: ApiVendor::Openai,
2539 api_key_env: Some("MY_KEY".to_string()),
2540 base_url: Some("https://proxy.example/v1/chat/completions".to_string()),
2541 timeout_secs: 5,
2542 curl_bin: "curl".to_string(),
2543 strict_json: true,
2544 });
2545 assert_eq!(provider.api_key_env, "MY_KEY");
2546 assert_eq!(
2547 provider.endpoint,
2548 "https://proxy.example/v1/chat/completions"
2549 );
2550 }
2551
2552 #[test]
2553 fn build_chat_body_shapes_per_vendor() {
2554 let anthropic = build_chat_body(ApiVendor::Anthropic, "claude-x", "sys", "hi", None);
2555 assert_eq!(anthropic["model"], "claude-x");
2556 assert_eq!(anthropic["system"], "sys");
2557 assert_eq!(anthropic["messages"][0]["role"], "user");
2558 assert_eq!(anthropic["messages"].as_array().unwrap().len(), 1);
2560 assert!(anthropic.get("output_config").is_none());
2562
2563 let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", None);
2564 assert_eq!(openai["messages"][0]["role"], "system");
2565 assert_eq!(openai["messages"][1]["role"], "user");
2566 assert!(openai.get("system").is_none());
2567 assert!(openai.get("response_format").is_none());
2568 }
2569
2570 #[test]
2571 fn build_chat_body_attaches_strict_schema_per_vendor() {
2572 let schema = verdict_schema(JudgeKind::Boolean);
2573 let anthropic = build_chat_body(
2574 ApiVendor::Anthropic,
2575 "claude-x",
2576 "sys",
2577 "hi",
2578 Some(schema.clone()),
2579 );
2580 assert_eq!(anthropic["output_config"]["format"]["type"], "json_schema");
2582 assert_eq!(
2583 anthropic["output_config"]["format"]["schema"]["properties"]["value"]["type"],
2584 "boolean"
2585 );
2586
2587 let numeric = verdict_schema(JudgeKind::Numeric);
2588 let openai = build_chat_body(ApiVendor::Openai, "gpt-x", "sys", "hi", Some(numeric));
2589 assert_eq!(openai["response_format"]["type"], "json_schema");
2591 assert_eq!(openai["response_format"]["json_schema"]["strict"], true);
2592 assert_eq!(
2593 openai["response_format"]["json_schema"]["schema"]["properties"]["value"]["type"],
2594 "number"
2595 );
2596 }
2597
2598 #[test]
2599 fn verdict_schema_requires_value_and_reason_with_no_extras() {
2600 let schema = verdict_schema(JudgeKind::Numeric);
2601 assert_eq!(schema["additionalProperties"], false);
2602 let required: Vec<&str> = schema["required"]
2603 .as_array()
2604 .unwrap()
2605 .iter()
2606 .map(|v| v.as_str().unwrap())
2607 .collect();
2608 assert_eq!(required, ["value", "reason"]);
2609 }
2610
2611 #[test]
2612 fn parses_anthropic_success_with_usage() {
2613 let raw = r#"{"content":[{"type":"text","text":"{\"value\": true}"}],
2614 "stop_reason":"end_turn","usage":{"input_tokens":12,"output_tokens":3}}"#;
2615 let outcome = parse_chat_response(ApiVendor::Anthropic, raw).unwrap();
2616 assert_eq!(outcome.text, "{\"value\": true}");
2617 let usage = outcome.usage.unwrap();
2618 assert_eq!(usage.input_tokens, Some(12));
2619 assert_eq!(usage.output_tokens, Some(3));
2620 assert!(usage.cost_usd.is_none());
2621 }
2622
2623 #[test]
2624 fn parses_openai_success_with_usage() {
2625 let raw = r#"{"choices":[{"message":{"content":"{\"value\": 8}"}}],
2626 "usage":{"prompt_tokens":20,"completion_tokens":4}}"#;
2627 let outcome = parse_chat_response(ApiVendor::Openai, raw).unwrap();
2628 assert_eq!(outcome.text, "{\"value\": 8}");
2629 let usage = outcome.usage.unwrap();
2630 assert_eq!(usage.input_tokens, Some(20));
2631 assert_eq!(usage.output_tokens, Some(4));
2632 }
2633
2634 #[test]
2635 fn parses_and_classifies_api_errors() {
2636 let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
2637 let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
2638 assert!(matches!(
2639 err,
2640 Error::Provider {
2641 kind: Some(ProviderErrorKind::Auth),
2642 ..
2643 }
2644 ));
2645
2646 let rate = r#"{"error":{"type":"rate_limit_exceeded","message":"slow down"}}"#;
2647 let err = parse_chat_response(ApiVendor::Openai, rate).unwrap_err();
2648 assert!(matches!(
2649 err,
2650 Error::Provider {
2651 kind: Some(ProviderErrorKind::RateLimit),
2652 ..
2653 }
2654 ));
2655 }
2656
2657 #[test]
2658 fn empty_reply_is_an_error() {
2659 let raw = r#"{"content":[],"stop_reason":"refusal"}"#;
2660 assert!(parse_chat_response(ApiVendor::Anthropic, raw).is_err());
2661 }
2662
2663 #[test]
2664 fn classify_api_error_maps_known_kinds() {
2665 assert_eq!(
2666 classify_api_error(Some("invalid_api_key")),
2667 Some(ProviderErrorKind::Auth)
2668 );
2669 assert_eq!(
2670 classify_api_error(Some("insufficient_quota")),
2671 Some(ProviderErrorKind::Quota)
2672 );
2673 assert_eq!(
2674 classify_api_error(Some("not_found_error")),
2675 Some(ProviderErrorKind::ModelNotFound)
2676 );
2677 assert_eq!(
2678 classify_api_error(Some("overloaded_error")),
2679 Some(ProviderErrorKind::Overloaded)
2680 );
2681 assert_eq!(classify_api_error(Some("something_else")), None);
2682 assert_eq!(classify_api_error(None), None);
2683 }
2684
2685 #[test]
2686 fn retryable_covers_transient_errors_only() {
2687 let overloaded = r#"{"error":{"type":"overloaded_error","message":"busy"}}"#;
2688 let err = parse_chat_response(ApiVendor::Anthropic, overloaded).unwrap_err();
2689 assert!(is_retryable(&err), "overload should retry");
2690
2691 let rate = r#"{"error":{"type":"rate_limit_error","message":"slow"}}"#;
2692 let err = parse_chat_response(ApiVendor::Anthropic, rate).unwrap_err();
2693 assert!(is_retryable(&err), "rate limit should retry");
2694
2695 let auth = r#"{"error":{"type":"authentication_error","message":"bad key"}}"#;
2696 let err = parse_chat_response(ApiVendor::Anthropic, auth).unwrap_err();
2697 assert!(!is_retryable(&err), "auth must not retry");
2698 }
2699
2700 #[test]
2701 fn curl_escape_handles_quotes_and_backslashes() {
2702 assert_eq!(curl_escape(r#"a"b\c"#), r#"a\"b\\c"#);
2703 }
2704
2705 struct StubResponder;
2708
2709 impl Provider for StubResponder {
2710 fn respond(
2711 &self,
2712 _platform: &str,
2713 _model: &str,
2714 _skill: &SkillRef<'_>,
2715 _messages: &[Message],
2716 _session: Option<&str>,
2717 ) -> Result<AssistantTurn> {
2718 Ok(AssistantTurn {
2719 message: "stub reply".to_string(),
2720 ..Default::default()
2721 })
2722 }
2723
2724 fn simulate_user(
2725 &self,
2726 _model: &str,
2727 _persona: &str,
2728 _messages: &[Message],
2729 ) -> Result<UserTurn> {
2730 unreachable!("split provider routes user simulation to the judge")
2731 }
2732
2733 fn judge(
2734 &self,
2735 _model: &str,
2736 _query: &JudgeQuery<'_>,
2737 _messages: &[Message],
2738 ) -> Result<JudgeVerdict> {
2739 unreachable!("split provider routes judging to the judge")
2740 }
2741
2742 fn supports_resume(&self, platform: &str) -> bool {
2743 platform == "claude-code"
2744 }
2745 }
2746
2747 #[test]
2748 fn split_provider_delegates_respond_and_resume() {
2749 let split = SplitProvider::new(
2750 Box::new(StubResponder),
2751 ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic)),
2752 );
2753 assert!(split.supports_resume("claude-code"));
2755 assert!(!split.supports_resume("codex"));
2756 let skill = SkillRef {
2757 name: "s",
2758 dir: "/tmp/s",
2759 instructions: "do things",
2760 };
2761 let turn = split
2762 .respond("claude-code", "m", &skill, &[], None)
2763 .unwrap();
2764 assert_eq!(turn.message, "stub reply");
2765 }
2766
2767 #[test]
2768 fn api_judge_does_not_run_skills() {
2769 let provider = ApiJudgeProvider::new(&api_config(ApiVendor::Anthropic));
2770 let skill = SkillRef {
2771 name: "s",
2772 dir: "/tmp/s",
2773 instructions: "x",
2774 };
2775 assert!(provider.respond("p", "m", &skill, &[], None).is_err());
2776 }
2777
2778 #[cfg(unix)]
2787 mod subprocess {
2788 use super::*;
2791 use std::os::unix::fs::PermissionsExt as _;
2792 use std::path::PathBuf;
2793
2794 fn script(tag: &str, body: &str) -> PathBuf {
2797 use std::sync::atomic::{AtomicU64, Ordering};
2798 static N: AtomicU64 = AtomicU64::new(0);
2799 let dir = std::env::temp_dir().join(format!(
2800 "skilltest-prov-{}-{tag}-{}",
2801 std::process::id(),
2802 N.fetch_add(1, Ordering::Relaxed)
2803 ));
2804 std::fs::create_dir_all(&dir).unwrap();
2805 let path = dir.join("script.sh");
2806 let mut f = std::fs::File::create(&path).unwrap();
2807 f.write_all(format!("#!/bin/sh\n{body}").as_bytes())
2808 .unwrap();
2809 let mut perms = std::fs::metadata(&path).unwrap().permissions();
2810 perms.set_mode(0o755);
2811 std::fs::set_permissions(&path, perms).unwrap();
2812 path
2813 }
2814
2815 fn skill_ref() -> SkillRef<'static> {
2816 SkillRef {
2817 name: "greeter",
2818 dir: "/tmp/greeter",
2819 instructions: "Be nice.",
2820 }
2821 }
2822
2823 #[test]
2826 fn command_provider_respond_parses_response() {
2827 let bin = script(
2829 "respond",
2830 "cat >/dev/null\necho '{\"message\":\"hi there\",\"done\":true,\
2831 \"usage\":{\"input_tokens\":4,\"output_tokens\":2},\"session_id\":\"s1\"}'\n",
2832 );
2833 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2834 let turn = provider
2835 .respond("demo", "fake", &skill_ref(), &[Message::user("hi")], None)
2836 .unwrap();
2837 assert_eq!(turn.message, "hi there");
2838 assert!(turn.done);
2839 assert_eq!(turn.session_id.as_deref(), Some("s1"));
2840 assert_eq!(turn.usage.unwrap().input_tokens, Some(4));
2841 }
2842
2843 #[test]
2844 fn command_provider_user_and_judge_parse_responses() {
2845 let user_bin = script(
2846 "user",
2847 "cat >/dev/null\necho '{\"message\":\"more please\",\"stop\":true}'\n",
2848 );
2849 let user_provider =
2850 CommandProvider::new(vec![user_bin.to_string_lossy().into_owned()]).unwrap();
2851 let user = user_provider.simulate_user("m", "persona", &[]).unwrap();
2852 assert_eq!(user.message, "more please");
2853 assert!(user.stop);
2854
2855 let judge_bin = script(
2856 "judge",
2857 "cat >/dev/null\necho '{\"value\":7.5,\"reason\":\"ok\"}'\n",
2858 );
2859 let judge_provider =
2860 CommandProvider::new(vec![judge_bin.to_string_lossy().into_owned()]).unwrap();
2861 let query = JudgeQuery {
2862 kind: JudgeKind::Numeric,
2863 criterion: "polite",
2864 scale: Some((0.0, 10.0)),
2865 };
2866 let verdict = judge_provider.judge("m", &query, &[]).unwrap();
2867 assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 7.5).abs() < 1e-9));
2868 assert_eq!(verdict.reason, "ok");
2869 }
2870
2871 #[test]
2872 fn command_provider_surfaces_nonzero_exit() {
2873 let bin = script("fail", "cat >/dev/null\necho 'boom' 1>&2\nexit 2\n");
2874 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2875 let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2876 let msg = err.to_string();
2877 assert!(msg.contains("provider exited"), "got: {msg}");
2878 assert!(msg.contains("boom"), "stderr is surfaced: {msg}");
2879 }
2880
2881 #[test]
2882 fn command_provider_rejects_empty_and_bad_output() {
2883 let empty = script("empty", "cat >/dev/null\n");
2884 let provider =
2885 CommandProvider::new(vec![empty.to_string_lossy().into_owned()]).unwrap();
2886 assert!(provider
2887 .judge(
2888 "m",
2889 &JudgeQuery {
2890 kind: JudgeKind::Boolean,
2891 criterion: "x",
2892 scale: None
2893 },
2894 &[],
2895 )
2896 .unwrap_err()
2897 .to_string()
2898 .contains("no output"));
2899
2900 let garbage = script("garbage", "cat >/dev/null\necho 'not json'\n");
2901 let provider =
2902 CommandProvider::new(vec![garbage.to_string_lossy().into_owned()]).unwrap();
2903 assert!(provider
2904 .respond("demo", "m", &skill_ref(), &[], None)
2905 .unwrap_err()
2906 .to_string()
2907 .contains("not valid JSON"));
2908 }
2909
2910 #[test]
2911 fn command_provider_reports_missing_binary() {
2912 let provider =
2913 CommandProvider::new(vec!["/no/such/skilltest-provider-binary".to_string()])
2914 .unwrap();
2915 let err = provider.simulate_user("m", "p", &[]).unwrap_err();
2916 assert!(err.to_string().contains("could not run provider"));
2917 }
2918
2919 #[test]
2920 fn command_provider_session_is_threaded_into_request() {
2921 let dir =
2924 std::env::temp_dir().join(format!("skilltest-prov-sess-{}", std::process::id()));
2925 std::fs::create_dir_all(&dir).unwrap();
2926 let seen = dir.join("seen.json");
2927 let bin = script(
2928 "session",
2929 &format!(
2930 "cat > '{}'\necho '{{\"message\":\"ok\"}}'\n",
2931 seen.display()
2932 ),
2933 );
2934 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2935 provider
2936 .respond(
2937 "demo",
2938 "m",
2939 &skill_ref(),
2940 &[Message::user("hi")],
2941 Some("session-xyz"),
2942 )
2943 .unwrap();
2944 let request = std::fs::read_to_string(&seen).unwrap();
2945 assert!(
2946 request.contains("\"session\":\"session-xyz\""),
2947 "got: {request}"
2948 );
2949 assert!(request.contains("\"op\":\"respond\""));
2950 }
2951
2952 #[test]
2953 fn command_provider_threads_mocks_and_parses_records() {
2954 let dir =
2956 std::env::temp_dir().join(format!("skilltest-prov-mocks-{}", std::process::id()));
2957 std::fs::create_dir_all(&dir).unwrap();
2958 let seen = dir.join("seen.json");
2959 let bin = script(
2960 "mocks",
2961 &format!(
2962 "cat > '{}'\necho '{{\"message\":\"ok\",\"mock_calls\":[{{\"tool\":\"bash\", \"input\":{{\"command\":\"git push\"}},\"action\":\"stub\",\"rule\":0}}]}}'\n",
2963 seen.display()
2964 ),
2965 );
2966 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2967 let rules = serde_json::json!({ "rules": [] });
2968 let plan = MockPlan {
2969 rules: Some(&rules),
2970 };
2971 let turn = provider
2972 .respond_with_mocks("demo", "m", &skill_ref(), &[], None, Some(&plan))
2973 .unwrap();
2974 let records = turn.mock_calls.expect("channel was on");
2975 assert_eq!(records.len(), 1);
2976 assert_eq!(records[0].action, "stub");
2977 assert_eq!(records[0].rule, Some(0));
2978 let request = std::fs::read_to_string(&seen).unwrap();
2980 assert!(
2981 request.contains("\"mocks\":{\"rules\":{\"rules\":[]}}"),
2982 "got: {request}"
2983 );
2984 }
2985
2986 #[test]
2987 fn command_provider_ignoring_mocks_is_loud() {
2988 let bin = script(
2991 "mocks-ignored",
2992 "cat >/dev/null\necho '{\"message\":\"ok\"}'\n",
2993 );
2994 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
2995 let plan = MockPlan { rules: None };
2996 let err = provider
2997 .respond_with_mocks("demo", "m", &skill_ref(), &[], None, Some(&plan))
2998 .unwrap_err();
2999 assert!(
3000 err.to_string().contains("ignored the request's `mocks`"),
3001 "{err}"
3002 );
3003 }
3004
3005 #[test]
3006 fn default_provider_rejects_mocks_loudly() {
3007 let plan = MockPlan { rules: None };
3010 let err = super::StubResponder
3011 .respond_with_mocks("p", "m", &skill_ref(), &[], None, Some(&plan))
3012 .unwrap_err();
3013 assert!(
3014 err.to_string().contains("does not support tool mocking"),
3015 "{err}"
3016 );
3017 let turn = super::StubResponder
3019 .respond_with_mocks("p", "m", &skill_ref(), &[], None, None)
3020 .unwrap();
3021 assert_eq!(turn.message, "stub reply");
3022 }
3023
3024 #[test]
3025 fn default_streaming_rejects_mocks_and_delegates_without() {
3026 let plan = MockPlan { rules: None };
3029 let err = super::StubResponder
3030 .respond_streaming_with_mocks(
3031 "p",
3032 "m",
3033 &skill_ref(),
3034 &[],
3035 None,
3036 Some(&plan),
3037 &mut |_| ControlFlow::Continue(()),
3038 )
3039 .unwrap_err();
3040 assert!(err.to_string().contains("does not support tool mocking"));
3041 let turn = super::StubResponder
3042 .respond_streaming_with_mocks("p", "m", &skill_ref(), &[], None, None, &mut |_| {
3043 ControlFlow::Continue(())
3044 })
3045 .unwrap();
3046 assert_eq!(turn.message, "stub reply");
3047 }
3048
3049 #[test]
3050 fn command_provider_streaming_with_mocks_replays_events() {
3051 let bin = script(
3054 "mocks-stream",
3055 "cat >/dev/null\necho '{\"message\":\"ok\",\"events\":[{\"kind\":\"tool_call\",\"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":0}],\"mock_calls\":[]}'\n",
3056 );
3057 let provider = CommandProvider::new(vec![bin.to_string_lossy().into_owned()]).unwrap();
3058 let plan = MockPlan { rules: None };
3059 let mut seen = 0usize;
3060 let turn = provider
3061 .respond_streaming_with_mocks(
3062 "demo",
3063 "m",
3064 &skill_ref(),
3065 &[],
3066 None,
3067 Some(&plan),
3068 &mut |event| {
3069 seen += 1;
3070 assert_eq!(event.name.as_deref(), Some("bash"));
3071 ControlFlow::Break(())
3072 },
3073 )
3074 .unwrap();
3075 assert_eq!(seen, 1);
3076 assert_eq!(turn.mock_calls, Some(Vec::new()));
3077 }
3078
3079 const TEST_HISTORY_DIR: &str = "/tmp/skilltest-test-history";
3085
3086 fn oh_provider(bin: PathBuf) -> OneharnessProvider {
3087 oh_provider_cfg(bin, true)
3088 }
3089
3090 fn oh_provider_cfg(bin: PathBuf, history: bool) -> OneharnessProvider {
3091 OneharnessProvider::new(&OneharnessConfig {
3092 bin: bin.to_string_lossy().into_owned(),
3093 judge_harness: "claude-code".to_string(),
3094 timeout_secs: 30,
3095 history,
3096 history_dir: Some(TEST_HISTORY_DIR.to_string()),
3097 })
3098 }
3099
3100 #[test]
3101 fn oneharness_respond_extracts_text_and_session() {
3102 let bin = script(
3103 "oh-ok",
3104 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
3105 \"text\":\" hello back \",\"session_id\":\"oh1\",\
3106 \"usage\":{\"input_tokens\":5}}]}'\n",
3107 );
3108 let turn = oh_provider(bin)
3109 .respond(
3110 "claude-code",
3111 "sonnet",
3112 &skill_ref(),
3113 &[Message::user("hi")],
3114 None,
3115 )
3116 .unwrap();
3117 assert_eq!(turn.message, "hello back");
3118 assert_eq!(turn.session_id.as_deref(), Some("oh1"));
3119 assert_eq!(turn.usage.unwrap().input_tokens, Some(5));
3120 assert!(turn.events.is_empty());
3121 }
3122
3123 #[test]
3124 fn oneharness_respond_surfaces_normalized_events() {
3125 let bin = script(
3129 "oh-events",
3130 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
3131 \"text\":\"done\",\"events\":[{\"kind\":\"tool_call\",\"name\":\"bash\",\
3132 \"input\":{\"command\":\"git commit -m x\"},\"output\":\"ok\",\"index\":0}]}]}'\n",
3133 );
3134 let turn = oh_provider(bin)
3135 .respond(
3136 "claude-code",
3137 "sonnet",
3138 &skill_ref(),
3139 &[Message::user("hi")],
3140 None,
3141 )
3142 .unwrap();
3143 assert_eq!(turn.events.len(), 1);
3144 assert_eq!(turn.events[0].kind, "tool_call");
3145 assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
3146 assert_eq!(
3147 turn.events[0].input,
3148 Some(serde_json::json!({"command": "git commit -m x"}))
3149 );
3150 assert_eq!(turn.events[0].output.as_deref(), Some("ok"));
3151 }
3152
3153 #[test]
3154 fn oneharness_respond_events_absent_is_empty_not_error() {
3155 let bin = script(
3158 "oh-noevents",
3159 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
3160 );
3161 let turn = oh_provider(bin)
3162 .respond("goose", "m", &skill_ref(), &[Message::user("hi")], None)
3163 .unwrap();
3164 assert!(turn.events.is_empty());
3165 }
3166
3167 #[test]
3168 fn oneharness_stream_forwards_events_then_parses_the_result() {
3169 let bin = script(
3174 "oh-stream",
3175 "cat >/dev/null\n\
3176 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
3177 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":0}}'\n\
3178 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
3179 \"text\":\" done \",\"session_id\":\"s1\",\"usage\":{\"input_tokens\":7}}]}}'\n",
3180 );
3181 let mut seen = Vec::new();
3182 let turn = oh_provider(bin)
3183 .respond_streaming(
3184 "claude-code",
3185 "sonnet",
3186 &skill_ref(),
3187 &[Message::user("hi")],
3188 None,
3189 &mut |event| {
3190 seen.push(event.name.clone());
3191 ControlFlow::Continue(())
3192 },
3193 )
3194 .unwrap();
3195 assert_eq!(seen, vec![Some("bash".to_string())]);
3197 assert_eq!(turn.message, "done");
3198 assert_eq!(turn.session_id.as_deref(), Some("s1"));
3199 assert_eq!(turn.usage.unwrap().input_tokens, Some(7));
3200 assert_eq!(turn.events.len(), 1);
3201 assert_eq!(turn.events[0].name.as_deref(), Some("bash"));
3202 }
3203
3204 #[test]
3205 fn oneharness_stream_short_circuits_on_break() {
3206 let bin = script(
3210 "oh-stream-abort",
3211 "cat >/dev/null\n\
3212 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
3213 \"name\":\"rm\",\"input\":{\"command\":\"rm -rf /\"},\"index\":0}}'\n\
3214 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
3215 \"name\":\"bash\",\"input\":{\"command\":\"ls\"},\"index\":1}}'\n\
3216 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
3217 \"text\":\"done\"}]}}'\n",
3218 );
3219 let mut seen = 0usize;
3220 let turn = oh_provider(bin)
3221 .respond_streaming(
3222 "claude-code",
3223 "sonnet",
3224 &skill_ref(),
3225 &[Message::user("hi")],
3226 None,
3227 &mut |event| {
3228 seen += 1;
3229 assert_eq!(event.name.as_deref(), Some("rm"));
3230 ControlFlow::Break(())
3231 },
3232 )
3233 .unwrap();
3234 assert_eq!(seen, 1, "aborted after the first event");
3235 assert_eq!(turn.events.len(), 1);
3236 assert_eq!(turn.events[0].name.as_deref(), Some("rm"));
3237 assert!(turn.message.is_empty());
3239 }
3240
3241 #[test]
3242 fn oneharness_stream_errors_when_no_result_line() {
3243 let bin = script(
3246 "oh-stream-noresult",
3247 "cat >/dev/null\n\
3248 printf '%s\\n' '{\"type\":\"event\",\"event\":{\"kind\":\"tool_call\",\
3249 \"name\":\"bash\",\"input\":{},\"index\":0}}'\n",
3250 );
3251 let err = oh_provider(bin)
3252 .respond_streaming(
3253 "claude-code",
3254 "sonnet",
3255 &skill_ref(),
3256 &[Message::user("hi")],
3257 None,
3258 &mut |_| ControlFlow::Continue(()),
3259 )
3260 .unwrap_err();
3261 assert!(
3262 matches!(err, Error::Provider { .. }),
3263 "expected a provider error, got: {err:?}"
3264 );
3265 }
3266
3267 #[test]
3268 fn oneharness_buffered_run_passes_events_and_omits_mode() {
3269 let bin = script(
3272 "oh-args",
3273 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
3274 cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
3275 );
3276 let dir = bin.parent().unwrap().to_path_buf();
3277 oh_provider(bin)
3278 .respond(
3279 "claude-code",
3280 "sonnet",
3281 &skill_ref(),
3282 &[Message::user("hi")],
3283 None,
3284 )
3285 .unwrap();
3286 let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
3287 .unwrap()
3288 .lines()
3289 .map(str::to_string)
3290 .collect();
3291 assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
3292 assert!(args.iter().any(|a| a == "--compact"), "got: {args:?}");
3293 assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
3294 assert!(!args.iter().any(|a| a == "--stream"), "got: {args:?}");
3295 }
3296
3297 #[test]
3298 fn oneharness_stream_run_passes_stream_and_omits_mode() {
3299 let bin = script(
3302 "oh-args-stream",
3303 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
3304 cat >/dev/null\n\
3305 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
3306 \"text\":\"hi\"}]}}'\n",
3307 );
3308 let dir = bin.parent().unwrap().to_path_buf();
3309 oh_provider(bin)
3310 .respond_streaming(
3311 "claude-code",
3312 "sonnet",
3313 &skill_ref(),
3314 &[Message::user("hi")],
3315 None,
3316 &mut |_| ControlFlow::Continue(()),
3317 )
3318 .unwrap();
3319 let args: Vec<String> = std::fs::read_to_string(dir.join("args"))
3320 .unwrap()
3321 .lines()
3322 .map(str::to_string)
3323 .collect();
3324 assert!(args.iter().any(|a| a == "--stream"), "got: {args:?}");
3325 assert!(args.iter().any(|a| a == "--events"), "got: {args:?}");
3326 assert!(!args.iter().any(|a| a == "--mode"), "got: {args:?}");
3327 assert!(!args.iter().any(|a| a == "--compact"), "got: {args:?}");
3328 }
3329
3330 fn argv_lines(path: &std::path::Path) -> Vec<String> {
3332 std::fs::read_to_string(path)
3333 .unwrap()
3334 .lines()
3335 .map(str::to_string)
3336 .collect()
3337 }
3338
3339 #[test]
3340 fn oneharness_respond_records_history_and_surfaces_command() {
3341 let bin = script(
3346 "oh-history",
3347 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
3348 cat >/dev/null\n\
3349 echo '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}],\
3350 \"history_file\":\"/tmp/skilltest-test-history/p/s.jsonl\"}'\n",
3351 );
3352 let dir = bin.parent().unwrap().to_path_buf();
3353 let turn = oh_provider(bin)
3354 .respond(
3355 "claude-code",
3356 "sonnet",
3357 &skill_ref(),
3358 &[Message::user("Greet Dr. Smith")],
3359 None,
3360 )
3361 .unwrap();
3362 let args = argv_lines(&dir.join("args"));
3363 assert!(args.iter().any(|a| a == "--history"), "got: {args:?}");
3364 let hd = args
3366 .iter()
3367 .position(|a| a == "--history-dir")
3368 .expect("--history-dir");
3369 assert_eq!(args[hd + 1], TEST_HISTORY_DIR);
3370 let hn = args
3371 .iter()
3372 .position(|a| a == "--history-name")
3373 .expect("--history-name");
3374 assert!(
3375 args[hn + 1].starts_with("skilltest-claude-code-sonnet-greet-dr-smith-"),
3376 "name: {}",
3377 args[hn + 1]
3378 );
3379 let cmd = turn.history_command.expect("history recorded");
3382 assert!(
3383 cmd.contains(&format!("history show {}", args[hn + 1])),
3384 "cmd: {cmd}"
3385 );
3386 assert!(
3387 cmd.contains(&format!("--history-dir {TEST_HISTORY_DIR}")),
3388 "cmd: {cmd}"
3389 );
3390 }
3391
3392 #[test]
3393 fn oneharness_history_command_absent_when_not_recorded() {
3394 let bin = script(
3398 "oh-history-none",
3399 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
3400 cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}]}'\n",
3401 );
3402 let dir = bin.parent().unwrap().to_path_buf();
3403 let turn = oh_provider(bin)
3404 .respond(
3405 "claude-code",
3406 "sonnet",
3407 &skill_ref(),
3408 &[Message::user("hi")],
3409 None,
3410 )
3411 .unwrap();
3412 assert!(
3413 std::fs::read_to_string(dir.join("args"))
3414 .unwrap()
3415 .contains("--history"),
3416 "flags were still requested"
3417 );
3418 assert!(
3419 turn.history_command.is_none(),
3420 "no confirmed session → no command"
3421 );
3422 }
3423
3424 #[test]
3425 fn oneharness_history_disabled_omits_flags_and_command() {
3426 let bin = script(
3429 "oh-history-off",
3430 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
3431 cat >/dev/null\n\
3432 echo '{\"results\":[{\"status\":\"ok\",\"text\":\"hi\"}],\
3433 \"history_file\":\"/x/s.jsonl\"}'\n",
3434 );
3435 let dir = bin.parent().unwrap().to_path_buf();
3436 let turn = oh_provider_cfg(bin, false)
3437 .respond(
3438 "claude-code",
3439 "sonnet",
3440 &skill_ref(),
3441 &[Message::user("hi")],
3442 None,
3443 )
3444 .unwrap();
3445 assert!(
3446 !std::fs::read_to_string(dir.join("args"))
3447 .unwrap()
3448 .contains("--history"),
3449 "history was disabled"
3450 );
3451 assert!(turn.history_command.is_none());
3452 }
3453
3454 #[test]
3455 fn oneharness_judge_and_user_runs_are_never_recorded() {
3456 let bin = script(
3459 "oh-history-judge",
3460 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
3461 cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
3462 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}\"}]}'\n",
3463 );
3464 let dir = bin.parent().unwrap().to_path_buf();
3465 let query = JudgeQuery {
3466 kind: JudgeKind::Boolean,
3467 criterion: "polite",
3468 scale: None,
3469 };
3470 oh_provider(bin).judge("m", &query, &[]).unwrap();
3471 assert!(
3472 !std::fs::read_to_string(dir.join("args"))
3473 .unwrap()
3474 .contains("--history"),
3475 "the judge run must not be recorded"
3476 );
3477 }
3478
3479 #[test]
3480 fn oneharness_stream_records_history() {
3481 let bin = script(
3482 "oh-history-stream",
3483 "d=$(dirname \"$0\"); printf '%s\\n' \"$@\" > \"$d/args\"\n\
3484 cat >/dev/null\n\
3485 printf '%s\\n' '{\"type\":\"result\",\"report\":{\"results\":[{\"status\":\"ok\",\
3486 \"text\":\"hi\"}],\"history_file\":\"/tmp/skilltest-test-history/p/s.jsonl\"}}'\n",
3487 );
3488 let dir = bin.parent().unwrap().to_path_buf();
3489 let turn = oh_provider(bin)
3490 .respond_streaming(
3491 "claude-code",
3492 "sonnet",
3493 &skill_ref(),
3494 &[Message::user("hi")],
3495 None,
3496 &mut |_| ControlFlow::Continue(()),
3497 )
3498 .unwrap();
3499 assert!(
3500 std::fs::read_to_string(dir.join("args"))
3501 .unwrap()
3502 .contains("--history"),
3503 "the streaming skill run is recorded"
3504 );
3505 assert!(turn.history_command.is_some());
3506 }
3507
3508 #[test]
3509 fn oneharness_falls_back_to_stdout_when_text_null() {
3510 let bin = script(
3511 "oh-fallback",
3512 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
3513 \"stdout\":\"raw reply\"}]}'\n",
3514 );
3515 let user = oh_provider(bin).simulate_user("m", "persona", &[]).unwrap();
3516 assert_eq!(user.message, "raw reply");
3517 }
3518
3519 #[test]
3520 fn oneharness_judge_parses_verdict() {
3521 let bin = script(
3522 "oh-judge",
3523 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\",\
3524 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"good\\\"}\"}]}'\n",
3525 );
3526 let query = JudgeQuery {
3527 kind: JudgeKind::Boolean,
3528 criterion: "polite",
3529 scale: None,
3530 };
3531 let verdict = oh_provider(bin).judge("m", &query, &[]).unwrap();
3532 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3533 assert_eq!(verdict.reason, "good");
3534 }
3535
3536 #[test]
3537 fn oneharness_classifies_failure_kind() {
3538 let bin = script(
3539 "oh-auth",
3540 "cat >/dev/null\necho '{\"results\":[{\"status\":\"error\",\
3541 \"failure_kind\":\"auth\",\"error\":\"no creds\"}]}'\n",
3542 );
3543 let err = oh_provider(bin)
3544 .respond("claude-code", "m", &skill_ref(), &[], None)
3545 .unwrap_err();
3546 assert!(matches!(
3547 err,
3548 Error::Provider {
3549 kind: Some(ProviderErrorKind::Auth),
3550 ..
3551 }
3552 ));
3553 }
3554
3555 #[test]
3556 fn oneharness_classifies_timeout_status_without_failure_kind() {
3557 let bin = script(
3562 "oh-err",
3563 "cat >/dev/null\necho '{\"results\":[{\"status\":\"timeout\",\
3564 \"stderr\":\"deadline\"}]}'\n",
3565 );
3566 let err = oh_provider(bin).simulate_user("m", "p", &[]).unwrap_err();
3567 let msg = err.to_string();
3568 assert!(msg.contains("harness run failed"), "got: {msg}");
3569 assert!(msg.contains("deadline"));
3570 assert!(matches!(
3571 err,
3572 Error::Provider {
3573 kind: Some(ProviderErrorKind::Timeout),
3574 ..
3575 }
3576 ));
3577 }
3578
3579 #[test]
3580 fn oneharness_failure_without_classifiable_signal_is_unclassified() {
3581 let bin = script(
3585 "oh-plain",
3586 "cat >/dev/null\necho '{\"results\":[{\"status\":\"error\",\
3587 \"error\":\"something broke\"}]}'\n",
3588 );
3589 let err = oh_provider(bin)
3590 .respond("claude-code", "m", &skill_ref(), &[], None)
3591 .unwrap_err();
3592 assert!(matches!(err, Error::Provider { kind: None, .. }));
3593 assert!(err.to_string().contains("something broke"));
3594 }
3595
3596 #[test]
3597 fn oneharness_errors_on_unparseable_output_and_no_results() {
3598 let garbage = script(
3599 "oh-garbage",
3600 "cat >/dev/null\necho 'not json' 1>&2\necho 'x'\n",
3601 );
3602 assert!(oh_provider(garbage)
3603 .simulate_user("m", "p", &[])
3604 .unwrap_err()
3605 .to_string()
3606 .contains("could not parse oneharness output"));
3607
3608 let empty = script("oh-empty", "cat >/dev/null\necho '{\"results\":[]}'\n");
3609 assert!(oh_provider(empty)
3610 .simulate_user("m", "p", &[])
3611 .unwrap_err()
3612 .to_string()
3613 .contains("no results"));
3614 }
3615
3616 #[test]
3617 fn oneharness_errors_when_no_text_or_stdout() {
3618 let bin = script(
3619 "oh-silent",
3620 "cat >/dev/null\necho '{\"results\":[{\"status\":\"ok\"}]}'\n",
3621 );
3622 assert!(oh_provider(bin)
3623 .respond("claude-code", "m", &skill_ref(), &[], None)
3624 .unwrap_err()
3625 .to_string()
3626 .contains("neither extractable text nor stdout"));
3627 }
3628
3629 #[test]
3630 fn oneharness_respond_resume_sends_only_latest_message() {
3631 let dir =
3635 std::env::temp_dir().join(format!("skilltest-oh-resume-{}", std::process::id()));
3636 std::fs::create_dir_all(&dir).unwrap();
3637 let prompt_file = dir.join("prompt.txt");
3638 let argv_file = dir.join("argv.txt");
3639 let bin = script(
3640 "oh-resume",
3641 &format!(
3642 "echo \"$@\" > '{}'\ncat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3643 argv_file.display(),
3644 prompt_file.display(),
3645 ),
3646 );
3647 let messages = [
3648 Message::user("first"),
3649 Message::assistant("reply"),
3650 Message::user("second"),
3651 ];
3652 oh_provider(bin.clone())
3653 .respond(
3654 "claude-code",
3655 "sonnet",
3656 &skill_ref(),
3657 &messages,
3658 Some("sess-1"),
3659 )
3660 .unwrap();
3661 let prompt = std::fs::read_to_string(&prompt_file).unwrap();
3662 assert_eq!(
3663 prompt.trim(),
3664 "second",
3665 "resume sends only the latest user message"
3666 );
3667 let argv = std::fs::read_to_string(&argv_file).unwrap();
3668 assert!(argv.contains("--resume sess-1"), "argv: {argv}");
3669 assert!(
3670 argv.contains("--system"),
3671 "skill is the system prompt: {argv}"
3672 );
3673 }
3674
3675 #[test]
3676 fn oneharness_omits_model_flag_when_model_empty() {
3677 let dir =
3678 std::env::temp_dir().join(format!("skilltest-oh-model-{}", std::process::id()));
3679 std::fs::create_dir_all(&dir).unwrap();
3680 let argv_file = dir.join("argv.txt");
3681 let bin = script(
3682 "oh-nomodel",
3683 &format!(
3684 "echo \"$@\" > '{}'\ncat >/dev/null\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3685 argv_file.display(),
3686 ),
3687 );
3688 oh_provider(bin)
3689 .respond("cursor", "", &skill_ref(), &[Message::user("hi")], None)
3690 .unwrap();
3691 let argv = std::fs::read_to_string(&argv_file).unwrap();
3692 assert!(
3693 !argv.contains("--model"),
3694 "empty model omits the flag: {argv}"
3695 );
3696 }
3697
3698 #[test]
3699 fn oneharness_reports_missing_binary() {
3700 let provider = oh_provider(PathBuf::from("/no/such/oneharness-binary"));
3701 let err = provider
3702 .respond("claude-code", "m", &skill_ref(), &[], None)
3703 .unwrap_err();
3704 assert!(err.to_string().contains("could not run"));
3705 }
3706
3707 #[test]
3708 fn oneharness_respond_with_mocks_passes_flags_and_reads_spy_log() {
3709 let dir =
3713 std::env::temp_dir().join(format!("skilltest-oh-mocks-{}", std::process::id()));
3714 std::fs::create_dir_all(&dir).unwrap();
3715 let rules_seen = dir.join("rules-seen.json");
3716 let bin = script(
3717 "oh-mocks",
3718 &format!(
3719 r#"rules=""; spy=""
3720while [ $# -gt 0 ]; do
3721 [ "$1" = "--mock-rules" ] && rules="$2"
3722 [ "$1" = "--spy-file" ] && spy="$2"
3723 shift
3724done
3725cat >/dev/null
3726cp "$rules" '{seen}'
3727printf '%s
3728' '{{"harness":"claude-code","event":{{"tool_name":"Bash","tool_input":{{"command":"git push"}}}},"action":"stub","rule":0}}' >> "$spy"
3729printf '%s
3730' '{{"harness":"claude-code","event":{{"tool_name":"Bash","tool_input":{{"command":"ls"}}}},"action":"allow","rule":null}}' >> "$spy"
3731echo '{{"results":[{{"status":"ok","text":"done"}}]}}'
3732"#,
3733 seen = rules_seen.display(),
3734 ),
3735 );
3736 let rules = serde_json::json!({ "rules": [
3737 { "match": { "event_contains": "git push" },
3738 "action": { "stub": { "output": "up-to-date", "exit_code": 0 } } }
3739 ]});
3740 let plan = MockPlan {
3741 rules: Some(&rules),
3742 };
3743 let turn = oh_provider(bin)
3744 .respond_with_mocks(
3745 "claude-code",
3746 "sonnet",
3747 &skill_ref(),
3748 &[Message::user("hi")],
3749 None,
3750 Some(&plan),
3751 )
3752 .unwrap();
3753 let seen: serde_json::Value =
3755 serde_json::from_str(&std::fs::read_to_string(&rules_seen).unwrap()).unwrap();
3756 assert_eq!(seen, rules);
3757 let records = turn.mock_calls.expect("channel was on");
3759 assert_eq!(records.len(), 2);
3760 assert_eq!(records[0].action, "stub");
3761 assert_eq!(records[0].rule, Some(0));
3762 assert_eq!(records[0].input.as_ref().unwrap()["command"], "git push");
3763 assert_eq!(records[1].action, "allow");
3764 }
3765
3766 #[test]
3767 fn oneharness_spy_only_plan_omits_rules_flag_and_missing_log_is_empty() {
3768 let dir =
3772 std::env::temp_dir().join(format!("skilltest-oh-spyonly-{}", std::process::id()));
3773 std::fs::create_dir_all(&dir).unwrap();
3774 let argv_file = dir.join("argv.txt");
3775 let bin = script(
3776 "oh-spyonly",
3777 &format!(
3778 "echo \"$@\" > '{}'\ncat >/dev/null\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"ok\"}}]}}'\n",
3779 argv_file.display(),
3780 ),
3781 );
3782 let plan = MockPlan { rules: None };
3783 let turn = oh_provider(bin)
3784 .respond_with_mocks(
3785 "claude-code",
3786 "sonnet",
3787 &skill_ref(),
3788 &[Message::user("hi")],
3789 None,
3790 Some(&plan),
3791 )
3792 .unwrap();
3793 assert_eq!(turn.mock_calls, Some(Vec::new()));
3794 let argv = std::fs::read_to_string(&argv_file).unwrap();
3795 assert!(argv.contains("--spy-file"), "argv: {argv}");
3796 assert!(!argv.contains("--mock-rules"), "argv: {argv}");
3797 }
3798
3799 fn api_provider_with_curl(curl: PathBuf, vendor: ApiVendor) -> ApiJudgeProvider {
3802 ApiJudgeProvider::new(&ApiJudgeConfig {
3803 vendor,
3804 api_key_env: Some("SKILLTEST_TEST_API_KEY".to_string()),
3805 base_url: Some("https://example.invalid/v1".to_string()),
3806 timeout_secs: 5,
3807 curl_bin: curl.to_string_lossy().into_owned(),
3808 strict_json: true,
3809 })
3810 }
3811
3812 #[test]
3813 fn api_judge_judges_through_fake_curl() {
3814 let curl = script(
3816 "curl-ok",
3817 "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
3818 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"polite\\\"}\"}],\
3819 \"usage\":{\"input_tokens\":9,\"output_tokens\":3}}'\n",
3820 );
3821 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3822 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3823 let query = JudgeQuery {
3824 kind: JudgeKind::Boolean,
3825 criterion: "polite",
3826 scale: None,
3827 };
3828 let verdict = provider
3829 .judge("claude-x", &query, &[Message::user("hi")])
3830 .unwrap();
3831 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3832 assert_eq!(verdict.usage.unwrap().input_tokens, Some(9));
3833 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3834 }
3835
3836 #[test]
3837 fn api_judge_simulates_user_through_fake_curl() {
3838 let curl = script(
3839 "curl-user",
3840 "cat >/dev/null\necho '{\"choices\":[{\"message\":\
3841 {\"content\":\"sure, go on\"}}]}'\n",
3842 );
3843 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3844 let provider = api_provider_with_curl(curl, ApiVendor::Openai);
3845 let user = provider.simulate_user("gpt-x", "a patient", &[]).unwrap();
3846 assert_eq!(user.message, "sure, go on");
3847 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3848 }
3849
3850 #[test]
3851 fn api_judge_errors_when_key_absent() {
3852 let curl = script("curl-unused", "cat >/dev/null\necho '{}'\n");
3853 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3854 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3855 let err = provider
3856 .judge(
3857 "m",
3858 &JudgeQuery {
3859 kind: JudgeKind::Boolean,
3860 criterion: "x",
3861 scale: None,
3862 },
3863 &[],
3864 )
3865 .unwrap_err();
3866 assert!(matches!(
3867 err,
3868 Error::Provider {
3869 kind: Some(ProviderErrorKind::Auth),
3870 ..
3871 }
3872 ));
3873 }
3874
3875 #[test]
3876 fn api_judge_surfaces_curl_failure() {
3877 let curl = script(
3878 "curl-fail",
3879 "cat >/dev/null\necho 'curl: (6) bad host' 1>&2\nexit 6\n",
3880 );
3881 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3882 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3883 let err = provider
3884 .judge(
3885 "m",
3886 &JudgeQuery {
3887 kind: JudgeKind::Boolean,
3888 criterion: "x",
3889 scale: None,
3890 },
3891 &[],
3892 )
3893 .unwrap_err();
3894 assert!(err.to_string().contains("curl failed"));
3895 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3896 }
3897
3898 #[test]
3899 fn write_curl_config_sets_private_mode_and_headers() {
3900 let dir = std::env::temp_dir().join(format!("skilltest-cfg-{}", std::process::id()));
3901 std::fs::create_dir_all(&dir).unwrap();
3902 let path = dir.join("c.cfg");
3903 write_curl_config(
3904 &path,
3905 "https://api.example/v1",
3906 &[("x-api-key".to_string(), "secret\"quote".to_string())],
3907 42,
3908 )
3909 .unwrap();
3910 let text = std::fs::read_to_string(&path).unwrap();
3911 assert!(text.contains("url = \"https://api.example/v1\""));
3912 assert!(text.contains("max-time = 42"));
3913 assert!(text.contains("secret\\\"quote"), "escaped header: {text}");
3915 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
3916 assert_eq!(mode & 0o777, 0o600, "config is private");
3917 }
3918
3919 #[test]
3920 fn api_judge_retries_a_transient_error_then_succeeds() {
3921 let dir = std::env::temp_dir().join(format!("skilltest-retry-{}", std::process::id()));
3924 std::fs::create_dir_all(&dir).unwrap();
3925 let counter = dir.join("n");
3926 let curl = script(
3927 "curl-retry",
3928 &format!(
3929 "cat >/dev/null\nif [ -f '{c}' ]; then \
3930 echo '{{\"content\":[{{\"type\":\"text\",\"text\":\"{{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}}\"}}]}}'; \
3931 else touch '{c}'; \
3932 echo '{{\"error\":{{\"type\":\"overloaded_error\",\"message\":\"busy\"}}}}'; \
3933 fi\n",
3934 c = counter.display(),
3935 ),
3936 );
3937 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3938 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3939 let verdict = provider
3940 .judge(
3941 "m",
3942 &JudgeQuery {
3943 kind: JudgeKind::Boolean,
3944 criterion: "x",
3945 scale: None,
3946 },
3947 &[],
3948 )
3949 .unwrap();
3950 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
3951 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3952 }
3953
3954 #[test]
3955 fn api_judge_gives_up_after_max_retries() {
3956 let curl = script(
3958 "curl-busy",
3959 "cat >/dev/null\necho '{\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}'\n",
3960 );
3961 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3962 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3963 let err = provider
3964 .judge(
3965 "m",
3966 &JudgeQuery {
3967 kind: JudgeKind::Boolean,
3968 criterion: "x",
3969 scale: None,
3970 },
3971 &[],
3972 )
3973 .unwrap_err();
3974 assert!(matches!(
3975 err,
3976 Error::Provider {
3977 kind: Some(ProviderErrorKind::Overloaded),
3978 ..
3979 }
3980 ));
3981 std::env::remove_var("SKILLTEST_TEST_API_KEY");
3982 }
3983
3984 #[test]
3985 fn api_judge_classifies_curl_timeout() {
3986 let curl = script(
3989 "curl-timeout",
3990 "cat >/dev/null\necho 'curl: (28) Operation timed out' 1>&2\nexit 28\n",
3991 );
3992 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
3993 let provider = api_provider_with_curl(curl, ApiVendor::Anthropic);
3994 let err = provider
3995 .judge(
3996 "m",
3997 &JudgeQuery {
3998 kind: JudgeKind::Boolean,
3999 criterion: "x",
4000 scale: None,
4001 },
4002 &[],
4003 )
4004 .unwrap_err();
4005 assert!(matches!(
4006 err,
4007 Error::Provider {
4008 kind: Some(ProviderErrorKind::Timeout),
4009 ..
4010 }
4011 ));
4012 std::env::remove_var("SKILLTEST_TEST_API_KEY");
4013 }
4014
4015 #[test]
4016 fn api_judge_reports_missing_curl_binary() {
4017 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
4018 let provider =
4019 api_provider_with_curl(PathBuf::from("/no/such/curl-binary"), ApiVendor::Anthropic);
4020 let err = provider
4021 .judge(
4022 "m",
4023 &JudgeQuery {
4024 kind: JudgeKind::Boolean,
4025 criterion: "x",
4026 scale: None,
4027 },
4028 &[],
4029 )
4030 .unwrap_err();
4031 assert!(err.to_string().contains("could not run"));
4032 std::env::remove_var("SKILLTEST_TEST_API_KEY");
4033 }
4034
4035 #[test]
4036 fn api_judge_surfaces_unparseable_response() {
4037 let curl = script("curl-garbage", "cat >/dev/null\necho 'not json at all'\n");
4038 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
4039 let provider = api_provider_with_curl(curl, ApiVendor::Openai);
4040 let err = provider
4041 .judge(
4042 "m",
4043 &JudgeQuery {
4044 kind: JudgeKind::Boolean,
4045 criterion: "x",
4046 scale: None,
4047 },
4048 &[],
4049 )
4050 .unwrap_err();
4051 assert!(err.to_string().contains("could not parse API response"));
4052 std::env::remove_var("SKILLTEST_TEST_API_KEY");
4053 }
4054
4055 #[test]
4056 fn split_provider_routes_judge_and_user_through_the_api() {
4057 let curl = script(
4060 "split-curl",
4061 "cat >/dev/null\necho '{\"content\":[{\"type\":\"text\",\
4062 \"text\":\"{\\\"value\\\": true, \\\"reason\\\": \\\"ok\\\"}\"}]}'\n",
4063 );
4064 std::env::set_var("SKILLTEST_TEST_API_KEY", "sk-test");
4065 let judge = api_provider_with_curl(curl, ApiVendor::Anthropic);
4066 let split = SplitProvider::new(Box::new(super::StubResponder), judge);
4067 let verdict = split
4068 .judge(
4069 "m",
4070 &JudgeQuery {
4071 kind: JudgeKind::Boolean,
4072 criterion: "polite",
4073 scale: None,
4074 },
4075 &[],
4076 )
4077 .unwrap();
4078 assert!(matches!(verdict.value, JudgeValue::Bool(true)));
4079 std::env::remove_var("SKILLTEST_TEST_API_KEY");
4080 }
4081
4082 #[test]
4083 fn oneharness_numeric_judge_uses_numeric_prompt() {
4084 let dir = std::env::temp_dir().join(format!("skilltest-ohnum-{}", std::process::id()));
4087 std::fs::create_dir_all(&dir).unwrap();
4088 let prompt_file = dir.join("prompt.txt");
4089 let bin = script(
4090 "oh-numeric",
4091 &format!(
4092 "cat > '{}'\necho '{{\"results\":[{{\"status\":\"ok\",\"text\":\"{{\\\"value\\\": 8.5, \\\"reason\\\": \\\"warm\\\"}}\"}}]}}'\n",
4093 prompt_file.display(),
4094 ),
4095 );
4096 let query = JudgeQuery {
4097 kind: JudgeKind::Numeric,
4098 criterion: "warmth",
4099 scale: Some((0.0, 10.0)),
4100 };
4101 let verdict = oh_provider(bin)
4102 .judge("m", &query, &[Message::assistant("hi")])
4103 .unwrap();
4104 assert!(matches!(verdict.value, JudgeValue::Number(v) if (v - 8.5).abs() < 1e-9));
4105 let prompt = std::fs::read_to_string(&prompt_file).unwrap();
4106 assert!(
4107 prompt.contains("scale from 0 to 10"),
4108 "numeric prompt: {prompt}"
4109 );
4110 }
4111
4112 #[test]
4113 fn supports_resume_method_matches_free_function() {
4114 let provider = oh_provider(PathBuf::from("/bin/true"));
4115 assert!(provider.supports_resume("claude-code"));
4116 assert!(!provider.supports_resume("codex"));
4117 }
4118 }
4119
4120 #[test]
4124 fn parse_verdict_rejects_missing_object_and_value() {
4125 assert!(parse_verdict(JudgeKind::Boolean, "just prose, no braces").is_err());
4127 assert!(parse_verdict(JudgeKind::Boolean, "{\"reason\": \"x\"}").is_err());
4129 assert!(parse_verdict(JudgeKind::Numeric, "{not: valid}").is_err());
4131 }
4132
4133 #[test]
4134 fn extract_json_object_handles_reversed_braces() {
4135 assert_eq!(extract_json_object("} then {"), None);
4137 }
4138
4139 #[test]
4140 fn unclassified_api_error_falls_back_to_plain_provider_error() {
4141 let raw = r#"{"error":{"type":"some_new_error","message":"odd"}}"#;
4143 let err = parse_chat_response(ApiVendor::Openai, raw).unwrap_err();
4144 assert!(matches!(err, Error::Provider { kind: None, .. }));
4145 assert!(err.to_string().contains("odd"));
4146 }
4147
4148 #[test]
4149 fn openai_empty_choice_text_is_an_error() {
4150 let raw = r#"{"choices":[]}"#;
4151 assert!(parse_chat_response(ApiVendor::Openai, raw).is_err());
4152 }
4153
4154 #[test]
4155 fn truncate_for_error_caps_length_on_a_char_boundary() {
4156 let long = "x".repeat(1000);
4157 assert_eq!(truncate_for_error(&long).chars().count(), 500);
4158 }
4159}