use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::config::SecretString;
use crate::error::CloudflareError;
use crate::fetch::auth_header;
use crate::health::{FailureClass, MAX_EXCERPT_CHARS};
pub const LIVE_TESTS_ENV: &str = "AUTH_CLOUDFLARE_LIVE_TESTS";
pub const TOOL_LOOP_MAX_TURNS: u32 = 8;
pub 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.";
pub const TOOL_LOOP_USER_PROMPT: &str = "Begin the fixture workflow. The target fixture id is 'calc'.";
const FIXTURE_ID_VALUES: &[&str] = &["calc", "greeter"];
const FIXTURE_CALC_SOURCE: &str = concat!(
"//! calc fixture: add() is intentionally off by one so the test fails until patched.\n",
"pub fn add(a: i32, b: i32) -> i32 {\n",
" a + b - 1\n",
"}\n",
"\n",
"#[test]\n",
"fn test_add() {\n",
" assert_eq!(add(2, 2), 4);\n",
"}\n",
);
const FIXTURE_GREETER_SOURCE: &str = concat!(
"//! greeter fixture: greet() is missing the comma so the test fails until patched.\n",
"pub fn greet(name: &str) -> String {\n",
" format!(\"Hello {name}!\")\n",
"}\n",
"\n",
"#[test]\n",
"fn test_greet() {\n",
" assert_eq!(greet(\"World\"), \"Hello, World!\");\n",
"}\n",
);
struct ParamSpec {
name: &'static str,
enum_values: &'static [&'static str],
}
struct ToolSpec {
name: &'static str,
description: &'static str,
params: &'static [ParamSpec],
required: &'static [&'static str],
}
const TOOL_SPECS: &[ToolSpec] = &[
ToolSpec {
name: "read_fixture",
description: "Read the Rust fixture source for the given fixture_id.",
params: &[ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES }],
required: &["fixture_id"],
},
ToolSpec {
name: "run_fixture_test",
description: "Run the fixture's test suite and return the controlled test report for the given fixture_id.",
params: &[ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES }],
required: &["fixture_id"],
},
ToolSpec {
name: "write_fixture_patch",
description: "Apply a patch that fixes the fixture source for the given fixture_id.",
params: &[
ParamSpec { name: "fixture_id", enum_values: FIXTURE_ID_VALUES },
ParamSpec { name: "patch", enum_values: &[] },
],
required: &["fixture_id", "patch"],
},
];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ToolCallObservation {
pub name: String,
pub arguments: serde_json::Value,
pub turn: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ToolLoopOutcome {
pub converged: bool,
pub turns_used: u32,
pub max_turns: u32,
pub tool_calls: Vec<ToolCallObservation>,
pub failure_class: Option<FailureClass>,
pub final_answer: Option<String>,
}
impl ToolLoopOutcome {
pub fn new(
converged: bool,
turns_used: u32,
max_turns: u32,
tool_calls: Vec<ToolCallObservation>,
failure_class: Option<FailureClass>,
final_answer: Option<String>,
) -> Self {
Self {
converged,
turns_used,
max_turns,
tool_calls,
failure_class,
final_answer: final_answer.map(|answer| truncate_final_answer(&answer)),
}
}
}
fn truncate_final_answer(answer: &str) -> String {
if answer.chars().count() <= MAX_EXCERPT_CHARS {
answer.to_string()
} else {
answer.chars().take(MAX_EXCERPT_CHARS).collect()
}
}
pub fn live_tests_enabled() -> bool {
std::env::var(LIVE_TESTS_ENV).is_ok_and(|value| value == "1")
}
pub fn tool_schemas() -> Vec<serde_json::Value> {
TOOL_SPECS
.iter()
.map(|spec| {
let mut properties = serde_json::Map::new();
for param in spec.params {
let mut property = serde_json::json!({ "type": "string" });
if !param.enum_values.is_empty() {
property["enum"] = serde_json::Value::Array(
param
.enum_values
.iter()
.map(|value| serde_json::Value::String((*value).to_string()))
.collect(),
);
}
properties.insert(param.name.to_string(), property);
}
serde_json::json!({
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": {
"type": "object",
"properties": serde_json::Value::Object(properties),
"required": spec.required,
"additionalProperties": false,
},
},
})
})
.collect()
}
pub fn fixture_source(fixture_id: &str) -> Option<String> {
match fixture_id {
"calc" => Some(FIXTURE_CALC_SOURCE.to_string()),
"greeter" => Some(FIXTURE_GREETER_SOURCE.to_string()),
_ => None,
}
}
fn fixture_test_report(fixture_id: &str) -> Option<String> {
match fixture_id {
"calc" => Some("assertion failed: add(2, 2) == 4, got 3".to_string()),
"greeter" => Some("assertion failed: greet(\"World\") == \"Hello, World!\", got \"Hello World!\"".to_string()),
_ => None,
}
}
pub fn execute_tool(name: &str, arguments: &serde_json::Value) -> Result<serde_json::Value, String> {
let fixture_id = arguments
.get("fixture_id")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
match name {
"read_fixture" => {
let source = fixture_source(fixture_id).ok_or_else(|| format!("unknown fixture_id {fixture_id:?}"))?;
Ok(serde_json::json!({ "status": "success", "fixture_id": fixture_id, "source": source }))
},
"run_fixture_test" => {
let output = fixture_test_report(fixture_id).ok_or_else(|| format!("unknown fixture_id {fixture_id:?}"))?;
Ok(serde_json::json!({ "status": "fail", "fixture_id": fixture_id, "output": output }))
},
"write_fixture_patch" => {
if fixture_source(fixture_id).is_none() {
return Err(format!("unknown fixture_id {fixture_id:?}"));
}
Ok(serde_json::json!({ "status": "success", "applied": true, "fixture_id": fixture_id }))
},
other => Err(format!("unknown tool {other}")),
}
}
pub fn validate_ordering(calls: &[ToolCallObservation]) -> bool {
let stages: Vec<u8> = calls.iter().filter_map(|call| stage_of(&call.name)).collect();
if stages.len() < 3 {
return false;
}
let has_all_stages = stages.contains(&1) && stages.contains(&2) && stages.contains(&3);
has_all_stages && stages.windows(2).all(|pair| pair[0] <= pair[1])
}
fn stage_of(name: &str) -> Option<u8> {
match name {
"read_fixture" => Some(1),
"run_fixture_test" => Some(2),
"write_fixture_patch" => Some(3),
_ => None,
}
}
pub fn find_duplicates(calls: &[ToolCallObservation]) -> Vec<&ToolCallObservation> {
let mut seen: Vec<(&str, &serde_json::Value)> = Vec::new();
let mut duplicates: Vec<&ToolCallObservation> = Vec::new();
for call in calls {
let already_seen = seen.iter().any(|(name, args)| *name == call.name && **args == call.arguments);
if already_seen {
duplicates.push(call);
} else {
seen.push((call.name.as_str(), &call.arguments));
}
}
duplicates
}
pub fn all_arguments_valid(calls: &[ToolCallObservation]) -> bool {
calls.iter().all(|call| arguments_valid_for_tool(&call.name, &call.arguments))
}
fn spec_for(name: &str) -> Option<&'static ToolSpec> {
TOOL_SPECS.iter().find(|spec| spec.name == name)
}
fn arguments_valid_for_tool(name: &str, arguments: &serde_json::Value) -> bool {
let Some(spec) = spec_for(name) else {
return false;
};
let serde_json::Value::Object(map) = arguments else {
return false;
};
if spec.required.iter().any(|required| !map.contains_key(*required)) {
return false;
}
for (key, value) in map.iter() {
let Some(param) = spec.params.iter().find(|param| param.name == key.as_str()) else {
return false;
};
if !value.is_string() {
return false;
}
if !param.enum_values.is_empty() {
let Some(actual) = value.as_str() else {
return false;
};
if !param.enum_values.contains(&actual) {
return false;
}
}
}
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TurnVerdict {
NoToolCall,
FinalAnswer,
Continue,
TurnLimitBreached,
}
fn assess_turn(has_tool_calls: bool, turn: u32, max_turns: u32) -> TurnVerdict {
if !has_tool_calls {
if turn == 1 { TurnVerdict::NoToolCall } else { TurnVerdict::FinalAnswer }
} else if turn >= max_turns {
TurnVerdict::TurnLimitBreached
} else {
TurnVerdict::Continue
}
}
pub fn run_tool_loop(
account_id: &str,
token: &SecretString,
base_url: &str,
model_id: &str,
timeout: Duration,
) -> Result<ToolLoopOutcome, CloudflareError> {
if !live_tests_enabled() {
return Err(CloudflareError::MissingEnv {
env_var: LIVE_TESTS_ENV,
hint: format!(
"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."
),
});
}
if token.as_ref().trim().is_empty() {
return Err(CloudflareError::MissingEnv {
env_var: crate::auth::TOKEN_ENV,
hint: "the API token is empty - export a scoped Workers AI token (Account → Workers AI → Write)"
.to_string(),
});
}
let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
let agent = ureq::AgentBuilder::new().timeout(timeout).build();
let mut messages: Vec<serde_json::Value> = vec![
serde_json::json!({ "role": "system", "content": TOOL_LOOP_SYSTEM_PROMPT }),
serde_json::json!({ "role": "user", "content": TOOL_LOOP_USER_PROMPT }),
];
let mut calls: Vec<ToolCallObservation> = Vec::new();
let mut turn: u32 = 0;
loop {
turn += 1;
let request = serde_json::json!({
"model": model_id,
"messages": messages,
"tools": tool_schemas(),
"tool_choice": "auto",
});
let response = post_chat_completion(&agent, &url, token, &request)?;
let message = response
.get("choices")
.and_then(|choices| choices.as_array())
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
.cloned()
.ok_or_else(|| CloudflareError::Http("tool-loop response missing choices[0].message".to_string()))?;
let tool_calls = message.get("tool_calls").and_then(|calls| calls.as_array()).cloned();
let has_tool_calls = tool_calls.as_ref().is_some_and(|calls| !calls.is_empty());
let final_answer = message.get("content").and_then(serde_json::Value::as_str).map(str::to_string);
match assess_turn(has_tool_calls, turn, TOOL_LOOP_MAX_TURNS) {
TurnVerdict::NoToolCall => {
return Ok(ToolLoopOutcome::new(
false,
turn,
TOOL_LOOP_MAX_TURNS,
calls,
Some(FailureClass::NoToolCall),
final_answer,
));
},
TurnVerdict::FinalAnswer => {
let converged =
validate_ordering(&calls) && find_duplicates(&calls).is_empty() && all_arguments_valid(&calls);
let failure_class = if converged { None } else { Some(FailureClass::ToolLoopDidNotConverge) };
return Ok(ToolLoopOutcome::new(
converged,
turn,
TOOL_LOOP_MAX_TURNS,
calls,
failure_class,
final_answer,
));
},
TurnVerdict::TurnLimitBreached => {
return Ok(ToolLoopOutcome::new(
false,
turn,
TOOL_LOOP_MAX_TURNS,
calls,
Some(FailureClass::ToolLoopDidNotConverge),
None,
));
},
TurnVerdict::Continue => {
let tool_calls = tool_calls.expect("Continue implies non-empty tool calls");
let mut turn_calls: Vec<ToolCallObservation> = Vec::new();
let mut results: Vec<(String, serde_json::Value)> = Vec::new();
for tool_call in &tool_calls {
let name = tool_call
.pointer("/function/name")
.and_then(serde_json::Value::as_str)
.unwrap_or("<missing>");
let raw_arguments = tool_call
.pointer("/function/arguments")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let call_id = tool_call.get("id").and_then(serde_json::Value::as_str).unwrap_or_default();
if spec_for(name).is_none() {
calls.push(ToolCallObservation {
name: name.to_string(),
arguments: serde_json::Value::String(raw_arguments.to_string()),
turn,
});
return Ok(ToolLoopOutcome::new(
false,
turn,
TOOL_LOOP_MAX_TURNS,
calls,
Some(FailureClass::InvalidToolName),
None,
));
}
let arguments: serde_json::Value = match serde_json::from_str(raw_arguments) {
Ok(value) => value,
Err(_) => {
calls.push(ToolCallObservation {
name: name.to_string(),
arguments: serde_json::Value::String(raw_arguments.to_string()),
turn,
});
return Ok(ToolLoopOutcome::new(
false,
turn,
TOOL_LOOP_MAX_TURNS,
calls,
Some(FailureClass::InvalidToolArguments),
None,
));
},
};
if !arguments_valid_for_tool(name, &arguments) {
calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
return Ok(ToolLoopOutcome::new(
false,
turn,
TOOL_LOOP_MAX_TURNS,
calls,
Some(FailureClass::InvalidToolArguments),
None,
));
}
let duplicated = calls
.iter()
.chain(turn_calls.iter())
.any(|prior| prior.name == name && prior.arguments == arguments);
if duplicated {
calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
return Ok(ToolLoopOutcome::new(
false,
turn,
TOOL_LOOP_MAX_TURNS,
calls,
Some(FailureClass::DuplicateToolCall),
None,
));
}
let result = execute_tool(name, &arguments)
.map_err(|message| CloudflareError::Http(redact_token(&message, token.as_ref())))?;
turn_calls.push(ToolCallObservation { name: name.to_string(), arguments, turn });
results.push((call_id.to_string(), result));
}
messages.push(serde_json::json!({ "role": "assistant", "content": null, "tool_calls": tool_calls }));
for (call_id, result) in results {
messages.push(serde_json::json!({
"role": "tool",
"tool_call_id": call_id,
"content": result.to_string(),
}));
}
calls.extend(turn_calls);
},
}
}
}
fn post_chat_completion(
agent: &ureq::Agent,
url: &str,
token: &SecretString,
body: &serde_json::Value,
) -> Result<serde_json::Value, CloudflareError> {
let request = agent
.post(url)
.set("Authorization", &auth_header(token))
.set("Accept", "application/json")
.set("Content-Type", "application/json");
let payload = body.to_string();
let (status, response) = match request.send_string(&payload) {
Ok(response) => (response.status(), response),
Err(ureq::Error::Status(status, response)) => (status, response),
Err(transport) => {
return Err(CloudflareError::Http(redact_token(&transport.to_string(), token.as_ref())));
},
};
let raw = response.into_string().map_err(|error| {
CloudflareError::Http(redact_token(&format!("read response body: {error}"), token.as_ref()))
})?;
let raw = redact_token(&raw, token.as_ref());
if status != 200 {
return Err(map_http_error(status, &raw));
}
serde_json::from_str(&raw).map_err(|_| CloudflareError::Http("tool-loop response was not valid JSON".to_string()))
}
fn map_http_error(status: u16, body: &str) -> CloudflareError {
let labeled = |code: u16, label: &str| -> CloudflareError {
CloudflareError::Api {
code: u32::from(code),
message: format!(
"{label} (HTTP {code}){}",
envelope_message(body).map(|m| format!(": {m}")).unwrap_or_default()
),
}
};
match status {
401 => labeled(401, "unauthorized"),
403 => labeled(403, "forbidden"),
429 => labeled(429, "rate limited"),
500..=599 => {
CloudflareError::Http(format!("tool-loop endpoint returned HTTP {status} (transient server error)"))
},
other => CloudflareError::Api { code: u32::from(other), message: format!("HTTP {other}: {body}") },
}
}
fn envelope_message(body: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(body)
.ok()?
.get("errors")?
.as_array()?
.first()?
.get("message")?
.as_str()
.map(str::to_string)
}
fn redact_token(text: &str, token: &str) -> String {
if token.is_empty() {
text.to_string()
} else {
text.replace(token, "<redacted>")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn obs(name: &str, arguments: serde_json::Value, turn: u32) -> ToolCallObservation {
ToolCallObservation { name: name.to_string(), arguments, turn }
}
#[test]
fn ordering_accepts_the_full_workflow() {
let calls = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
obs(
"write_fixture_patch",
json!({"fixture_id": "calc", "patch": "fix the off-by-one"}),
3,
),
];
assert!(validate_ordering(&calls));
}
#[test]
fn ordering_allows_extra_reads_before_the_run() {
let calls = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("read_fixture", json!({"fixture_id": "greeter"}), 1),
obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
];
assert!(validate_ordering(&calls));
}
#[test]
fn ordering_rejects_reread_after_run() {
let calls = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
obs("read_fixture", json!({"fixture_id": "calc"}), 3),
];
assert!(!validate_ordering(&calls));
}
#[test]
fn ordering_rejects_run_before_read_and_run_after_write() {
let run_first = vec![
obs("run_fixture_test", json!({"fixture_id": "calc"}), 1),
obs("read_fixture", json!({"fixture_id": "calc"}), 2),
obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
];
assert!(!validate_ordering(&run_first));
let write_then_run = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 2),
obs("run_fixture_test", json!({"fixture_id": "calc"}), 3),
];
assert!(!validate_ordering(&write_then_run));
}
#[test]
fn ordering_rejects_incomplete_workflows() {
let read_only = vec![obs("read_fixture", json!({"fixture_id": "calc"}), 1)];
assert!(!validate_ordering(&read_only));
let skipped_run = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 2),
];
assert!(!validate_ordering(&skipped_run));
assert!(!validate_ordering(&[]));
}
#[test]
fn ordering_ignores_unknown_tool_names() {
let calls = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("some_bogus_tool", json!({}), 1),
obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
];
assert!(validate_ordering(&calls));
}
#[test]
fn duplicates_are_detected_by_name_and_identical_arguments() {
let repeated_read = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("read_fixture", json!({"fixture_id": "calc"}), 2),
];
let duplicates = find_duplicates(&repeated_read);
assert_eq!(duplicates.len(), 1);
assert_eq!(duplicates[0].name, "read_fixture");
assert_eq!(duplicates[0].turn, 2);
let different_args = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("read_fixture", json!({"fixture_id": "greeter"}), 1),
];
assert!(find_duplicates(&different_args).is_empty());
let repeated_run = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
obs("run_fixture_test", json!({"fixture_id": "calc"}), 3),
];
assert_eq!(find_duplicates(&repeated_run).len(), 1);
let reordered = vec![
obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 1),
obs("write_fixture_patch", json!({"patch": "fix", "fixture_id": "calc"}), 2),
];
assert_eq!(find_duplicates(&reordered).len(), 1);
}
#[test]
fn argument_validation_accepts_exact_calls() {
let calls = vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("run_fixture_test", json!({"fixture_id": "greeter"}), 2),
obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix add"}), 3),
];
assert!(all_arguments_valid(&calls));
}
#[test]
fn argument_validation_rejects_missing_required_fields() {
assert!(!all_arguments_valid(&[obs("read_fixture", json!({}), 1)]));
assert!(!all_arguments_valid(&[obs(
"write_fixture_patch",
json!({"fixture_id": "calc"}),
1
)]));
}
#[test]
fn argument_validation_rejects_wrong_enum_value() {
assert!(!all_arguments_valid(&[obs("read_fixture", json!({"fixture_id": "nope"}), 1)]));
}
#[test]
fn argument_validation_rejects_extra_properties() {
assert!(!all_arguments_valid(&[obs(
"read_fixture",
json!({"fixture_id": "calc", "extra": 1}),
1
)]));
}
#[test]
fn argument_validation_rejects_wrong_types_and_unknown_tools() {
assert!(!all_arguments_valid(&[obs("read_fixture", json!({"fixture_id": 42}), 1)]));
assert!(!all_arguments_valid(&[obs("bogus_tool", json!({}), 1)]));
assert!(!all_arguments_valid(&[obs(
"read_fixture",
serde_json::Value::String("not an object".to_string()),
1
)]));
}
#[test]
fn convergence_counting_on_synthetic_turns() {
assert_eq!(assess_turn(true, 1, 8), TurnVerdict::Continue);
assert_eq!(assess_turn(true, 2, 8), TurnVerdict::Continue);
assert_eq!(assess_turn(true, 3, 8), TurnVerdict::Continue);
assert_eq!(assess_turn(false, 4, 8), TurnVerdict::FinalAnswer);
assert_eq!(assess_turn(false, 1, 8), TurnVerdict::NoToolCall);
assert_eq!(assess_turn(true, 8, 8), TurnVerdict::TurnLimitBreached);
assert_eq!(assess_turn(false, 8, 8), TurnVerdict::FinalAnswer);
}
#[test]
fn outcome_serde_roundtrip_and_snake_case() {
let outcome = ToolLoopOutcome::new(
true,
4,
TOOL_LOOP_MAX_TURNS,
vec![
obs("read_fixture", json!({"fixture_id": "calc"}), 1),
obs("run_fixture_test", json!({"fixture_id": "calc"}), 2),
obs("write_fixture_patch", json!({"fixture_id": "calc", "patch": "fix"}), 3),
],
None,
Some("final status: pass".to_string()),
);
let json = serde_json::to_string(&outcome).expect("ser");
let back: ToolLoopOutcome = serde_json::from_str(&json).expect("de");
assert_eq!(back, outcome);
let value: serde_json::Value = serde_json::from_str(&json).expect("parse");
for key in [
"converged",
"turns_used",
"max_turns",
"tool_calls",
"failure_class",
"final_answer",
] {
assert!(value.get(key).is_some(), "missing snake_case key {key}");
}
assert_eq!(value["tool_calls"][0]["arguments"]["fixture_id"], "calc");
assert!(value["tool_calls"][0].get("turn").is_some());
assert_eq!(value["converged"], true);
let rendered = json.to_lowercase();
assert!(
!rendered.contains("assertion failed"),
"tool outputs must never enter the outcome"
);
assert!(!rendered.contains("test harness"), "prompts must never enter the outcome");
assert!(!rendered.contains("\"source\""), "tool outputs must never enter the outcome");
}
#[test]
fn final_answer_is_truncated_char_safe() {
let long = "€".repeat(600);
let outcome = ToolLoopOutcome::new(true, 1, 8, vec![], None, Some(long.clone()));
let answer = outcome.final_answer.expect("answer present");
assert_eq!(answer.chars().count(), MAX_EXCERPT_CHARS);
assert_eq!(answer, "€".repeat(MAX_EXCERPT_CHARS));
let short = ToolLoopOutcome::new(true, 1, 8, vec![], None, Some("ok".to_string()));
assert_eq!(short.final_answer.as_deref(), Some("ok"));
}
#[test]
fn live_gate_closed_returns_refusal_without_network() {
with_live_tests_env(None, || {
let token = SecretString::new("cfut_test_synthetic_token_0001");
let error = run_tool_loop(
"0123456789abcdef0123456789abcdef",
&token,
"https://example.test/ai/v1",
"@cf/deepseek-ai/deepseek-v4-flash-0731",
Duration::from_secs(1),
)
.expect_err("gate must refuse without AUTH_CLOUDFLARE_LIVE_TESTS=1");
match error {
CloudflareError::MissingEnv { env_var, hint } => {
assert_eq!(env_var, LIVE_TESTS_ENV);
assert!(hint.contains(LIVE_TESTS_ENV), "hint must name the env var: {hint}");
assert!(
hint.to_lowercase().contains("opt-in"),
"hint must explain the opt-in gate: {hint}"
);
},
other => panic!("expected MissingEnv refusal, got {other:?}"),
}
});
}
#[test]
fn live_tests_enabled_matches_exactly_one() {
with_live_tests_env(Some("1"), || assert!(live_tests_enabled()));
with_live_tests_env(None, || assert!(!live_tests_enabled()));
with_live_tests_env(Some("0"), || assert!(!live_tests_enabled()));
with_live_tests_env(Some("yes"), || assert!(!live_tests_enabled()));
with_live_tests_env(Some("1 "), || {
assert!(!live_tests_enabled(), "whitespace is not exactly '1'");
});
}
#[test]
fn fake_tools_are_deterministic_and_in_memory() {
let read = execute_tool("read_fixture", &json!({"fixture_id": "calc"})).expect("read succeeds");
assert_eq!(read["status"], "success");
let source = read["source"].as_str().expect("source present");
assert!(source.contains("fn add"));
assert!(source.contains("assert_eq!(add(2, 2), 4)"));
assert_eq!(
execute_tool("read_fixture", &json!({"fixture_id": "calc"})).expect("deterministic"),
read
);
let run = execute_tool("run_fixture_test", &json!({"fixture_id": "calc"})).expect("run succeeds");
assert_eq!(run["status"], "fail", "the report is a controlled failure");
assert_eq!(run["output"], "assertion failed: add(2, 2) == 4, got 3");
let write = execute_tool("write_fixture_patch", &json!({"fixture_id": "calc", "patch": "fix"}))
.expect("write succeeds");
assert_eq!(write["status"], "success");
assert_eq!(write["applied"].as_bool(), Some(true));
assert!(execute_tool("read_fixture", &json!({"fixture_id": "nope"})).is_err());
assert!(execute_tool("bogus", &json!({})).is_err());
}
#[test]
fn tool_schemas_are_openai_function_shaped() {
let schemas = tool_schemas();
assert_eq!(schemas.len(), 3);
let names: Vec<&str> = schemas.iter().map(|s| s["function"]["name"].as_str().unwrap()).collect();
assert_eq!(names, vec!["read_fixture", "run_fixture_test", "write_fixture_patch"]);
for schema in &schemas {
assert_eq!(schema["type"], "function");
assert_eq!(schema["function"]["parameters"]["type"], "object");
assert_eq!(schema["function"]["parameters"]["additionalProperties"].as_bool(), Some(false));
let required = schema["function"]["parameters"]["required"]
.as_array()
.expect("required present");
assert!(!required.is_empty());
}
assert_eq!(
schemas[0]["function"]["parameters"]["properties"]["fixture_id"]["enum"],
json!(["calc", "greeter"])
);
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_live_tests_env(value: Option<&str>, f: impl FnOnce()) {
let _guard = ENV_LOCK.lock().unwrap();
let saved = std::env::var(LIVE_TESTS_ENV).ok();
match value {
Some(value) => std::env::set_var(LIVE_TESTS_ENV, value),
None => std::env::remove_var(LIVE_TESTS_ENV),
}
f();
match saved {
Some(saved) => std::env::set_var(LIVE_TESTS_ENV, saved),
None => std::env::remove_var(LIVE_TESTS_ENV),
}
}
}