use super::super::io::KillConfig;
use super::super::runtime::base::{
evaluate_tool_suppression, FileActivityConfig, MonitorLoopState, ToolSuppressionAction,
};
use super::super::runtime::MonitorConfig;
use super::super::*;
use crate::executor::{AgentChild, MockAgentChild, MockProcessExecutor};
use crate::workspace::MemoryWorkspace;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
fn fast_kill_config() -> KillConfig {
KillConfig::new(
Duration::from_millis(10),
Duration::from_millis(1),
Duration::from_millis(5),
Duration::from_millis(50),
Duration::from_millis(10),
)
}
fn wait_until_idle_timeout_exceeded(timestamp: &SharedActivityTimestamp, timeout: Duration) {
timestamp.store(0, Ordering::Release);
while !is_idle_timeout_exceeded(timestamp, timeout) {
std::thread::yield_now();
}
}
#[test]
fn tool_suppression_cap_allows_timeout_after_max_ticks() {
let timestamp = new_activity_timestamp();
wait_until_idle_timeout_exceeded(×tamp, Duration::ZERO);
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = Arc::clone(&should_stop);
let (mock_child, _controller) = MockAgentChild::new_running(0);
let child = Arc::new(Mutex::new(Box::new(mock_child) as Box<dyn AgentChild>));
let executor: Arc<dyn crate::executor::ProcessExecutor> = Arc::new(MockProcessExecutor::new());
let tool_activity_check: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(|| true);
let config = MonitorConfig {
timeout: Duration::ZERO,
check_interval: Duration::ZERO,
kill_config: fast_kill_config(),
required_idle_confirmations: 1,
check_child_processes: false,
completion_check: None,
partial_completion_check: None,
tool_activity_check: Some(tool_activity_check),
max_tool_suppression_ticks: 2,
};
let result = monitor_idle_timeout_with_interval_and_kill_config(
×tamp,
None,
&child,
&should_stop_clone,
&executor,
config,
);
assert!(
matches!(result, MonitorResult::TimedOut { .. }),
"tool suppressor cap must allow idle timeout to fire after max_tool_suppression_ticks; \
got {result:?}"
);
}
#[test]
fn reset_idle_resets_consecutive_tool_suppression_ticks() {
let mut s = MonitorLoopState::new();
assert_eq!(
s.consecutive_tool_suppression_ticks, 0,
"new MonitorLoopState must start with consecutive_tool_suppression_ticks = 0"
);
s.consecutive_tool_suppression_ticks = 5;
s.reset_idle();
assert_eq!(
s.consecutive_tool_suppression_ticks, 0,
"reset_idle must reset consecutive_tool_suppression_ticks to 0"
);
}
#[test]
fn tool_suppressor_disabled_when_check_returns_false_allows_clean_stop() {
let timestamp = new_activity_timestamp();
wait_until_idle_timeout_exceeded(×tamp, Duration::ZERO);
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = Arc::clone(&should_stop);
let should_stop_for_test = Arc::clone(&should_stop);
let (mock_child, _controller) = MockAgentChild::new_running(0);
let child = Arc::new(Mutex::new(Box::new(mock_child) as Box<dyn AgentChild>));
let executor: Arc<dyn crate::executor::ProcessExecutor> = Arc::new(MockProcessExecutor::new());
let check_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let check_count_clone = Arc::clone(&check_count);
let tool_activity_check: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(move || {
let n = check_count_clone.fetch_add(1, Ordering::SeqCst);
n < 3
});
let config = MonitorConfig {
timeout: Duration::ZERO,
check_interval: Duration::from_millis(50),
kill_config: fast_kill_config(),
required_idle_confirmations: 2,
check_child_processes: false,
completion_check: None,
partial_completion_check: None,
tool_activity_check: Some(tool_activity_check),
max_tool_suppression_ticks: 10, };
let handle = std::thread::spawn(move || {
monitor_idle_timeout_with_interval_and_kill_config(
×tamp,
None,
&child,
&should_stop_clone,
&executor,
config,
)
});
while check_count.load(Ordering::SeqCst) < 4 {
std::thread::yield_now();
}
should_stop_for_test.store(true, Ordering::Release);
let result = handle.join().expect("monitor thread panicked");
assert_eq!(
result,
MonitorResult::ProcessCompleted,
"monitor must not kill when should_stop is set after tool check returns false"
);
}
#[test]
fn evaluate_tool_suppression_inactive_when_check_false() {
assert!(matches!(
evaluate_tool_suppression(false, 0, 10),
ToolSuppressionAction::Inactive
));
assert!(matches!(
evaluate_tool_suppression(false, u32::MAX, 10),
ToolSuppressionAction::Inactive
));
assert!(matches!(
evaluate_tool_suppression(false, 5, 0),
ToolSuppressionAction::Inactive
));
}
#[test]
fn evaluate_tool_suppression_cap_zero_immediately_exceeds() {
match evaluate_tool_suppression(true, 0, 0) {
ToolSuppressionAction::CapExceeded { ticks } => assert_eq!(ticks, 1),
other => panic!(
"expected CapExceeded, got {other:?}",
other = std::mem::discriminant(&other)
),
}
}
#[test]
fn evaluate_tool_suppression_exact_cap_boundary() {
let max_ticks = 5;
match evaluate_tool_suppression(true, max_ticks - 1, max_ticks) {
ToolSuppressionAction::Suppress { ticks } => assert_eq!(ticks, max_ticks),
other => panic!(
"expected Suppress at max_ticks, got {other:?}",
other = std::mem::discriminant(&other)
),
}
match evaluate_tool_suppression(true, max_ticks, max_ticks) {
ToolSuppressionAction::CapExceeded { ticks } => assert_eq!(ticks, max_ticks + 1),
other => panic!(
"expected CapExceeded above max_ticks, got {other:?}",
other = std::mem::discriminant(&other)
),
}
}
#[test]
fn evaluate_tool_suppression_saturates_at_u32_max() {
match evaluate_tool_suppression(true, u32::MAX, u32::MAX) {
ToolSuppressionAction::Suppress { ticks } => assert_eq!(ticks, u32::MAX),
other => panic!(
"expected Suppress with saturated ticks, got {other:?}",
other = std::mem::discriminant(&other)
),
}
match evaluate_tool_suppression(true, u32::MAX, u32::MAX - 1) {
ToolSuppressionAction::CapExceeded { ticks } => assert_eq!(ticks, u32::MAX),
other => panic!(
"expected CapExceeded when max_ticks < u32::MAX, got {other:?}",
other = std::mem::discriminant(&other)
),
}
}
#[test]
fn reset_idle_preserving_tool_suppression_preserves_counter() {
let mut s = MonitorLoopState::new();
s.consecutive_idle_count = 3;
s.consecutive_tool_suppression_ticks = 7;
s.child_startup_grace_available = false;
s.reset_idle_preserving_tool_suppression();
assert_eq!(s.consecutive_idle_count, 0, "idle count must be reset");
assert!(
s.last_child_observation.is_none(),
"child observation must be reset"
);
assert!(
s.child_startup_grace_available,
"startup grace must be restored"
);
assert_eq!(
s.consecutive_tool_suppression_ticks, 7,
"tool suppression ticks must be preserved"
);
}
#[test]
fn reset_idle_still_zeros_tool_suppression_ticks() {
let mut s = MonitorLoopState::new();
s.consecutive_tool_suppression_ticks = 10;
s.reset_idle();
assert_eq!(
s.consecutive_tool_suppression_ticks, 0,
"reset_idle must zero the tool suppression counter (genuine progress resets the cap)"
);
}
#[test]
fn tool_suppression_suppress_preserves_ticks_after_idle_reset() {
let mut s = MonitorLoopState::new();
s.consecutive_idle_count = 2;
s.consecutive_tool_suppression_ticks = 3;
s.reset_idle_preserving_tool_suppression();
s.consecutive_tool_suppression_ticks = 4;
assert_eq!(s.consecutive_idle_count, 0);
assert_eq!(s.consecutive_tool_suppression_ticks, 4);
}
#[test]
fn tool_suppression_counter_survives_interleaved_resets() {
let mut s = MonitorLoopState::new();
s.consecutive_tool_suppression_ticks = 5;
s.consecutive_idle_count = 3;
s.reset_idle();
assert_eq!(
s.consecutive_tool_suppression_ticks, 0,
"genuine progress must zero the tool suppression counter"
);
assert_eq!(s.consecutive_idle_count, 0);
s.consecutive_tool_suppression_ticks = 1;
s.consecutive_idle_count = 1;
s.reset_idle_preserving_tool_suppression();
s.consecutive_tool_suppression_ticks = 2; assert_eq!(
s.consecutive_tool_suppression_ticks, 2,
"tool suppressor reset must preserve the counter"
);
assert_eq!(
s.consecutive_idle_count, 0,
"idle count must be zeroed by preserving reset"
);
assert!(
s.child_startup_grace_available,
"startup grace must be restored by preserving reset"
);
s.reset_idle();
assert_eq!(
s.consecutive_tool_suppression_ticks, 0,
"second genuine-progress reset must zero the counter again"
);
}
#[test]
fn tool_suppression_cap_exceeded_then_tool_resumes_resets_counter() {
let max_ticks: u32 = 3;
let mut s = MonitorLoopState::new();
for tick in 0..=max_ticks {
let action =
evaluate_tool_suppression(true, s.consecutive_tool_suppression_ticks, max_ticks);
match action {
ToolSuppressionAction::Suppress { ticks } => {
s.reset_idle_preserving_tool_suppression();
s.consecutive_tool_suppression_ticks = ticks;
assert!(tick < max_ticks, "should suppress before cap; tick={tick}");
}
ToolSuppressionAction::CapExceeded { ticks: _ } => {
assert_eq!(tick, max_ticks, "cap should exceed at tick={max_ticks}");
}
ToolSuppressionAction::Inactive => {
panic!("check_result was true; Inactive is impossible");
}
}
}
let action = evaluate_tool_suppression(false, s.consecutive_tool_suppression_ticks, max_ticks);
assert!(
matches!(action, ToolSuppressionAction::Inactive),
"tool check false must produce Inactive"
);
s.consecutive_tool_suppression_ticks = 0;
assert_eq!(
s.consecutive_tool_suppression_ticks, 0,
"counter must reset to 0 after tool goes inactive"
);
let action = evaluate_tool_suppression(true, s.consecutive_tool_suppression_ticks, max_ticks);
match action {
ToolSuppressionAction::Suppress { ticks } => {
assert_eq!(ticks, 1, "first tick of new tool execution should be 1");
s.reset_idle_preserving_tool_suppression();
s.consecutive_tool_suppression_ticks = ticks;
}
other => panic!(
"expected Suppress for fresh tool execution, got {other:?}",
other = std::mem::discriminant(&other)
),
}
for _ in 1..max_ticks {
let action =
evaluate_tool_suppression(true, s.consecutive_tool_suppression_ticks, max_ticks);
assert!(
matches!(action, ToolSuppressionAction::Suppress { .. }),
"should still suppress within the fresh cap window"
);
if let ToolSuppressionAction::Suppress { ticks } = action {
s.reset_idle_preserving_tool_suppression();
s.consecutive_tool_suppression_ticks = ticks;
}
}
let action = evaluate_tool_suppression(true, s.consecutive_tool_suppression_ticks, max_ticks);
assert!(
matches!(action, ToolSuppressionAction::CapExceeded { .. }),
"fresh cap window should also expire after max_ticks"
);
}
#[test]
fn tool_suppression_genuine_progress_resets_cap_during_active_tool() {
let max_ticks: u32 = 5;
let mut s = MonitorLoopState::new();
for _ in 0..(max_ticks - 1) {
let action =
evaluate_tool_suppression(true, s.consecutive_tool_suppression_ticks, max_ticks);
if let ToolSuppressionAction::Suppress { ticks } = action {
s.reset_idle_preserving_tool_suppression();
s.consecutive_tool_suppression_ticks = ticks;
} else {
panic!("expected Suppress while approaching cap");
}
}
assert_eq!(
s.consecutive_tool_suppression_ticks,
max_ticks - 1,
"should be one tick away from the cap"
);
s.reset_idle();
assert_eq!(
s.consecutive_tool_suppression_ticks, 0,
"genuine progress must zero the tool suppression counter"
);
for expected_tick in 1..=max_ticks {
let action =
evaluate_tool_suppression(true, s.consecutive_tool_suppression_ticks, max_ticks);
match action {
ToolSuppressionAction::Suppress { ticks } => {
assert_eq!(ticks, expected_tick);
s.reset_idle_preserving_tool_suppression();
s.consecutive_tool_suppression_ticks = ticks;
}
other => panic!(
"expected Suppress at tick {expected_tick} after progress reset, got {other:?}",
other = std::mem::discriminant(&other)
),
}
}
let action = evaluate_tool_suppression(true, s.consecutive_tool_suppression_ticks, max_ticks);
assert!(
matches!(action, ToolSuppressionAction::CapExceeded { .. }),
"cap must be exceeded after full window following progress reset"
);
}
use super::super::runtime::core::apply_tool_suppression_action;
#[test]
fn cap_exceeded_warns_once_then_suppresses_duplicate_warnings() {
let mut s = MonitorLoopState::new();
let max_ticks: u32 = 3;
assert!(
!s.tool_suppression_cap_warned,
"new MonitorLoopState must start with cap_warned = false"
);
let result = apply_tool_suppression_action(
ToolSuppressionAction::CapExceeded { ticks: 4 },
max_ticks,
&mut s,
);
assert!(!result, "CapExceeded must return false (no suppression)");
assert!(
s.tool_suppression_cap_warned,
"first CapExceeded must set cap_warned = true"
);
let result = apply_tool_suppression_action(
ToolSuppressionAction::CapExceeded { ticks: 5 },
max_ticks,
&mut s,
);
assert!(!result, "CapExceeded must still return false");
assert!(
s.tool_suppression_cap_warned,
"cap_warned must remain true across subsequent CapExceeded ticks"
);
let result = apply_tool_suppression_action(
ToolSuppressionAction::CapExceeded { ticks: 6 },
max_ticks,
&mut s,
);
assert!(!result, "CapExceeded must still return false");
assert!(s.tool_suppression_cap_warned, "cap_warned must remain true");
}
#[test]
fn reset_idle_resets_cap_warned_allowing_fresh_warning() {
let mut s = MonitorLoopState::new();
let max_ticks: u32 = 2;
apply_tool_suppression_action(
ToolSuppressionAction::CapExceeded { ticks: 3 },
max_ticks,
&mut s,
);
assert!(s.tool_suppression_cap_warned);
s.reset_idle();
assert!(
!s.tool_suppression_cap_warned,
"reset_idle must clear cap_warned for fresh warning on next episode"
);
assert_eq!(s.consecutive_tool_suppression_ticks, 0);
apply_tool_suppression_action(
ToolSuppressionAction::CapExceeded { ticks: 3 },
max_ticks,
&mut s,
);
assert!(
s.tool_suppression_cap_warned,
"new episode must set cap_warned again after reset_idle"
);
}
#[test]
fn inactive_resets_cap_warned_for_fresh_episode() {
let mut s = MonitorLoopState::new();
let max_ticks: u32 = 2;
apply_tool_suppression_action(
ToolSuppressionAction::CapExceeded { ticks: 3 },
max_ticks,
&mut s,
);
assert!(s.tool_suppression_cap_warned);
apply_tool_suppression_action(ToolSuppressionAction::Inactive, max_ticks, &mut s);
assert!(
!s.tool_suppression_cap_warned,
"Inactive must reset cap_warned"
);
assert_eq!(
s.consecutive_tool_suppression_ticks, 0,
"Inactive must zero tick counter"
);
apply_tool_suppression_action(
ToolSuppressionAction::CapExceeded { ticks: 3 },
max_ticks,
&mut s,
);
assert!(
s.tool_suppression_cap_warned,
"new CapExceeded after Inactive must set flag again"
);
}
#[test]
fn preserving_reset_keeps_cap_warned_flag() {
let mut s = MonitorLoopState::new();
let max_ticks: u32 = 2;
apply_tool_suppression_action(
ToolSuppressionAction::CapExceeded { ticks: 3 },
max_ticks,
&mut s,
);
assert!(s.tool_suppression_cap_warned);
s.consecutive_tool_suppression_ticks = 8;
s.reset_idle_preserving_tool_suppression();
assert!(
s.tool_suppression_cap_warned,
"preserving reset must not reset the cap_warned flag"
);
assert_eq!(
s.consecutive_tool_suppression_ticks, 8,
"preserving reset must not reset the tick counter"
);
}
#[test]
fn suppress_action_does_not_affect_cap_warned() {
let mut s = MonitorLoopState::new();
let max_ticks: u32 = 10;
assert!(!s.tool_suppression_cap_warned);
let result = apply_tool_suppression_action(
ToolSuppressionAction::Suppress { ticks: 1 },
max_ticks,
&mut s,
);
assert!(result, "Suppress must return true");
assert!(
!s.tool_suppression_cap_warned,
"Suppress must not set cap_warned"
);
assert_eq!(s.consecutive_tool_suppression_ticks, 1);
}
#[test]
fn tool_suppressor_cap_exceeded_but_partial_completion_suppresses() {
let timestamp = new_activity_timestamp();
wait_until_idle_timeout_exceeded(×tamp, Duration::ZERO);
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = Arc::clone(&should_stop);
let should_stop_for_test = Arc::clone(&should_stop);
let (mock_child, _controller) = MockAgentChild::new_running(0);
let child = Arc::new(Mutex::new(Box::new(mock_child) as Box<dyn AgentChild>));
let executor: Arc<dyn crate::executor::ProcessExecutor> = Arc::new(MockProcessExecutor::new());
let tool_check_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let tool_check_count_clone = Arc::clone(&tool_check_count);
let tool_activity_check: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(move || {
tool_check_count_clone.fetch_add(1, Ordering::SeqCst);
true
});
let tool_check_count_for_partial = Arc::clone(&tool_check_count);
let partial_check_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let partial_check_count_clone = Arc::clone(&partial_check_count);
let partial_completion_check: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(move || {
partial_check_count_clone.fetch_add(1, Ordering::SeqCst);
tool_check_count_for_partial.load(Ordering::SeqCst) >= 3
});
let config = MonitorConfig {
timeout: Duration::ZERO,
check_interval: Duration::from_millis(50),
kill_config: fast_kill_config(),
required_idle_confirmations: 2,
check_child_processes: false,
completion_check: None,
partial_completion_check: Some(partial_completion_check),
tool_activity_check: Some(tool_activity_check),
max_tool_suppression_ticks: 2,
};
let handle = std::thread::spawn(move || {
monitor_idle_timeout_with_interval_and_kill_config(
×tamp,
None,
&child,
&should_stop_clone,
&executor,
config,
)
});
while partial_check_count.load(Ordering::SeqCst) < 5 {
std::thread::yield_now();
}
should_stop_for_test.store(true, Ordering::Release);
let result = handle.join().expect("monitor thread panicked");
assert_eq!(
result,
MonitorResult::ProcessCompleted,
"partial completion suppressor must prevent timeout even when tool suppressor cap is exceeded"
);
assert!(
tool_check_count.load(Ordering::SeqCst) >= 3,
"tool check must have been called enough times to exceed cap"
);
assert!(
partial_check_count.load(Ordering::SeqCst) >= 1,
"partial completion check must have been called at least once after cap exceeded"
);
}
#[test]
fn tool_suppressor_cap_exceeded_but_file_activity_suppresses() {
let timestamp = new_activity_timestamp();
wait_until_idle_timeout_exceeded(×tamp, Duration::ZERO);
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = Arc::clone(&should_stop);
let should_stop_for_test = Arc::clone(&should_stop);
let (mock_child, _controller) = MockAgentChild::new_running(0);
let child = Arc::new(Mutex::new(Box::new(mock_child) as Box<dyn AgentChild>));
let executor: Arc<dyn crate::executor::ProcessExecutor> = Arc::new(MockProcessExecutor::new());
let tool_check_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let tool_check_count_clone = Arc::clone(&tool_check_count);
let tool_activity_check: Arc<dyn Fn() -> bool + Send + Sync> = Arc::new(move || {
tool_check_count_clone.fetch_add(1, Ordering::SeqCst);
true
});
let workspace: Arc<dyn crate::workspace::Workspace> =
Arc::new(MemoryWorkspace::new_test().with_file(".agent/PLAN.md", "# Progress"));
let file_activity_config = Some(FileActivityConfig {
tracker: new_file_activity_tracker(),
workspace,
});
let config = MonitorConfig {
timeout: Duration::ZERO,
check_interval: Duration::from_millis(50),
kill_config: fast_kill_config(),
required_idle_confirmations: 1,
check_child_processes: false,
completion_check: None,
partial_completion_check: None,
tool_activity_check: Some(tool_activity_check),
max_tool_suppression_ticks: 2,
};
let handle = std::thread::spawn(move || {
monitor_idle_timeout_with_interval_and_kill_config(
×tamp,
file_activity_config.as_ref(),
&child,
&should_stop_clone,
&executor,
config,
)
});
while tool_check_count.load(Ordering::SeqCst) < 5 {
std::thread::yield_now();
}
should_stop_for_test.store(true, Ordering::Release);
let result = handle.join().expect("monitor thread panicked");
assert_eq!(
result,
MonitorResult::ProcessCompleted,
"file activity suppressor must prevent timeout even when tool suppressor cap is exceeded"
);
assert!(
tool_check_count.load(Ordering::SeqCst) >= 3,
"tool check must have been called enough times to exceed cap"
);
}