use super::*;
#[tokio::test]
async fn blocked_tool_call_guard_emits_tool_and_system_messages() {
let mut backing = TestContextBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
let max_streak = max_consecutive_blocked_tool_calls_per_turn(&ctx);
let args = json!({"path":"src/main.rs"});
let mut outcome = None;
for idx in 0..=max_streak {
outcome = enforce_blocked_tool_call_guard(
&mut ctx,
&format!("blocked_{idx}"),
tool_names::READ_FILE,
&args,
);
}
assert!(matches!(
outcome,
Some(TurnHandlerOutcome::Break(TurnLoopResult::Blocked { .. }))
));
assert!(
ctx.working_history
.iter()
.any(|message| message.content.as_text().contains("blocked_streak"))
);
assert!(ctx.working_history.iter().any(|message| {
message.role == uni::MessageRole::System
&& message
.content
.as_text()
.contains("Consecutive blocked tool calls reached per-turn cap")
}));
}
#[tokio::test]
async fn blocked_tool_call_guard_short_circuits_to_recovery_when_active() {
let mut backing = TestContextBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
let args = json!({"path":"src/main.rs"});
ctx.activate_recovery("loop detector");
let outcome =
enforce_blocked_tool_call_guard(&mut ctx, "blocked_recovery", tool_names::READ_FILE, &args);
assert!(matches!(outcome, Some(TurnHandlerOutcome::Continue)));
}
#[tokio::test]
async fn unified_validation_ignores_preseeded_legacy_loop_detector_state() {
let mut backing = TestContextBacking::new(2).await;
let valid_file = backing.sample_file.clone();
let valid_args = json!({"path": valid_file.to_string_lossy()});
cache_tool_permission(
&mut backing,
tool_names::READ_FILE,
&valid_args,
PermissionGrant::Permanent,
)
.await;
backing
.autonomous_executor
.set_loop_limit(tool_names::READ_FILE, 2);
let seeded_args = json!({"path": valid_file.to_string_lossy()});
assert!(
backing
.autonomous_executor
.record_tool_call(tool_names::READ_FILE, &seeded_args)
.is_none()
);
let _ = backing
.autonomous_executor
.record_tool_call(tool_names::READ_FILE, &seeded_args);
let warning = backing
.autonomous_executor
.record_tool_call(tool_names::READ_FILE, &seeded_args);
assert!(warning.is_some());
assert!(
backing
.autonomous_executor
.is_hard_limit_exceeded(tool_names::READ_FILE)
);
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let mut tp_ctx = backing.turn_processing_context();
let mut outcome_ctx = ToolOutcomeContext {
ctx: &mut tp_ctx,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
};
let outcome = handle_single_tool_call(
&mut outcome_ctx,
"legacy_detector_seeded",
tool_names::READ_FILE,
valid_args,
)
.await
.expect("unified validation should ignore legacy detector state");
assert!(outcome.is_none());
assert_eq!(outcome_ctx.ctx.harness_state.tool_calls, 1);
assert!(!outcome_ctx.ctx.working_history.iter().any(|message| {
message
.content
.as_text()
.contains("Loop detector stopped repeated")
}));
assert!(
backing
.autonomous_executor
.is_hard_limit_exceeded(tool_names::READ_FILE)
);
}
#[tokio::test]
async fn repeated_shell_guard_activates_recovery_without_breaking_turn() {
let mut backing = TestContextBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
let max_repeated_runs = ctx
.vt_cfg
.map(|cfg| cfg.tools.max_repeated_tool_calls)
.filter(|value| *value > 0)
.unwrap_or(vtcode_core::config::constants::defaults::DEFAULT_MAX_REPEATED_TOOL_CALLS);
let args = json!({"action":"run","command":"cargo check"});
let mut outcome = None;
for idx in 0..=max_repeated_runs {
outcome = enforce_repeated_shell_run_guard(
&mut ctx,
&format!("shell_{idx}"),
tool_names::UNIFIED_EXEC,
&args,
);
}
assert!(matches!(outcome, Some(ValidationResult::Blocked)));
assert!(ctx.is_recovery_active());
}
#[tokio::test]
async fn duplicate_task_tracker_create_is_blocked_not_breaking() {
let mut backing = TestContextBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
let args = json!({
"action": "create",
"title": "Task Checklist",
"items": ["step 1"]
});
let first = enforce_duplicate_task_tracker_create_guard(
&mut ctx,
"task_tracker_first",
tool_names::TASK_TRACKER,
&args,
);
assert!(first.is_none());
let second = enforce_duplicate_task_tracker_create_guard(
&mut ctx,
"task_tracker_second",
tool_names::TASK_TRACKER,
&args,
);
assert!(matches!(second, Some(ValidationResult::Blocked)));
}
#[tokio::test]
async fn enter_plan_mode_clears_task_tracker_create_signatures() {
let mut backing = TestContextBacking::new(4).await;
let enter_args = json!({});
cache_tool_permission(
&mut backing,
tool_names::ENTER_PLAN_MODE,
&enter_args,
PermissionGrant::Permanent,
)
.await;
let mut ctx = backing.turn_processing_context();
let create_args = json!({
"action": "create",
"title": "Task Checklist",
"items": ["step 1"]
});
let first = enforce_duplicate_task_tracker_create_guard(
&mut ctx,
"task_tracker_seed",
tool_names::TASK_TRACKER,
&create_args,
);
assert!(first.is_none());
let result = validate_tool_call(
&mut ctx,
"enter_plan_mode_call",
tool_names::ENTER_PLAN_MODE,
&enter_args,
)
.await
.expect("validate enter_plan_mode");
assert!(matches!(result, ValidationResult::Proceed(_)));
let second = enforce_duplicate_task_tracker_create_guard(
&mut ctx,
"task_tracker_after_plan",
tool_names::TASK_TRACKER,
&create_args,
);
assert!(second.is_none());
}
#[tokio::test]
async fn recovery_skip_step_pushes_structured_tool_message() {
let mut backing = TestContextBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
let outcome = recovery::apply_recovery_action(
&mut ctx,
"recovery_call",
crate::agent::runloop::unified::turn::recovery_flow::RecoveryAction::SkipStep,
)
.await
.expect("skip-step recovery should succeed");
assert!(matches!(outcome, Some(ValidationResult::Handled)));
assert!(
ctx.working_history
.iter()
.any(|message| { message.content.as_text().contains("\"skipped\":true") })
);
}
#[tokio::test]
async fn repeated_identical_readonly_call_in_same_turn_reuses_recent_result() {
let mut backing = TestContextBacking::new(4).await;
let args = json!({
"action": "read",
"path": backing.sample_file.to_string_lossy()
});
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let mut tp_ctx = backing.turn_processing_context();
let mut outcome_ctx = ToolOutcomeContext {
ctx: &mut tp_ctx,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
};
let first = handle_single_tool_call(
&mut outcome_ctx,
"read_once",
tool_names::UNIFIED_FILE,
args.clone(),
)
.await
.expect("first readonly call should succeed");
assert!(first.is_none());
assert_eq!(outcome_ctx.ctx.harness_state.tool_calls, 1);
assert_eq!(outcome_ctx.ctx.tool_registry.execution_history_len(), 1);
let second = handle_single_tool_call(
&mut outcome_ctx,
"read_twice",
tool_names::UNIFIED_FILE,
args,
)
.await
.expect("duplicate readonly call should be reused");
assert!(second.is_none());
assert_eq!(outcome_ctx.ctx.harness_state.tool_calls, 1);
assert_eq!(outcome_ctx.ctx.tool_registry.execution_history_len(), 1);
assert!(outcome_ctx.ctx.working_history.iter().any(|message| {
message
.content
.as_text()
.contains("\"reused_recent_result\":true")
}));
assert!(outcome_ctx.ctx.working_history.iter().any(|message| {
message
.content
.as_text()
.contains("\"result_ref_only\":true")
}));
}
#[tokio::test]
async fn repeated_same_file_read_variants_activate_recovery_before_fourth_read() {
let mut backing = TestContextBacking::new(4).await;
let sample_path = backing.sample_file.to_string_lossy().to_string();
let read_args = vec![
json!({
"action": "read",
"path": sample_path.clone()
}),
json!({
"action": "read",
"path": sample_path.clone(),
"line_start": 1,
"line_end": 1
}),
json!({
"action": "read",
"path": sample_path.clone(),
"offset": 0,
"limit": 1
}),
json!({
"action": "read",
"path": sample_path.clone(),
"line_start": 1,
"line_end": 2
}),
];
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let mut tp_ctx = backing.turn_processing_context();
let mut outcome_ctx = ToolOutcomeContext {
ctx: &mut tp_ctx,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
};
for (idx, args) in read_args.into_iter().enumerate().take(3) {
let outcome = handle_single_tool_call(
&mut outcome_ctx,
&format!("read_variant_{idx}"),
tool_names::UNIFIED_FILE,
args,
)
.await
.expect("read variant should complete");
assert!(outcome.is_none());
}
assert_eq!(outcome_ctx.ctx.tool_registry.execution_history_len(), 3);
assert_eq!(
outcome_ctx
.ctx
.harness_state
.consecutive_same_file_read_family_calls,
3
);
let blocked = handle_single_tool_call(
&mut outcome_ctx,
"read_variant_blocked",
tool_names::UNIFIED_FILE,
json!({
"action": "read",
"path": sample_path,
"line_start": 1,
"line_end": 2
}),
)
.await
.expect("fourth read attempt should be handled");
assert!(matches!(blocked, Some(TurnHandlerOutcome::Continue)));
assert_eq!(outcome_ctx.ctx.tool_registry.execution_history_len(), 3);
assert_eq!(
outcome_ctx
.ctx
.harness_state
.consecutive_same_file_read_family_calls,
4
);
assert!(outcome_ctx.ctx.is_recovery_active());
assert!(
outcome_ctx
.ctx
.working_history
.iter()
.any(|message| { message.content.as_text().contains("repeated_read_family") })
);
}
#[tokio::test]
async fn denied_tool_permission_emits_policy_response_without_budget_burn() {
let mut backing = TestContextBacking::new(2).await;
let valid_file = backing.sample_file.clone();
let denial_args = json!({"path": valid_file.to_string_lossy()});
cache_tool_permission(
&mut backing,
tool_names::READ_FILE,
&denial_args,
PermissionGrant::Denied,
)
.await;
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let mut tp_ctx = backing.turn_processing_context();
let mut outcome_ctx = ToolOutcomeContext {
ctx: &mut tp_ctx,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
};
let outcome = handle_single_tool_call(
&mut outcome_ctx,
"denied",
tool_names::READ_FILE,
denial_args,
)
.await
.expect("denied permission should be handled");
assert!(outcome.is_none());
assert_eq!(outcome_ctx.ctx.harness_state.tool_calls, 0);
assert!(outcome_ctx.ctx.working_history.iter().any(|message| {
message
.content
.as_text()
.contains("execution denied by policy")
}));
}
#[tokio::test]
async fn prepared_tool_calls_respect_unlimited_budget_when_cap_disabled() {
let mut backing = TestContextBacking::new(0).await;
let valid_file = backing.sample_file.clone();
let valid_args = json!({"path": valid_file.to_string_lossy()});
cache_tool_permission(
&mut backing,
tool_names::READ_FILE,
&valid_args,
PermissionGrant::Permanent,
)
.await;
let tool_call = PreparedAssistantToolCall::new(uni::ToolCall::function(
"prepared_read".to_string(),
tool_names::READ_FILE.to_string(),
serde_json::to_string(&valid_args).expect("serialize tool args"),
));
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let mut tp_ctx = backing.turn_processing_context();
let mut outcome_ctx = ToolOutcomeContext {
ctx: &mut tp_ctx,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
};
let outcome = handle_prepared_tool_call(&mut outcome_ctx, &tool_call)
.await
.expect("prepared tool call should execute");
assert!(outcome.is_none());
assert_eq!(outcome_ctx.ctx.harness_state.tool_calls, 1);
assert!(!outcome_ctx.ctx.harness_state.tool_budget_exhausted());
assert!(!outcome_ctx.ctx.working_history.iter().any(|message| {
message
.content
.as_text()
.contains("exceeded max tool calls per turn")
}));
}
#[tokio::test]
async fn multiple_prepared_tool_calls_respect_unlimited_budget_when_cap_disabled() {
let mut backing = TestContextBacking::new(0).await;
let second_file = backing
.sample_file
.parent()
.expect("temp workspace root")
.join("other.txt");
std::fs::write(&second_file, "world\n").expect("write second sample file");
let tool_calls = vec![
PreparedAssistantToolCall::new(uni::ToolCall::function(
"prepared_search_1".to_string(),
tool_names::UNIFIED_SEARCH.to_string(),
serde_json::to_string(&json!({
"action": "grep",
"path": ".",
"pattern": "hello"
}))
.expect("serialize tool args"),
)),
PreparedAssistantToolCall::new(uni::ToolCall::function(
"prepared_search_2".to_string(),
tool_names::UNIFIED_SEARCH.to_string(),
serde_json::to_string(&json!({
"action": "grep",
"path": ".",
"pattern": "world"
}))
.expect("serialize tool args"),
)),
];
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let mut tp_ctx = backing.turn_processing_context();
let mut outcome_ctx = ToolOutcomeContext {
ctx: &mut tp_ctx,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
};
let outcome = handle_tool_calls(&mut outcome_ctx, &tool_calls)
.await
.expect("prepared tool calls should execute");
assert!(outcome.is_none());
assert_eq!(outcome_ctx.ctx.harness_state.tool_calls, 2);
assert!(!outcome_ctx.ctx.harness_state.tool_budget_exhausted());
assert!(!outcome_ctx.ctx.working_history.iter().any(|message| {
message
.content
.as_text()
.contains("exceeded max tool calls per turn")
}));
}
#[tokio::test]
async fn end_to_end_blocked_calls_do_not_burn_budget_before_valid_call() {
let mut backing = TestContextBacking::new(1).await;
let valid_file = backing.sample_file.clone();
let valid_args = json!({"path": valid_file.to_string_lossy()});
cache_tool_permission(
&mut backing,
tool_names::READ_FILE,
&valid_args,
PermissionGrant::Permanent,
)
.await;
let mut turn_modified_files: BTreeSet<std::path::PathBuf> = BTreeSet::new();
let mut repeated_tool_attempts = LoopTracker::new();
let mut tp_ctx = backing.turn_processing_context();
let mut outcome_ctx = ToolOutcomeContext {
ctx: &mut tp_ctx,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
};
let blocked_args = json!({"path":"/var/db/shadow"});
let first = handle_single_tool_call(
&mut outcome_ctx,
"blocked_1",
tool_names::READ_FILE,
blocked_args.clone(),
)
.await
.expect("first blocked call should not fail hard");
assert!(first.is_none());
let second = handle_single_tool_call(
&mut outcome_ctx,
"blocked_2",
tool_names::READ_FILE,
blocked_args,
)
.await
.expect("second blocked call should not fail hard");
assert!(second.is_none());
assert_eq!(outcome_ctx.ctx.harness_state.tool_calls, 0);
assert!(!outcome_ctx.ctx.harness_state.tool_budget_exhausted());
let third = handle_single_tool_call(
&mut outcome_ctx,
"valid_1",
tool_names::READ_FILE,
valid_args.clone(),
)
.await
.expect("valid call should execute");
assert!(third.is_none());
assert_eq!(outcome_ctx.ctx.harness_state.tool_calls, 1);
assert!(outcome_ctx.ctx.harness_state.tool_budget_exhausted());
let exhausted = handle_single_tool_call(
&mut outcome_ctx,
"exhausted",
tool_names::READ_FILE,
valid_args,
)
.await
.expect("exhausted-budget call should return structured outcome");
assert!(matches!(
exhausted,
Some(TurnHandlerOutcome::Break(TurnLoopResult::Blocked { .. }))
));
assert!(outcome_ctx.ctx.working_history.iter().any(|message| {
message.role == uni::MessageRole::System
&& message
.content
.as_text()
.contains("\"continue\" or provide a new instruction")
}));
}