1use std::time::Duration;
40
41use serde::{Deserialize, Serialize};
42
43use crate::config::SecretString;
44use crate::error::CloudflareError;
45use crate::fetch::auth_header;
46use crate::health::{FailureClass, MAX_EXCERPT_CHARS};
47
48pub const LIVE_TESTS_ENV: &str = "AUTH_CLOUDFLARE_LIVE_TESTS";
52
53pub const TOOL_LOOP_MAX_TURNS: u32 = 8;
56
57pub const TOOL_LOOP_SYSTEM_PROMPT: &str = "You are in a test harness. Complete the fixture workflow: read the fixture, run its test, write the patch, then answer concisely with the final status.";
60
61pub const TOOL_LOOP_USER_PROMPT: &str = "Begin the fixture workflow. The target fixture id is 'calc'.";
64
65const FIXTURE_ID_VALUES: &[&str] = &["calc", "greeter"];
69
70const FIXTURE_CALC_SOURCE: &str = concat!(
74 "//! calc fixture: add() is intentionally off by one so the test fails until patched.\n",
75 "pub fn add(a: i32, b: i32) -> i32 {\n",
76 " a + b - 1\n",
77 "}\n",
78 "\n",
79 "#[test]\n",
80 "fn test_add() {\n",
81 " assert_eq!(add(2, 2), 4);\n",
82 "}\n",
83);
84
85const FIXTURE_GREETER_SOURCE: &str = concat!(
88 "//! greeter fixture: greet() is missing the comma so the test fails until patched.\n",
89 "pub fn greet(name: &str) -> String {\n",
90 " format!(\"Hello {name}!\")\n",
91 "}\n",
92 "\n",
93 "#[test]\n",
94 "fn test_greet() {\n",
95 " assert_eq!(greet(\"World\"), \"Hello, World!\");\n",
96 "}\n",
97);
98
99struct ParamSpec {
102 name: &'static str,
103 enum_values: &'static [&'static str],
104}
105
106struct ToolSpec {
110 name: &'static str,
111 description: &'static str,
112 params: &'static [ParamSpec],
113 required: &'static [&'static str],
114}
115
116const TOOL_SPECS: &[ToolSpec] = &[
119 ToolSpec {
120 name: "read_fixture",
121 description: "Read the Rust fixture source for the given fixture_id.",
122 params: &[ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES }],
123 required: &["fixture_id"],
124 },
125 ToolSpec {
126 name: "run_fixture_test",
127 description: "Run the fixture's test suite and return the controlled test report for the given fixture_id.",
128 params: &[ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES }],
129 required: &["fixture_id"],
130 },
131 ToolSpec {
132 name: "write_fixture_patch",
133 description: "Apply a patch that fixes the fixture source for the given fixture_id.",
134 params: &[
135 ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES },
136 ParamSpec { name: "patch", enum_values: &[] },
137 ],
138 required: &["fixture_id", "patch"],
139 },
140];
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub struct ToolCallObservation {
150 pub name: String,
151 pub arguments: serde_json::Value,
152 pub turn: u32,
154}
155
156#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub struct ToolLoopOutcome {
164 pub converged: bool,
165 pub turns_used: u32,
168 pub max_turns: u32,
169 pub tool_calls: Vec<ToolCallObservation>,
170 pub failure_class: Option<FailureClass>,
171 pub final_answer: Option<String>,
173}
174
175impl ToolLoopOutcome {
176 pub fn new(
180 converged: bool,
181 turns_used: u32,
182 max_turns: u32,
183 tool_calls: Vec<ToolCallObservation>,
184 failure_class: Option<FailureClass>,
185 final_answer: Option<String>,
186 ) -> Self {
187 Self {
188 converged,
189 turns_used,
190 max_turns,
191 tool_calls,
192 failure_class,
193 final_answer: final_answer.map(|answer| truncate_final_answer(&answer)),
194 }
195 }
196}
197
198fn truncate_final_answer(answer: &str) -> String {
200 if answer.chars().count() <= MAX_EXCERPT_CHARS {
201 answer.to_string()
202 } else {
203 answer.chars().take(MAX_EXCERPT_CHARS).collect()
204 }
205}
206
207pub fn live_tests_enabled() -> bool {
211 std::env::var(LIVE_TESTS_ENV).is_ok_and(|value| value == "1")
212}
213
214pub fn tool_schemas() -> Vec<serde_json::Value> {
220 TOOL_SPECS
221 .iter()
222 .map(|spec| {
223 let mut properties = serde_json::Map::new();
224 for param in spec.params {
225 let mut property = serde_json::json!({ "type": "string" });
226 if !param.enum_values.is_empty() {
227 property["enum"] = serde_json::Value::Array(
228 param
229 .enum_values
230 .iter()
231 .map(|value| serde_json::Value::String((*value).to_string()))
232 .collect(),
233 );
234 }
235 properties.insert(param.name.to_string(), property);
236 }
237 serde_json::json!({
238 "type": "function",
239 "function": {
240 "name": spec.name,
241 "description": spec.description,
242 "parameters": {
243 "type": "object",
244 "properties": serde_json::Value::Object(properties),
245 "required": spec.required,
246 "additionalProperties": false,
247 },
248 },
249 })
250 })
251 .collect()
252}
253
254pub fn fixture_source(fixture_id: &str) -> Option<String> {
257 match fixture_id {
258 "calc" => Some(FIXTURE_CALC_SOURCE.to_string()),
259 "greeter" => Some(FIXTURE_GREETER_SOURCE.to_string()),
260 _ => None,
261 }
262}
263
264fn fixture_test_report(fixture_id: &str) -> Option<String> {
267 match fixture_id {
268 "calc" => Some("assertion failed: add(2, 2) == 4, got 3".to_string()),
269 "greeter" => Some("assertion failed: greet(\"World\") == \"Hello, World!\", got \"Hello World!\"".to_string()),
270 _ => None,
271 }
272}
273
274pub fn execute_tool(name: &str, arguments: &serde_json::Value) -> Result<serde_json::Value, String> {
283 let fixture_id = arguments
284 .get("fixture_id")
285 .and_then(serde_json::Value::as_str)
286 .unwrap_or_default();
287 match name {
288 "read_fixture" => {
289 let source = fixture_source(fixture_id).ok_or_else(|| format!("unknown fixture_id {fixture_id:?}"))?;
290 Ok(serde_json::json!({ "status": "success", "fixture_id": fixture_id, "source": source }))
291 },
292 "run_fixture_test" => {
293 let output = fixture_test_report(fixture_id).ok_or_else(|| format!("unknown fixture_id {fixture_id:?}"))?;
294 Ok(serde_json::json!({ "status": "fail", "fixture_id": fixture_id, "output": output }))
295 },
296 "write_fixture_patch" => {
297 if fixture_source(fixture_id).is_none() {
298 return Err(format!("unknown fixture_id {fixture_id:?}"));
299 }
300 Ok(serde_json::json!({ "status": "success", "applied": true, "fixture_id": fixture_id }))
301 },
302 other => Err(format!("unknown tool {other}")),
303 }
304}
305
306pub fn validate_ordering(calls: &[ToolCallObservation]) -> bool {
315 let stages: Vec<u8> = calls.iter().filter_map(|call| stage_of(&call.name)).collect();
316 if stages.len() < 3 {
317 return false;
318 }
319 let has_all_stages = stages.contains(&1) && stages.contains(&2) && stages.contains(&3);
320 has_all_stages && stages.windows(2).all(|pair| pair[0] <= pair[1])
321}
322
323fn stage_of(name: &str) -> Option<u8> {
324 match name {
325 "read_fixture" => Some(1),
326 "run_fixture_test" => Some(2),
327 "write_fixture_patch" => Some(3),
328 _ => None,
329 }
330}
331
332pub fn find_duplicates(calls: &[ToolCallObservation]) -> Vec<&ToolCallObservation> {
338 let mut seen: Vec<(&str, &serde_json::Value)> = Vec::new();
339 let mut duplicates: Vec<&ToolCallObservation> = Vec::new();
340 for call in calls {
341 let already_seen = seen.iter().any(|(name, args)| *name == call.name && **args == call.arguments);
342 if already_seen {
343 duplicates.push(call);
344 } else {
345 seen.push((call.name.as_str(), &call.arguments));
346 }
347 }
348 duplicates
349}
350
351pub fn all_arguments_valid(calls: &[ToolCallObservation]) -> bool {
357 calls.iter().all(|call| arguments_valid_for_tool(&call.name, &call.arguments))
358}
359
360fn spec_for(name: &str) -> Option<&'static ToolSpec> {
361 TOOL_SPECS.iter().find(|spec| spec.name == name)
362}
363
364fn arguments_valid_for_tool(name: &str, arguments: &serde_json::Value) -> bool {
365 let Some(spec) = spec_for(name) else {
366 return false;
367 };
368 let serde_json::Value::Object(map) = arguments else {
369 return false;
370 };
371 if spec.required.iter().any(|required| !map.contains_key(*required)) {
372 return false;
373 }
374 for (key, value) in map.iter() {
375 let Some(param) = spec.params.iter().find(|param| param.name == key.as_str()) else {
376 return false;
377 };
378 if !value.is_string() {
379 return false;
380 }
381 if !param.enum_values.is_empty() {
382 let Some(actual) = value.as_str() else {
383 return false;
384 };
385 if !param.enum_values.contains(&actual) {
386 return false;
387 }
388 }
389 }
390 true
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396enum TurnVerdict {
397 NoToolCall,
399 FinalAnswer,
401 Continue,
403 TurnLimitBreached,
406}
407
408fn assess_turn(has_tool_calls: bool, turn: u32, max_turns: u32) -> TurnVerdict {
409 if !has_tool_calls {
410 if turn == 1 { TurnVerdict::NoToolCall } else { TurnVerdict::FinalAnswer }
411 } else if turn >= max_turns {
412 TurnVerdict::TurnLimitBreached
413 } else {
414 TurnVerdict::Continue
415 }
416}
417
418pub fn run_tool_loop(
429 account_id: &str,
430 token: &SecretString,
431 base_url: &str,
432 model_id: &str,
433 timeout: Duration,
434) -> Result<ToolLoopOutcome, CloudflareError> {
435 if !live_tests_enabled() {
436 return Err(CloudflareError::MissingEnv {
437 env_var: LIVE_TESTS_ENV,
438 hint: format!(
439 "live multi-turn tool-loop conformance for {model_id} on account {account_id} is opt-in and paid: export {LIVE_TESTS_ENV}=1 to run it (and set AUTH_CLOUDFLARE_MAX_COST_USD to cap spend). Never enable it in CI."
440 ),
441 });
442 }
443 if token.as_ref().trim().is_empty() {
444 return Err(CloudflareError::MissingEnv {
445 env_var: crate::auth::TOKEN_ENV,
446 hint: "the API token is empty - export a scoped Workers AI token (Account → Workers AI → Write)"
447 .to_string(),
448 });
449 }
450
451 let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
452 let agent = ureq::AgentBuilder::new().timeout(timeout).build();
453 let mut messages: Vec<serde_json::Value> = vec![
454 serde_json::json!({ "role": "system", "content": TOOL_LOOP_SYSTEM_PROMPT }),
455 serde_json::json!({ "role": "user", "content": TOOL_LOOP_USER_PROMPT }),
456 ];
457 let mut calls: Vec<ToolCallObservation> = Vec::new();
458 let mut turn: u32 = 0;
459 loop {
460 turn += 1;
461 let request = serde_json::json!({
462 "model": model_id,
463 "messages": messages,
464 "tools": tool_schemas(),
465 "tool_choice": "auto",
466 });
467 let response = post_chat_completion(&agent, &url, token, &request)?;
468 let message = response
469 .get("choices")
470 .and_then(|choices| choices.as_array())
471 .and_then(|choices| choices.first())
472 .and_then(|choice| choice.get("message"))
473 .cloned()
474 .ok_or_else(|| CloudflareError::Http("tool-loop response missing choices[0].message".to_string()))?;
475 let tool_calls = message.get("tool_calls").and_then(|calls| calls.as_array()).cloned();
476 let has_tool_calls = tool_calls.as_ref().is_some_and(|calls| !calls.is_empty());
477 let final_answer = message.get("content").and_then(serde_json::Value::as_str).map(str::to_string);
478 match assess_turn(has_tool_calls, turn, TOOL_LOOP_MAX_TURNS) {
479 TurnVerdict::NoToolCall => {
480 return Ok(ToolLoopOutcome::new(
481 false,
482 turn,
483 TOOL_LOOP_MAX_TURNS,
484 calls,
485 Some(FailureClass::NoToolCall),
486 final_answer,
487 ));
488 },
489 TurnVerdict::FinalAnswer => {
490 let converged =
494 validate_ordering(&calls) && find_duplicates(&calls).is_empty() && all_arguments_valid(&calls);
495 let failure_class = if converged { None } else { Some(FailureClass::ToolLoopDidNotConverge) };
496 return Ok(ToolLoopOutcome::new(
497 converged,
498 turn,
499 TOOL_LOOP_MAX_TURNS,
500 calls,
501 failure_class,
502 final_answer,
503 ));
504 },
505 TurnVerdict::TurnLimitBreached => {
506 return Ok(ToolLoopOutcome::new(
507 false,
508 turn,
509 TOOL_LOOP_MAX_TURNS,
510 calls,
511 Some(FailureClass::ToolLoopDidNotConverge),
512 None,
513 ));
514 },
515 TurnVerdict::Continue => {
516 let tool_calls = tool_calls.expect("Continue implies non-empty tool calls");
517 let mut turn_calls: Vec<ToolCallObservation> = Vec::new();
518 let mut results: Vec<(String, serde_json::Value)> = Vec::new();
519 for tool_call in &tool_calls {
520 let name = tool_call
521 .pointer("/function/name")
522 .and_then(serde_json::Value::as_str)
523 .unwrap_or("<missing>");
524 let raw_arguments = tool_call
525 .pointer("/function/arguments")
526 .and_then(serde_json::Value::as_str)
527 .unwrap_or_default();
528 let call_id = tool_call.get("id").and_then(serde_json::Value::as_str).unwrap_or_default();
529 if spec_for(name).is_none() {
531 calls.push(ToolCallObservation {
532 name: name.to_string(),
533 arguments: serde_json::Value::String(raw_arguments.to_string()),
534 turn,
535 });
536 return Ok(ToolLoopOutcome::new(
537 false,
538 turn,
539 TOOL_LOOP_MAX_TURNS,
540 calls,
541 Some(FailureClass::InvalidToolName),
542 None,
543 ));
544 }
545 let arguments: serde_json::Value = match serde_json::from_str(raw_arguments) {
547 Ok(value) => value,
548 Err(_) => {
549 calls.push(ToolCallObservation {
550 name: name.to_string(),
551 arguments: serde_json::Value::String(raw_arguments.to_string()),
552 turn,
553 });
554 return Ok(ToolLoopOutcome::new(
555 false,
556 turn,
557 TOOL_LOOP_MAX_TURNS,
558 calls,
559 Some(FailureClass::InvalidToolArguments),
560 None,
561 ));
562 },
563 };
564 if !arguments_valid_for_tool(name, &arguments) {
566 calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
567 return Ok(ToolLoopOutcome::new(
568 false,
569 turn,
570 TOOL_LOOP_MAX_TURNS,
571 calls,
572 Some(FailureClass::InvalidToolArguments),
573 None,
574 ));
575 }
576 let duplicated = calls
579 .iter()
580 .chain(turn_calls.iter())
581 .any(|prior| prior.name == name && prior.arguments == arguments);
582 if duplicated {
583 calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
584 return Ok(ToolLoopOutcome::new(
585 false,
586 turn,
587 TOOL_LOOP_MAX_TURNS,
588 calls,
589 Some(FailureClass::DuplicateToolCall),
590 None,
591 ));
592 }
593 let result = execute_tool(name, &arguments)
596 .map_err(|message| CloudflareError::Http(redact_token(&message, token.as_ref())))?;
597 turn_calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
598 results.push((call_id.to_string(), result));
599 }
600 messages.push(serde_json::json!({ "role": "assistant", "content": null, "tool_calls": tool_calls }));
603 for (call_id, result) in results {
604 messages.push(serde_json::json!({
605 "role": "tool",
606 "tool_call_id": call_id,
607 "content": result.to_string(),
608 }));
609 }
610 calls.extend(turn_calls);
611 },
612 }
613 }
614}
615
616fn post_chat_completion(
620 agent: &ureq::Agent,
621 url: &str,
622 token: &SecretString,
623 body: &serde_json::Value,
624) -> Result<serde_json::Value, CloudflareError> {
625 let request = agent
626 .post(url)
627 .set("Authorization", &auth_header(token))
628 .set("Accept", "application/json")
629 .set("Content-Type", "application/json");
630 let payload = body.to_string();
631 let (status, response) = match request.send_string(&payload) {
632 Ok(response) => (response.status(), response),
633 Err(ureq::Error::Status(status, response)) => (status, response),
634 Err(transport) => {
635 return Err(CloudflareError::Http(redact_token(&transport.to_string(), token.as_ref())));
636 },
637 };
638 let raw = response.into_string().map_err(|error| {
639 CloudflareError::Http(redact_token(&format!("read response body: {error}"), token.as_ref()))
640 })?;
641 let raw = redact_token(&raw, token.as_ref());
642 if status != 200 {
643 return Err(map_http_error(status, &raw));
644 }
645 serde_json::from_str(&raw).map_err(|_| CloudflareError::Http("tool-loop response was not valid JSON".to_string()))
646}
647
648fn map_http_error(status: u16, body: &str) -> CloudflareError {
651 let labeled = |code: u16, label: &str| -> CloudflareError {
652 CloudflareError::Api {
653 code: u32::from(code),
654 message: format!(
655 "{label} (HTTP {code}){}",
656 envelope_message(body).map(|m| format!(": {m}")).unwrap_or_default()
657 ),
658 }
659 };
660 match status {
661 401 => labeled(401, "unauthorized"),
662 403 => labeled(403, "forbidden"),
663 429 => labeled(429, "rate limited"),
664 500..=599 => {
665 CloudflareError::Http(format!("tool-loop endpoint returned HTTP {status} (transient server error)"))
666 },
667 other => CloudflareError::Api { code: u32::from(other), message: format!("HTTP {other}: {body}") },
668 }
669}
670
671fn envelope_message(body: &str) -> Option<String> {
673 serde_json::from_str::<serde_json::Value>(body)
674 .ok()?
675 .get("errors")?
676 .as_array()?
677 .first()?
678 .get("message")?
679 .as_str()
680 .map(str::to_string)
681}
682
683fn redact_token(text: &str, token: &str) -> String {
686 if token.is_empty() {
687 text.to_string()
688 } else {
689 text.replace(token, "<redacted>")
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use super::*;
696 use serde_json::json;
697
698 fn obs(name: &str, arguments: serde_json::Value, turn: u32) -> ToolCallObservation {
699 ToolCallObservation { name: name.to_string(), arguments, turn }
700 }
701
702 #[test]
703 fn ordering_accepts_the_full_workflow() {
704 let calls = vec![
705 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
706 obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
707 obs(
708 "write_fixture_patch",
709 json!({"fixture_id": "calc", "patch": "fix the off-by-one"}),
710 3,
711 ),
712 ];
713 assert!(validate_ordering(&calls));
714 }
715
716 #[test]
717 fn ordering_allows_extra_reads_before_the_run() {
718 let calls = vec![
720 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
721 obs("read_fixture", json!({"fixture_id": "greeter"}), 1),
722 obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
723 obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
724 ];
725 assert!(validate_ordering(&calls));
726 }
727
728 #[test]
729 fn ordering_rejects_reread_after_run() {
730 let calls = vec![
732 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
733 obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
734 obs("read_fixture", json!({"fixture_id": "calc"}), 3),
735 ];
736 assert!(!validate_ordering(&calls));
737 }
738
739 #[test]
740 fn ordering_rejects_run_before_read_and_run_after_write() {
741 let run_first = vec![
742 obs("run_fixture_test", json!({"fixture_id": "calc"}), 1),
743 obs("read_fixture", json!({"fixture_id": "calc"}), 2),
744 obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
745 ];
746 assert!(!validate_ordering(&run_first));
747 let write_then_run = vec![
748 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
749 obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 2),
750 obs("run_fixture_test", json!({"fixture_id": "calc"}), 3),
751 ];
752 assert!(!validate_ordering(&write_then_run));
753 }
754
755 #[test]
756 fn ordering_rejects_incomplete_workflows() {
757 let read_only = vec![obs("read_fixture", json!({"fixture_id": "calc"}), 1)];
759 assert!(!validate_ordering(&read_only));
760 let skipped_run = vec![
762 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
763 obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 2),
764 ];
765 assert!(!validate_ordering(&skipped_run));
766 assert!(!validate_ordering(&[]));
767 }
768
769 #[test]
770 fn ordering_ignores_unknown_tool_names() {
771 let calls = vec![
773 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
774 obs("some_bogus_tool", json!({}), 1),
775 obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
776 obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
777 ];
778 assert!(validate_ordering(&calls));
779 }
780
781 #[test]
782 fn duplicates_are_detected_by_name_and_identical_arguments() {
783 let repeated_read = vec![
784 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
785 obs("read_fixture", json!({"fixture_id": "calc"}), 2),
786 ];
787 let duplicates = find_duplicates(&repeated_read);
788 assert_eq!(duplicates.len(), 1);
789 assert_eq!(duplicates[0].name, "read_fixture");
790 assert_eq!(duplicates[0].turn, 2);
791
792 let different_args = vec![
794 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
795 obs("read_fixture", json!({"fixture_id": "greeter"}), 1),
796 ];
797 assert!(find_duplicates(&different_args).is_empty());
798
799 let repeated_run = vec![
801 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
802 obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
803 obs("run_fixture_test", json!({"fixture_id": "calc"}), 3),
804 ];
805 assert_eq!(find_duplicates(&repeated_run).len(), 1);
806
807 let reordered = vec![
809 obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 1),
810 obs("write_fixture_patch", json!({"patch": "fix", "fixture_id": "calc"}), 2),
811 ];
812 assert_eq!(find_duplicates(&reordered).len(), 1);
813 }
814
815 #[test]
816 fn argument_validation_accepts_exact_calls() {
817 let calls = vec![
818 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
819 obs("run_fixture_test", json!({"fixture_id": "greeter"}), 2),
820 obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix add"}), 3),
821 ];
822 assert!(all_arguments_valid(&calls));
823 }
824
825 #[test]
826 fn argument_validation_rejects_missing_required_fields() {
827 assert!(!all_arguments_valid(&[obs("read_fixture", json!({}), 1)]));
828 assert!(!all_arguments_valid(&[obs(
829 "write_fixture_patch",
830 json!({"fixture_id": "calc"}),
831 1
832 )]));
833 }
834
835 #[test]
836 fn argument_validation_rejects_wrong_enum_value() {
837 assert!(!all_arguments_valid(&[obs("read_fixture", json!({"fixture_id": "nope"}), 1)]));
838 }
839
840 #[test]
841 fn argument_validation_rejects_extra_properties() {
842 assert!(!all_arguments_valid(&[obs(
844 "read_fixture",
845 json!({"fixture_id": "calc", "extra": 1}),
846 1
847 )]));
848 }
849
850 #[test]
851 fn argument_validation_rejects_wrong_types_and_unknown_tools() {
852 assert!(!all_arguments_valid(&[obs("read_fixture", json!({"fixture_id": 42}), 1)]));
853 assert!(!all_arguments_valid(&[obs("bogus_tool", json!({}), 1)]));
854 assert!(!all_arguments_valid(&[obs(
855 "read_fixture",
856 serde_json::Value::String("not an object".to_string()),
857 1
858 )]));
859 }
860
861 #[test]
862 fn convergence_counting_on_synthetic_turns() {
863 assert_eq!(assess_turn(true, 1, 8), TurnVerdict::Continue);
865 assert_eq!(assess_turn(true, 2, 8), TurnVerdict::Continue);
866 assert_eq!(assess_turn(true, 3, 8), TurnVerdict::Continue);
867 assert_eq!(assess_turn(false, 4, 8), TurnVerdict::FinalAnswer);
868 assert_eq!(assess_turn(false, 1, 8), TurnVerdict::NoToolCall);
870 assert_eq!(assess_turn(true, 8, 8), TurnVerdict::TurnLimitBreached);
872 assert_eq!(assess_turn(false, 8, 8), TurnVerdict::FinalAnswer);
874 }
875
876 #[test]
877 fn outcome_serde_roundtrip_and_snake_case() {
878 let outcome = ToolLoopOutcome::new(
879 true,
880 4,
881 TOOL_LOOP_MAX_TURNS,
882 vec![
883 obs("read_fixture", json!({"fixture_id": "calc"}), 1),
884 obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
885 obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
886 ],
887 None,
888 Some("final status: pass".to_string()),
889 );
890 let json = serde_json::to_string(&outcome).expect("ser");
891 let back: ToolLoopOutcome = serde_json::from_str(&json).expect("de");
892 assert_eq!(back, outcome);
893 let value: serde_json::Value = serde_json::from_str(&json).expect("parse");
894 for key in [
895 "converged",
896 "turns_used",
897 "max_turns",
898 "tool_calls",
899 "failure_class",
900 "final_answer",
901 ] {
902 assert!(value.get(key).is_some(), "missing snake_case key {key}");
903 }
904 assert_eq!(value["tool_calls"][0]["arguments"]["fixture_id"], "calc");
905 assert!(value["tool_calls"][0].get("turn").is_some());
906 assert_eq!(value["converged"], true);
907 let rendered = json.to_lowercase();
909 assert!(
910 !rendered.contains("assertion failed"),
911 "tool outputs must never enter the outcome"
912 );
913 assert!(!rendered.contains("test harness"), "prompts must never enter the outcome");
914 assert!(!rendered.contains("\"source\""), "tool outputs must never enter the outcome");
915 }
916
917 #[test]
918 fn final_answer_is_truncated_char_safe() {
919 let long = "€".repeat(600);
920 let outcome = ToolLoopOutcome::new(true, 1, 8, vec![], None, Some(long.clone()));
921 let answer = outcome.final_answer.expect("answer present");
922 assert_eq!(answer.chars().count(), MAX_EXCERPT_CHARS);
923 assert_eq!(answer, "€".repeat(MAX_EXCERPT_CHARS));
924 let short = ToolLoopOutcome::new(true, 1, 8, vec![], None, Some("ok".to_string()));
925 assert_eq!(short.final_answer.as_deref(), Some("ok"));
926 }
927
928 #[test]
929 fn live_gate_closed_returns_refusal_without_network() {
930 with_live_tests_env(None, || {
933 let token = SecretString::new("cfut_test_synthetic_token_0001");
934 let error = run_tool_loop(
935 "0123456789abcdef0123456789abcdef",
936 &token,
937 "https://example.test/ai/v1",
938 "@cf/deepseek-ai/deepseek-v4-flash-0731",
939 Duration::from_secs(1),
940 )
941 .expect_err("gate must refuse without AUTH_CLOUDFLARE_LIVE_TESTS=1");
942 match error {
943 CloudflareError::MissingEnv { env_var, hint } => {
944 assert_eq!(env_var, LIVE_TESTS_ENV);
945 assert!(hint.contains(LIVE_TESTS_ENV), "hint must name the env var: {hint}");
946 assert!(
947 hint.to_lowercase().contains("opt-in"),
948 "hint must explain the opt-in gate: {hint}"
949 );
950 },
951 other => panic!("expected MissingEnv refusal, got {other:?}"),
952 }
953 });
954 }
955
956 #[test]
957 fn live_tests_enabled_matches_exactly_one() {
958 with_live_tests_env(Some("1"), || assert!(live_tests_enabled()));
959 with_live_tests_env(None, || assert!(!live_tests_enabled()));
960 with_live_tests_env(Some("0"), || assert!(!live_tests_enabled()));
961 with_live_tests_env(Some("yes"), || assert!(!live_tests_enabled()));
962 with_live_tests_env(Some("1 "), || {
963 assert!(!live_tests_enabled(), "whitespace is not exactly '1'");
964 });
965 }
966
967 #[test]
968 fn fake_tools_are_deterministic_and_in_memory() {
969 let read = execute_tool("read_fixture", &json!({"fixture_id": "calc"})).expect("read succeeds");
970 assert_eq!(read["status"], "success");
971 let source = read["source"].as_str().expect("source present");
972 assert!(source.contains("fn add"));
973 assert!(source.contains("assert_eq!(add(2, 2), 4)"));
974 assert_eq!(
975 execute_tool("read_fixture", &json!({"fixture_id": "calc"})).expect("deterministic"),
976 read
977 );
978
979 let run = execute_tool("run_fixture_test", &json!({"fixture_id": "calc"})).expect("run succeeds");
980 assert_eq!(run["status"], "fail", "the report is a controlled failure");
981 assert_eq!(run["output"], "assertion failed: add(2, 2) == 4, got 3");
982
983 let write = execute_tool("write_fixture_patch", &json!({"fixture_id": "calc", "patch": "fix"}))
984 .expect("write succeeds");
985 assert_eq!(write["status"], "success");
986 assert_eq!(write["applied"].as_bool(), Some(true));
987
988 assert!(execute_tool("read_fixture", &json!({"fixture_id": "nope"})).is_err());
989 assert!(execute_tool("bogus", &json!({})).is_err());
990 }
991
992 #[test]
993 fn tool_schemas_are_openai_function_shaped() {
994 let schemas = tool_schemas();
995 assert_eq!(schemas.len(), 3);
996 let names: Vec<&str> = schemas.iter().map(|s| s["function"]["name"].as_str().unwrap()).collect();
997 assert_eq!(names, vec!["read_fixture", "run_fixture_test", "write_fixture_patch"]);
998 for schema in &schemas {
999 assert_eq!(schema["type"], "function");
1000 assert_eq!(schema["function"]["parameters"]["type"], "object");
1001 assert_eq!(schema["function"]["parameters"]["additionalProperties"].as_bool(), Some(false));
1002 let required = schema["function"]["parameters"]["required"]
1003 .as_array()
1004 .expect("required present");
1005 assert!(!required.is_empty());
1006 }
1007 assert_eq!(
1009 schemas[0]["function"]["parameters"]["properties"]["fixture_id"]["enum"],
1010 json!(["calc", "greeter"])
1011 );
1012 }
1013
1014 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1017
1018 fn with_live_tests_env(value: Option<&str>, f: impl FnOnce()) {
1019 let _guard = ENV_LOCK.lock().unwrap();
1020 let saved = std::env::var(LIVE_TESTS_ENV).ok();
1021 match value {
1022 Some(value) => std::env::set_var(LIVE_TESTS_ENV, value),
1023 None => std::env::remove_var(LIVE_TESTS_ENV),
1024 }
1025 f();
1026 match saved {
1027 Some(saved) => std::env::set_var(LIVE_TESTS_ENV, saved),
1028 None => std::env::remove_var(LIVE_TESTS_ENV),
1029 }
1030 }
1031}