use super::NoopGuard;
use super::config::{DefaultGuardConfig, ReasoningOnlyAction};
use super::default::DefaultGuard;
use agent_base::engine::react_loop_guard::{GuardCtx, GuardDecision, ReactLoopGuard};
use agent_base::llm_trait::response::{
ChatResponse, ChatStream, FinishReason as LlmFinishReason, StreamChunk,
};
use agent_base::llm_trait::{Capabilities, ChatRequest, LlmError, LlmProvider};
use agent_base::types::{FinishReason, SessionId};
use serde_json::Value;
use std::pin::Pin;
use std::sync::Arc;
fn make_ctx(
reasoning_only_strikes: usize,
empty_response_strikes: usize,
run_has_tool_calls: bool,
is_reasoning_only: bool,
is_empty_response: bool,
is_text_only: bool,
) -> GuardCtx {
GuardCtx {
session_id: SessionId {
id: 1,
external_id: None,
},
turn_count: 1,
user_input: "test".to_string(),
model_response: "response".to_string(),
finish_reason: FinishReason::Stop,
available_tools: vec![],
reasoning_only_strikes,
empty_response_strikes,
run_has_tool_calls,
last_tool_calls_invalid: false,
all_user_inputs: vec!["test".to_string()],
is_reasoning_only,
is_empty_response,
is_text_only,
thinking_disabled: false,
original_thinking_enabled: true,
remaining_turns: 50,
}
}
fn make_ctx_with_text(
user_input: &str,
model_response: &str,
run_has_tool_calls: bool,
is_text_only: bool,
) -> GuardCtx {
GuardCtx {
session_id: SessionId {
id: 1,
external_id: None,
},
turn_count: 2,
user_input: user_input.to_string(),
model_response: model_response.to_string(),
finish_reason: FinishReason::Stop,
available_tools: vec!["echo".to_string()],
reasoning_only_strikes: 0,
empty_response_strikes: 0,
run_has_tool_calls,
last_tool_calls_invalid: false,
all_user_inputs: vec![user_input.to_string()],
is_reasoning_only: false,
is_empty_response: false,
is_text_only,
thinking_disabled: false,
original_thinking_enabled: true,
remaining_turns: 50,
}
}
struct MockJudgeClient {
response: String,
}
impl MockJudgeClient {
fn new(response: Value) -> Self {
Self {
response: response.to_string(),
}
}
}
#[async_trait::async_trait]
impl LlmProvider for MockJudgeClient {
async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
let chunks = vec![
Ok(StreamChunk::Text(self.response.clone())),
Ok(StreamChunk::Stop {
finish_reason: Some("stop".to_string()),
}),
];
Ok(ChatStream::new(Box::pin(futures_util::stream::iter(
chunks,
))))
}
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
Ok(ChatResponse {
content: self.response.clone(),
tool_calls: vec![],
usage: agent_base::UsageInfo::default(),
finish_reason: LlmFinishReason::Stop,
raw: None,
reasoning_content: None,
thinking_signature: None,
})
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
fn info(&self) -> agent_base::llm_trait::ProviderInfo {
agent_base::llm_trait::ProviderInfo {
name: "mock-judge".to_string(),
model: "mock-model".to_string(),
version: None,
}
}
}
struct MockTimeoutClient;
#[async_trait::async_trait]
impl LlmProvider for MockTimeoutClient {
async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
struct HangingStream;
impl futures_core::Stream for HangingStream {
type Item = Result<StreamChunk, LlmError>;
fn poll_next(
self: Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
std::task::Poll::Pending }
}
Ok(ChatStream::new(Box::pin(HangingStream)))
}
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
std::future::pending().await
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
fn info(&self) -> agent_base::llm_trait::ProviderInfo {
agent_base::llm_trait::ProviderInfo {
name: "mock-timeout".to_string(),
model: "mock-model".to_string(),
version: None,
}
}
}
struct MockErrorClient;
#[async_trait::async_trait]
impl LlmProvider for MockErrorClient {
async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
Err(LlmError::llm("simulated stream error"))
}
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
Err(LlmError::llm("simulated judge error"))
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
fn info(&self) -> agent_base::llm_trait::ProviderInfo {
agent_base::llm_trait::ProviderInfo {
name: "mock-error".to_string(),
model: "mock-model".to_string(),
version: None,
}
}
}
#[tokio::test]
async fn test_reasoning_only_below_threshold() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(1, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(
decision,
GuardDecision::Continue { nudge: Some(_) }
));
}
#[tokio::test]
async fn test_reasoning_only_at_threshold() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(3, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(decision, GuardDecision::Fail { .. }));
}
#[tokio::test]
async fn test_reasoning_only_with_tools_below_threshold() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let mut ctx = make_ctx(1, 0, false, true, false, false);
ctx.available_tools = vec!["echo".to_string()];
ctx.run_has_tool_calls = true;
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Continue { nudge: Some(_) }),
"reasoning_only with tools, below threshold → Continue, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_reasoning_only_with_tools_at_threshold() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let mut ctx = make_ctx(3, 0, false, true, false, false);
ctx.available_tools = vec!["echo".to_string()];
ctx.run_has_tool_calls = true;
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Fail { .. }),
"reasoning_only with tools, at threshold → Fail, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_empty_response_below_threshold() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 1, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(
decision,
GuardDecision::Continue { nudge: Some(_) }
));
}
#[tokio::test]
async fn test_empty_response_at_threshold() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 3, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(decision, GuardDecision::Fail { .. }));
}
#[tokio::test]
async fn test_empty_response_with_tool_calls() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 1, true, false, true, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Continue { nudge: Some(_) }),
"empty response with tools → Continue (nudge), got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_without_tool_calls() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 0, false, false, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(decision, GuardDecision::Complete));
}
#[tokio::test]
async fn test_text_only_with_tool_calls_no_llm_client() {
let config = DefaultGuardConfig {
use_llm_judge: true,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(0, 0, true, false, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(
decision,
GuardDecision::Continue { nudge: Some(_) }
));
}
#[tokio::test]
async fn test_text_only_with_tool_calls_no_llm_client_fail_open() {
let config = DefaultGuardConfig {
use_llm_judge: true,
judge_fail_open: true,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(0, 0, true, false, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(decision, GuardDecision::Complete));
}
#[tokio::test]
async fn test_text_only_with_tool_calls_judge_disabled() {
let config = DefaultGuardConfig {
use_llm_judge: false,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(0, 0, true, false, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(decision, GuardDecision::Complete));
}
#[tokio::test]
async fn test_text_only_no_tools_judge_enabled_no_client() {
let config = DefaultGuardConfig {
use_llm_judge: true,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let long_output = "x".repeat(300);
let ctx = make_ctx_with_text("query", &long_output, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"no tools + judge enabled + no client → Complete (skips judge), got: {:?}",
decision
);
}
#[tokio::test]
async fn test_custom_config() {
let config = DefaultGuardConfig {
reasoning_only_max_strikes: 5,
empty_response_max_strikes: 2,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(4, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(
decision,
GuardDecision::Continue { nudge: Some(_) }
));
let ctx = make_ctx(0, 2, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
assert!(matches!(decision, GuardDecision::Fail { .. }));
}
#[tokio::test]
async fn test_text_only_judge_says_done() {
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": true,
"reason": "task is complete"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text("what is 2+2?", "The answer is 4.", true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"judge says done → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_judge_says_not_done() {
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": false,
"reason": "only answered part of the question"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text(
"list all files and explain each",
"Here are the files:",
true,
true,
);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("incomplete"),
"nudge should mention incomplete: {}",
msg
);
assert!(
msg.contains("only answered part"),
"nudge should include judge reason: {}",
msg
);
}
other => panic!("expected Continue, got: {:?}", other),
}
}
#[tokio::test]
async fn test_text_only_judge_reason_propagated_to_nudge() {
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": false,
"reason": "missing file list and explanation"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text("list files and explain", "here are the files:", true, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("missing file list and explanation"),
"nudge should include judge reason: {}",
msg
);
}
other => panic!("expected Continue, got: {:?}", other),
}
}
#[tokio::test]
async fn test_text_only_short_response_detected_no_tools() {
let long_input = "a".repeat(200);
let short_output = "done";
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx_with_text(&long_input, short_output, false, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(msg.contains("incomplete"), "short response nudge: {}", msg);
}
other => panic!("expected Continue for short response, got: {:?}", other),
}
}
#[tokio::test]
async fn test_text_only_short_response_with_judge_done() {
let long_input = "a".repeat(200);
let short_output = "42";
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": true,
"reason": "answer is correct"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text(&long_input, short_output, true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"short response + judge done → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_after_rejected_tool_calls_never_judges_complete() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let mut ctx = make_ctx_with_text(
"analyze 3 projects with sub-agents",
"已启动 4 个子 agent 分析,稍后汇总。",
true,
true,
);
ctx.last_tool_calls_invalid = true;
let decision = guard.on_turn(&ctx).await;
match decision {
GuardDecision::Continue { nudge: Some(msg) } => {
assert!(
msg.contains("NOT executed"),
"nudge must tell the model the tool call never executed, got: {msg}"
);
}
other => panic!(
"text-only after rejected tool calls must Continue with a re-issue \
nudge, got: {other:?}"
),
}
}
#[tokio::test]
async fn test_text_only_skip_threshold() {
let long_output = "x".repeat(300);
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": false,
"reason": "would be incomplete but skipped"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text("query", &long_output, true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"response >= skip_threshold → Complete without calling judge, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_skip_judge_when_input_too_large() {
let long_input = "a".repeat(10_001);
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": false,
"reason": "would be incomplete but skipped"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text(&long_input, "short answer", true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"input > 10k → skip judge → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_judge_timeout_fail_open() {
let judge_client = Arc::new(MockTimeoutClient);
let config = DefaultGuardConfig {
judge_fail_open: true,
judge_timeout_secs: 1,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let ctx = make_ctx_with_text("query", "short answer", true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"timeout + fail_open → Complete (trust model), got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_judge_timeout_fail_closed() {
let judge_client = Arc::new(MockTimeoutClient);
let config = DefaultGuardConfig {
judge_fail_open: false,
judge_timeout_secs: 1,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let ctx = make_ctx_with_text("query", "short answer", true, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("Cannot verify"),
"fail_closed timeout → Continue with verify message: {}",
msg
);
}
other => panic!(
"expected Continue for timeout + fail_closed, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_text_only_judge_error_fail_closed() {
let judge_client = Arc::new(MockErrorClient);
let config = DefaultGuardConfig {
judge_fail_open: false,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let ctx = make_ctx_with_text("query", "short", true, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("Cannot verify"),
"fail_closed error → Continue with verify message: {}",
msg
);
}
other => panic!(
"expected Continue for judge error + fail_closed, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_text_only_judge_error_fail_open() {
let judge_client = Arc::new(MockErrorClient);
let config = DefaultGuardConfig {
judge_fail_open: true,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let ctx = make_ctx_with_text("query", "short", true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"fail_open + judge error → Complete (trust model), got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_short_response_judge_timeout_fail_closed() {
let judge_client = Arc::new(MockTimeoutClient);
let config = DefaultGuardConfig {
judge_fail_open: false,
judge_timeout_secs: 1,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let long_input = "a".repeat(200);
let ctx = make_ctx_with_text(&long_input, "42", true, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("Cannot verify"),
"short + timeout + fail_closed → Continue: {}",
msg
);
}
other => panic!(
"expected Continue for short response + timeout + fail_closed, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_text_only_short_response_judge_error_fail_closed() {
let judge_client = Arc::new(MockErrorClient);
let config = DefaultGuardConfig {
judge_fail_open: false,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let long_input = "a".repeat(200);
let ctx = make_ctx_with_text(&long_input, "42", true, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("Cannot verify"),
"short + error + fail_closed → Continue: {}",
msg
);
}
other => panic!(
"expected Continue for short response + judge error + fail_closed, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_text_only_short_response_judge_error_fail_open() {
let judge_client = Arc::new(MockErrorClient);
let config = DefaultGuardConfig {
judge_fail_open: true,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let long_input = "a".repeat(200);
let ctx = make_ctx_with_text(&long_input, "42", true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"short + error + fail_open → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_judge_with_multiple_user_messages() {
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": false,
"reason": "agent has not finished the task yet"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let mut ctx = make_ctx_with_text("继续", "I'll continue working on it.", true, true);
ctx.all_user_inputs = vec![
"帮我分析一下这个 bug 的根因".to_string(),
"好的,那你帮我修复一下".to_string(),
"继续".to_string(),
];
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("incomplete"),
"judge should see history and say incomplete: {}",
msg
);
}
other => panic!("expected Continue (judge says not done), got: {:?}", other),
}
}
#[tokio::test]
async fn test_text_only_judge_respects_recent_user_count() {
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": true,
"reason": "task complete"
})));
let config = DefaultGuardConfig {
recent_user_count: 2,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let mut ctx = make_ctx_with_text("继续", "done", true, true);
ctx.all_user_inputs = vec![
"msg1".to_string(),
"msg2".to_string(),
"msg3".to_string(),
"msg4".to_string(),
"继续".to_string(),
];
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"judge says done → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_no_scene_flags_returns_complete() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 0, false, false, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"no scene flags → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_reasoning_only_strikes_zero() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("reasoning") || msg.contains("tool call"),
"nudge should mention the issue: {}",
msg
);
}
other => panic!("strikes=0 → Continue, got: {:?}", other),
}
}
#[tokio::test]
async fn test_empty_response_strikes_zero() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 0, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("empty") || msg.contains("response"),
"nudge should mention empty response: {}",
msg
);
}
other => panic!("strikes=0 → Continue, got: {:?}", other),
}
}
#[tokio::test]
async fn test_reasoning_only_just_below_threshold() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(2, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Continue { nudge: Some(_) }),
"strikes=2, threshold=3 → Continue, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_empty_response_just_below_threshold() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 2, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Continue { nudge: Some(_) }),
"strikes=2, threshold=3 → Continue, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_reasoning_only_nudge_content() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(1, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert_eq!(msg, &DefaultGuardConfig::default().reasoning_only_nudge);
}
other => panic!("expected Continue, got: {:?}", other),
}
}
#[tokio::test]
async fn test_empty_response_nudge_content() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 1, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert_eq!(msg, &DefaultGuardConfig::default().empty_response_nudge);
}
other => panic!("expected Continue, got: {:?}", other),
}
}
#[tokio::test]
async fn test_short_response_nudge_content() {
let long_input = "a".repeat(200);
let short_output = "ok";
let config = DefaultGuardConfig {
use_llm_judge: false, ..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx_with_text(&long_input, short_output, false, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert_eq!(msg, &DefaultGuardConfig::default().short_response_nudge);
}
other => panic!("expected Continue for short response, got: {:?}", other),
}
}
#[tokio::test]
async fn test_fail_error_message_reasoning_only() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(3, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Fail { error } => {
assert!(
error.contains("reasoning"),
"fail error should mention reasoning: {}",
error
);
}
other => panic!("expected Fail, got: {:?}", other),
}
}
#[tokio::test]
async fn test_fail_error_message_empty_response() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(0, 3, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Fail { error } => {
assert!(
error.contains("empty"),
"fail error should mention empty: {}",
error
);
}
other => panic!("expected Fail, got: {:?}", other),
}
}
#[test]
fn test_default_guard_config_values() {
let config = DefaultGuardConfig::default();
assert_eq!(config.reasoning_only_max_strikes, 3);
assert_eq!(config.empty_response_max_strikes, 3);
assert!(config.use_llm_judge);
assert_eq!(config.judge_timeout_secs, 10);
assert_eq!(config.judge_skip_threshold, 256);
assert!(!config.judge_fail_open);
assert!(config.detect_short_response);
assert_eq!(config.short_response_min_input, 128);
assert_eq!(config.short_response_max_output, 64);
assert_eq!(config.recent_user_count, 5);
}
#[tokio::test]
async fn test_text_only_with_tools_judge_disabled_returns_complete() {
let config = DefaultGuardConfig {
use_llm_judge: false,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(0, 0, true, false, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"tools + judge disabled → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_short_response_without_tools_nudge() {
let long_input = "a".repeat(200);
let short_output = "done";
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx_with_text(&long_input, short_output, false, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("incomplete"),
"should use short_response_nudge: {}",
msg
);
}
other => panic!(
"expected Continue for short response without tools, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_text_only_input_shorter_than_output_not_short() {
let short_input = "hi";
let long_output = "a".repeat(200);
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx_with_text(short_input, &long_output, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"short input + long output + no tools → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_short_response_exact_min_input() {
let input = "a".repeat(128);
let output = "x"; let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx_with_text(&input, output, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"exact min_input (128 > 128 is false) → not short → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_short_response_just_above_min_input() {
let input = "a".repeat(129);
let output = "x";
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx_with_text(&input, output, false, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
assert!(nudge.is_some(), "just above min_input → short → nudge");
}
other => panic!(
"expected Continue for just above boundary, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_text_only_not_short_response_just_below_min_input() {
let input = "a".repeat(127);
let output = "x";
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx_with_text(&input, output, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"below min_input → not short → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_text_only_short_response_output_at_max() {
let input = "a".repeat(200);
let output = "a".repeat(64); let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx_with_text(&input, &output, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"output at max_output → not short → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_custom_nudge_messages() {
let config = DefaultGuardConfig {
reasoning_only_nudge: "custom reasoning nudge".to_string(),
empty_response_nudge: "custom empty nudge".to_string(),
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(1, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
assert_eq!(nudge.as_deref(), Some("custom reasoning nudge"));
}
other => panic!("expected Continue, got: {:?}", other),
}
let ctx = make_ctx(0, 1, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
assert_eq!(nudge.as_deref(), Some("custom empty nudge"));
}
other => panic!("expected Continue, got: {:?}", other),
}
}
#[tokio::test]
async fn test_judge_malformed_json_fail_closed() {
struct MalformedJsonClient;
#[async_trait::async_trait]
impl LlmProvider for MalformedJsonClient {
async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
let chunks = vec![
Ok(StreamChunk::Text("not json at all".to_string())),
Ok(StreamChunk::Stop {
finish_reason: Some("stop".to_string()),
}),
];
Ok(ChatStream::new(Box::pin(futures_util::stream::iter(
chunks,
))))
}
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
Ok(ChatResponse {
content: "not json at all".to_string(),
tool_calls: vec![],
usage: agent_base::UsageInfo::default(),
finish_reason: LlmFinishReason::Stop,
raw: None,
reasoning_content: None,
thinking_signature: None,
})
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
fn info(&self) -> agent_base::llm_trait::ProviderInfo {
agent_base::llm_trait::ProviderInfo {
name: "malformed".to_string(),
model: "malformed-model".to_string(),
version: None,
}
}
}
let config = DefaultGuardConfig {
judge_fail_open: false,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, Arc::new(MalformedJsonClient));
let ctx = make_ctx_with_text("query", "short", true, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("Cannot verify"),
"malformed JSON + fail_closed → Continue with verify: {}",
msg
);
}
other => panic!(
"expected Continue for malformed JSON + fail_closed, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_judge_malformed_json_fail_open() {
struct MalformedJsonClient;
#[async_trait::async_trait]
impl LlmProvider for MalformedJsonClient {
async fn stream(&self, _request: ChatRequest) -> Result<ChatStream, LlmError> {
let chunks = vec![
Ok(StreamChunk::Text("not json".to_string())),
Ok(StreamChunk::Stop {
finish_reason: Some("stop".to_string()),
}),
];
Ok(ChatStream::new(Box::pin(futures_util::stream::iter(
chunks,
))))
}
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, LlmError> {
Ok(ChatResponse {
content: "not json".to_string(),
tool_calls: vec![],
usage: agent_base::UsageInfo::default(),
finish_reason: LlmFinishReason::Stop,
raw: None,
reasoning_content: None,
thinking_signature: None,
})
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
fn info(&self) -> agent_base::llm_trait::ProviderInfo {
agent_base::llm_trait::ProviderInfo {
name: "malformed".to_string(),
model: "malformed-model".to_string(),
version: None,
}
}
}
let config = DefaultGuardConfig {
judge_fail_open: true,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, Arc::new(MalformedJsonClient));
let ctx = make_ctx_with_text("query", "short", true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"malformed JSON + fail_open → Complete (trust model), got: {:?}",
decision
);
}
#[tokio::test]
async fn test_judge_partial_json_missing_done() {
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"reason": "some reason"
})));
let config = DefaultGuardConfig {
judge_fail_open: false,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::with_llm_client(config, judge_client);
let ctx = make_ctx_with_text("query", "short", true, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("Cannot verify"),
"missing field + fail_closed → Continue: {}",
msg
);
}
other => panic!(
"expected Continue for partial JSON + fail_closed, got: {:?}",
other
),
}
}
mod judge_function_tests {
use super::super::judge::call_completion_judge;
use super::*;
fn to_dyn<T: LlmProvider + 'static>(client: Arc<T>) -> Arc<dyn LlmProvider> {
client
}
#[tokio::test]
async fn test_judge_no_client_fail_open() {
let result = call_completion_judge(
None,
"input",
"response",
&["input".to_string()],
true, 10,
5,
)
.await;
assert!(result.is_ok());
let judge = result.unwrap();
assert!(judge.done, "no client + fail_open → done=true");
}
#[tokio::test]
async fn test_judge_no_client_fail_closed() {
let result = call_completion_judge(
None,
"input",
"response",
&["input".to_string()],
false, 10,
5,
)
.await;
assert!(result.is_err(), "no client + fail_closed → error");
}
#[tokio::test]
async fn test_judge_with_valid_response() {
let client = to_dyn(Arc::new(MockJudgeClient::new(serde_json::json!({
"done": true,
"reason": "all tasks complete"
}))));
let result = call_completion_judge(
Some(&client),
"do something",
"done",
&["do something".to_string()],
false,
10,
5,
)
.await;
assert!(result.is_ok());
let judge = result.unwrap();
assert!(judge.done);
assert_eq!(judge.reason, "all tasks complete");
}
#[tokio::test]
async fn test_judge_empty_user_inputs() {
let client = to_dyn(Arc::new(MockJudgeClient::new(serde_json::json!({
"done": false,
"reason": "not enough context"
}))));
let result = call_completion_judge(
Some(&client),
"single input",
"response",
&[], false,
10,
5,
)
.await;
assert!(result.is_ok());
let judge = result.unwrap();
assert!(!judge.done);
}
#[tokio::test]
async fn test_judge_single_user_input() {
let client = to_dyn(Arc::new(MockJudgeClient::new(serde_json::json!({
"done": true,
"reason": "complete"
}))));
let result = call_completion_judge(
Some(&client),
"only message",
"response",
&["only message".to_string()],
false,
10,
5,
)
.await;
assert!(result.is_ok());
assert!(result.unwrap().done);
}
#[tokio::test]
async fn test_judge_timeout_fail_open() {
let client = to_dyn(Arc::new(MockTimeoutClient));
let result = call_completion_judge(
Some(&client),
"input",
"response",
&["input".to_string()],
true, 1, 5,
)
.await;
assert!(result.is_ok(), "timeout + fail_open → Ok");
assert!(result.unwrap().done);
}
#[tokio::test]
async fn test_judge_timeout_fail_closed() {
let client = to_dyn(Arc::new(MockTimeoutClient));
let result = call_completion_judge(
Some(&client),
"input",
"response",
&["input".to_string()],
false, 1,
5,
)
.await;
assert!(result.is_err(), "timeout + fail_closed → Err");
}
#[tokio::test]
async fn test_judge_error_fail_open() {
let client = to_dyn(Arc::new(MockErrorClient));
let result = call_completion_judge(
Some(&client),
"input",
"response",
&["input".to_string()],
true,
10,
5,
)
.await;
assert!(result.is_ok(), "error + fail_open → Ok (trust model)");
let judge = result.unwrap();
assert!(judge.done, "fail_open → done=true");
assert!(
judge.reason.contains("trusting model"),
"reason should mention trusting model: {}",
judge.reason
);
}
#[tokio::test]
async fn test_judge_error_fail_closed() {
let client = to_dyn(Arc::new(MockErrorClient));
let result = call_completion_judge(
Some(&client),
"input",
"response",
&["input".to_string()],
false,
10,
5,
)
.await;
assert!(result.is_err(), "error + fail_closed → Err");
}
#[tokio::test]
async fn test_judge_recent_user_count_truncation() {
let client = to_dyn(Arc::new(MockJudgeClient::new(serde_json::json!({
"done": true,
"reason": "complete"
}))));
let result = call_completion_judge(
Some(&client),
"msg5",
"response",
&[
"msg1".to_string(),
"msg2".to_string(),
"msg3".to_string(),
"msg4".to_string(),
"msg5".to_string(),
],
false,
10,
2, )
.await;
assert!(result.is_ok());
}
}
#[test]
fn test_guard_decision_continue_with_nudge() {
let d = GuardDecision::Continue {
nudge: Some("test nudge".to_string()),
};
match &d {
GuardDecision::Continue { nudge } => {
assert_eq!(nudge.as_deref(), Some("test nudge"));
}
_ => panic!("expected Continue"),
}
}
#[test]
fn test_guard_decision_continue_without_nudge() {
let d = GuardDecision::Continue { nudge: None };
match &d {
GuardDecision::Continue { nudge } => {
assert!(nudge.is_none());
}
_ => panic!("expected Continue"),
}
}
#[test]
fn test_guard_decision_complete() {
let d = GuardDecision::Complete;
assert!(matches!(d, GuardDecision::Complete));
}
#[test]
fn test_guard_decision_fail() {
let d = GuardDecision::Fail {
error: "something went wrong".to_string(),
};
match &d {
GuardDecision::Fail { error } => {
assert_eq!(error, "something went wrong");
}
_ => panic!("expected Fail"),
}
}
#[tokio::test]
async fn test_noop_guard_normal_complete() {
let guard = NoopGuard;
let ctx = make_ctx(0, 0, false, false, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"NoopGuard normal → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_noop_guard_text_only_complete() {
let guard = NoopGuard;
let ctx = make_ctx(0, 0, false, false, false, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"NoopGuard text_only → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_noop_guard_reasoning_only_fails() {
let guard = NoopGuard;
let ctx = make_ctx(0, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Fail { .. }),
"NoopGuard reasoning_only → Fail (safety), got: {:?}",
decision
);
}
#[tokio::test]
async fn test_noop_guard_empty_response_fails() {
let guard = NoopGuard;
let ctx = make_ctx(0, 0, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Fail { .. }),
"NoopGuard empty_response → Fail (safety), got: {:?}",
decision
);
}
#[tokio::test]
async fn test_noop_guard_all_flags_reasoning_priority() {
let guard = NoopGuard;
let ctx = make_ctx(3, 3, true, true, true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Fail { .. }),
"NoopGuard all flags → Fail (reasoning/empty), got: {:?}",
decision
);
}
#[tokio::test]
async fn test_reasoning_only_takes_priority_over_empty() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(1, 1, false, true, true, false);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert_eq!(msg, &DefaultGuardConfig::default().reasoning_only_nudge);
}
other => panic!(
"reasoning_only takes priority, expected Continue, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_reasoning_only_takes_priority_over_text_only() {
let guard = DefaultGuard::new(DefaultGuardConfig::default());
let ctx = make_ctx(1, 0, false, true, false, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert_eq!(msg, &DefaultGuardConfig::default().reasoning_only_nudge);
}
other => panic!(
"reasoning_only takes priority, expected Continue, got: {:?}",
other
),
}
}
#[tokio::test]
async fn test_text_only_non_short_with_tools_judge_not_done() {
let medium_output = "a".repeat(100);
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": false,
"reason": "missing details"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text("query", &medium_output, true, true);
let decision = guard.on_turn(&ctx).await;
match &decision {
GuardDecision::Continue { nudge } => {
let msg = nudge.as_ref().unwrap();
assert!(
msg.contains("incomplete") && msg.contains("missing details"),
"non-short + judge not done → Continue with reason: {}",
msg
);
}
other => panic!("expected Continue, got: {:?}", other),
}
}
#[tokio::test]
async fn test_text_only_non_short_with_tools_judge_done() {
let medium_output = "a".repeat(100);
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": true,
"reason": "task complete"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text("query", &medium_output, true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"non-short + judge done → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_short_response_skip_judge_when_input_too_large() {
let long_input = "a".repeat(10_001);
let short_output = "42";
let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
"done": false,
"reason": "would be skipped"
})));
let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
let ctx = make_ctx_with_text(&long_input, short_output, true, true);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"short response + input > 10k → skip judge → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_disable_thinking_strategy_below_threshold() {
let config = DefaultGuardConfig {
reasoning_only_action: ReasoningOnlyAction::DisableThinking,
reasoning_only_max_strikes: 3,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(2, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Continue { .. }),
"below threshold → Continue, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_disable_thinking_strategy_at_threshold() {
let config = DefaultGuardConfig {
reasoning_only_action: ReasoningOnlyAction::DisableThinking,
reasoning_only_max_strikes: 3,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(3, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::DisableThinking { .. }),
"at threshold → DisableThinking, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_disable_thinking_strategy_already_disabled() {
let config = DefaultGuardConfig {
reasoning_only_action: ReasoningOnlyAction::DisableThinking,
reasoning_only_max_strikes: 3,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let mut ctx = make_ctx(3, 0, false, true, false, false);
ctx.thinking_disabled = true;
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Fail { .. }),
"thinking disabled + reasoning-only → Fail, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_on_tool_call_restores_thinking() {
let config = DefaultGuardConfig {
reasoning_only_action: ReasoningOnlyAction::DisableThinking,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let mut ctx = make_ctx(0, 0, true, false, false, false);
ctx.thinking_disabled = true;
ctx.original_thinking_enabled = true;
let decision = guard.on_tool_call(&ctx).await;
assert!(
matches!(decision, GuardDecision::RestoreThinking),
"thinking disabled + tool call → RestoreThinking, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_on_tool_call_no_restore_when_original_disabled() {
let config = DefaultGuardConfig {
reasoning_only_action: ReasoningOnlyAction::DisableThinking,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let mut ctx = make_ctx(0, 0, true, false, false, false);
ctx.thinking_disabled = true;
ctx.original_thinking_enabled = false;
let decision = guard.on_tool_call(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"original disabled + tool call → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_on_tool_call_no_restore_when_thinking_enabled() {
let config = DefaultGuardConfig {
reasoning_only_action: ReasoningOnlyAction::DisableThinking,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let mut ctx = make_ctx(0, 0, true, false, false, false);
ctx.thinking_disabled = false;
ctx.original_thinking_enabled = true;
let decision = guard.on_tool_call(&ctx).await;
assert!(
matches!(decision, GuardDecision::Complete),
"thinking enabled + tool call → Complete, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_fail_strategy_at_threshold() {
let config = DefaultGuardConfig {
reasoning_only_action: ReasoningOnlyAction::Fail,
reasoning_only_max_strikes: 3,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(3, 0, false, true, false, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Fail { .. }),
"at threshold → Fail, got: {:?}",
decision
);
}
#[tokio::test]
async fn test_empty_response_with_disable_thinking_strategy() {
let config = DefaultGuardConfig {
reasoning_only_action: ReasoningOnlyAction::DisableThinking,
empty_response_max_strikes: 3,
..DefaultGuardConfig::default()
};
let guard = DefaultGuard::new(config);
let ctx = make_ctx(0, 3, false, false, true, false);
let decision = guard.on_turn(&ctx).await;
assert!(
matches!(decision, GuardDecision::Fail { .. }),
"empty response at threshold → Fail, got: {:?}",
decision
);
}