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