use super::recovery_guidance::{
empty_response_notice, empty_response_recovery_mode, empty_response_recovery_reason,
planning_empty_response_synthesis_directive, recovery_empty_fallback_safety_message,
recovery_empty_response_fallback_message,
};
use anyhow::Result;
use std::collections::BTreeSet;
use std::path::PathBuf;
use vtcode_core::llm::provider as uni;
use vtcode_core::utils::ansi::MessageStyle;
use crate::agent::runloop::unified::run_loop_context::RecoveryMode;
use crate::agent::runloop::unified::turn::context::{
PreparedAssistantToolCall, TurnHandlerOutcome, TurnLoopResult, TurnProcessingContext, TurnProcessingResult,
};
use crate::agent::runloop::unified::turn::guards::handle_turn_balancer;
use crate::agent::runloop::unified::turn::tool_outcomes::{ToolOutcomeContext, handle_tool_calls, helpers};
use crate::agent::runloop::unified::turn::turn_loop::{
MAX_ASSISTANT_TEXT_RESPONSES_PER_TURN, PENDING_VERIFICATION_BLOCK_REASON, RECOVERY_CONTRACT_VIOLATION_REASON,
};
pub(crate) struct HandleTurnProcessingResultParams<'a> {
pub ctx: &'a mut TurnProcessingContext<'a>,
pub processing_result: TurnProcessingResult,
pub response_streamed: bool,
pub step_count: usize,
pub repeated_tool_attempts: &'a mut helpers::LoopTracker,
pub turn_modified_files: &'a mut BTreeSet<PathBuf>,
pub max_tool_loops: usize,
pub tool_repeat_limit: usize,
}
fn should_suppress_pre_tool_result_claim(assistant_text: &str, tool_calls: &[PreparedAssistantToolCall]) -> bool {
if assistant_text.trim().is_empty() {
return false;
}
if !tool_calls.iter().any(PreparedAssistantToolCall::is_command_execution) {
return false;
}
let lower = assistant_text.to_ascii_lowercase();
[
"found ",
"warning",
"warnings",
"error",
"errors",
"passed",
"failed",
"no issues",
"completed successfully",
]
.iter()
.any(|marker| lower.contains(marker))
}
fn record_assistant_tool_calls(
history: &mut Vec<uni::Message>,
tool_calls: &[PreparedAssistantToolCall],
history_len_before_assistant: usize,
) {
if tool_calls.is_empty() {
return;
}
let raw_tool_calls = tool_calls
.iter()
.map(|tool_call| tool_call.raw_call().clone())
.collect::<Vec<_>>();
let appended_assistant_message = history.len() > history_len_before_assistant
&& history
.last()
.is_some_and(|message| message.role == uni::MessageRole::Assistant && message.tool_calls.is_none());
if appended_assistant_message {
if let Some(last) = history.last_mut() {
last.tool_calls = Some(raw_tool_calls);
last.phase = Some(uni::AssistantPhase::Commentary);
}
return;
}
history.push(
uni::Message::assistant_with_tools(String::new(), raw_tool_calls)
.with_phase(Some(uni::AssistantPhase::Commentary)),
);
}
pub(crate) enum PendingVerificationTextOutcome {
Continue,
Block { reason: String },
AutoVerify { command: String },
}
fn is_completion_claim_text(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
[
"is complete",
"is done",
"is finished",
"are complete",
"are done",
"completed",
"all done",
"good to go",
"ready for review",
"task complete",
"work complete",
"implementation complete",
"successfully",
"verified",
"tests pass",
"test passes",
"build passes",
"no errors",
"working correctly",
]
.iter()
.any(|marker| lower.contains(marker))
}
impl TurnProcessingContext<'_> {
pub(crate) fn handle_pending_verification_text_response(
&mut self,
repeated_tool_attempts: &mut helpers::LoopTracker,
assistant_text: &str,
) -> Result<PendingVerificationTextOutcome> {
repeated_tool_attempts.mark_verification_pending();
if !repeated_tool_attempts.verification_warning_emitted {
let (warning, directive) = if repeated_tool_attempts.fix_edits_remaining > 0 {
(helpers::FAILED_VERIFICATION_FIX_WARNING, helpers::FAILED_VERIFICATION_FIX_DIRECTIVE)
} else {
(helpers::ANTI_BLIND_EDITING_WARNING, helpers::ANTI_BLIND_EDITING_DIRECTIVE)
};
self.renderer.line(MessageStyle::Warning, warning).unwrap_or(());
self.working_history.push(uni::Message::system(directive.to_string()));
repeated_tool_attempts.verification_warning_emitted = true;
}
if is_completion_claim_text(assistant_text)
&& let Some(command) = self.try_harness_auto_verify(repeated_tool_attempts)
{
return Ok(PendingVerificationTextOutcome::AutoVerify { command });
}
let response_count = self.harness_state.record_assistant_text_response();
if response_count < MAX_ASSISTANT_TEXT_RESPONSES_PER_TURN {
return Ok(PendingVerificationTextOutcome::Continue);
}
let max_attempts = helpers::verification_in_turn_attempts(self.vt_cfg);
if repeated_tool_attempts.record_verification_auto_recovery_with_limit(max_attempts) {
let attempt = repeated_tool_attempts.verification_auto_recovery_attempts();
let workspace_root = self.tool_registry.workspace_root();
let default_verifier =
vtcode_core::tools::tool_intent::default_verifier_for_workspace(workspace_root.as_path());
let directive = vtcode_core::tools::tool_intent::verification_recovery_directive(
default_verifier.as_deref(),
attempt,
max_attempts,
);
self.renderer
.line(MessageStyle::Info, helpers::VERIFICATION_AUTO_RECOVERY_WARNING)
.unwrap_or(());
self.working_history.push(uni::Message::system(directive));
self.harness_state.reset_assistant_text_response_streak();
self.session_stats
.set_verification_snapshot(repeated_tool_attempts.verification_snapshot());
return Ok(PendingVerificationTextOutcome::Continue);
}
if let Some(command) = self.try_harness_auto_verify(repeated_tool_attempts) {
return Ok(PendingVerificationTextOutcome::AutoVerify { command });
}
Ok(PendingVerificationTextOutcome::Block {
reason: PENDING_VERIFICATION_BLOCK_REASON.to_string(),
})
}
fn try_harness_auto_verify(&mut self, repeated_tool_attempts: &mut helpers::LoopTracker) -> Option<String> {
let max_failures = helpers::verification_max_consecutive_failures(self.vt_cfg);
if helpers::verification_auto_execute_enabled(self.vt_cfg)
&& self.session_stats.verification_consecutive_failures() < max_failures
&& repeated_tool_attempts.should_auto_execute_verifier()
{
return helpers::resolve_harness_verifier_command(
self.vt_cfg,
self.tool_registry.workspace_root().as_path(),
);
}
None
}
}
fn last_tool_response_text(history: &[uni::Message], call_id: &str, window_start: usize) -> Option<String> {
history.get(window_start..)?.iter().rev().find_map(|message| {
(message.role == uni::MessageRole::Tool && message.tool_call_id.as_deref() == Some(call_id))
.then(|| message.content.as_text().to_string())
})
}
pub(crate) async fn execute_harness_auto_verification(
ctx: &mut TurnProcessingContext<'_>,
repeated_tool_attempts: &mut helpers::LoopTracker,
turn_modified_files: &mut BTreeSet<PathBuf>,
step_count: usize,
max_tool_loops: usize,
tool_repeat_limit: usize,
command: String,
) -> Result<TurnHandlerOutcome> {
use vtcode_core::config::constants::tools as tool_names;
repeated_tool_attempts.record_auto_verification_executed();
ctx.renderer
.line(MessageStyle::Info, &format!("{} `{command}`", helpers::HARNESS_AUTO_VERIFICATION_WARNING))
.unwrap_or(());
ctx.working_history.push(uni::Message::system(format!(
"Harness auto-verification: running `{command}` via exec_command (standalone, output capped). \
This call is harness-issued autonomous recovery, not a model action; its result carries the same weight as a model-run verifier."
)));
let raw_call = uni::ToolCall::function(
helpers::HARNESS_AUTO_VERIFY_CALL_ID.to_string(),
tool_names::EXEC_COMMAND.to_string(),
serde_json::json!({"cmd": command}).to_string(),
);
let synthetic = PreparedAssistantToolCall::new(raw_call);
if synthetic.args().is_none() {
return Ok(TurnHandlerOutcome::Break(TurnLoopResult::Blocked {
reason: Some(PENDING_VERIFICATION_BLOCK_REASON.to_string()),
}));
}
let history_len_before_assistant = ctx.working_history.len();
record_assistant_tool_calls(ctx.working_history, std::slice::from_ref(&synthetic), history_len_before_assistant);
let break_outcome = {
let mut t_ctx_inner = ToolOutcomeContext {
ctx: &mut *ctx,
repeated_tool_attempts: &mut *repeated_tool_attempts,
turn_modified_files: &mut *turn_modified_files,
};
let outcome = handle_tool_calls(&mut t_ctx_inner, std::slice::from_ref(&synthetic)).await?;
if t_ctx_inner.repeated_tool_attempts.verification_is_pending() {
if let Some(response_text) = last_tool_response_text(
t_ctx_inner.ctx.working_history,
helpers::HARNESS_AUTO_VERIFY_CALL_ID,
history_len_before_assistant,
) {
let failures = t_ctx_inner
.ctx
.session_stats
.record_verification_auto_failure(command.clone(), &response_text);
t_ctx_inner.ctx.working_history.push(uni::Message::system(format!(
"Harness auto-verification `{command}` did not clear the gate (consecutive failure {failures} this episode). \
A bounded fix window is active: repair the reported failure, then re-run the standalone verifier."
)));
}
} else {
t_ctx_inner.ctx.session_stats.record_verification_auto_success();
t_ctx_inner.ctx.working_history.push(uni::Message::system(format!(
"Harness auto-verification `{command}` exited 0; the verification gate is cleared. Resume the request."
)));
}
t_ctx_inner
.ctx
.session_stats
.set_verification_snapshot(t_ctx_inner.repeated_tool_attempts.verification_snapshot());
outcome
};
if let Some(res) = break_outcome {
return Ok(res);
}
Ok(handle_turn_balancer(ctx, step_count, repeated_tool_attempts, max_tool_loops, tool_repeat_limit).await)
}
pub(crate) async fn handle_turn_processing_result<'a>(
params: HandleTurnProcessingResultParams<'a>,
) -> Result<TurnHandlerOutcome> {
match params.processing_result {
TurnProcessingResult::ToolCalls {
tool_calls,
assistant_text,
reasoning,
reasoning_details,
} => {
if params.ctx.is_recovery_active() && params.ctx.recovery_pass_used() && params.ctx.recovery_is_tool_free()
{
if params.ctx.is_planning_active() {
if params.ctx.try_planning_violation_repair(&assistant_text)? {
return Ok(TurnHandlerOutcome::Continue);
}
return params.ctx.break_planning_recovery_with_handoff(
"the synthesis response attempted a tool call while tools were disabled",
(!assistant_text.trim().is_empty()).then_some(assistant_text.as_str()),
);
}
params
.ctx
.harness_state
.record_recovery_rejected_synthesis(assistant_text.trim().to_string());
return Ok(TurnHandlerOutcome::Break(TurnLoopResult::Blocked {
reason: Some(RECOVERY_CONTRACT_VIOLATION_REASON.to_string()),
}));
}
let assistant_text = if should_suppress_pre_tool_result_claim(&assistant_text, &tool_calls) {
String::new()
} else {
assistant_text
};
let assistant_text_len = assistant_text.len();
let reasoning_segments = reasoning.len();
let reasoning_details_count = reasoning_details.as_ref().map_or(0, Vec::len);
let history_len_before_assistant = params.ctx.working_history.len();
params.ctx.handle_assistant_response(
assistant_text,
reasoning,
reasoning_details,
params.response_streamed,
Some(uni::AssistantPhase::Commentary),
)?;
record_assistant_tool_calls(params.ctx.working_history, &tool_calls, history_len_before_assistant);
tracing::info!(
target: "vtcode.turn.metrics",
metric = "tool_call_turn_start",
run_id = %params.ctx.harness_state.run_id.0,
turn_id = %params.ctx.harness_state.turn_id.0,
tool_calls = tool_calls.len(),
assistant_text_len,
reasoning_segments,
reasoning_details = reasoning_details_count,
history_len = params.ctx.working_history.len(),
"turn metric"
);
let outcome = {
let mut t_ctx_inner = ToolOutcomeContext {
ctx: &mut *params.ctx,
repeated_tool_attempts: &mut *params.repeated_tool_attempts,
turn_modified_files: &mut *params.turn_modified_files,
};
handle_tool_calls(&mut t_ctx_inner, &tool_calls).await?
};
if let Some(res) = outcome {
tracing::info!(
target: "vtcode.turn.metrics",
metric = "tool_call_turn_outcome",
run_id = %params.ctx.harness_state.run_id.0,
turn_id = %params.ctx.harness_state.turn_id.0,
outcome = "direct_break",
"turn metric"
);
return Ok(res);
}
let balancer_outcome = handle_turn_balancer(
&mut *params.ctx,
params.step_count,
&mut *params.repeated_tool_attempts,
params.max_tool_loops,
params.tool_repeat_limit,
)
.await;
tracing::info!(
target: "vtcode.turn.metrics",
metric = "tool_call_turn_outcome",
run_id = %params.ctx.harness_state.run_id.0,
turn_id = %params.ctx.harness_state.turn_id.0,
outcome = match &balancer_outcome {
TurnHandlerOutcome::Continue => "continue",
TurnHandlerOutcome::Break(_) => "break",
TurnHandlerOutcome::SwitchPrimaryAgent(_) => "switch_primary_agent",
TurnHandlerOutcome::SwitchPrimaryAgentWithPolicy { .. } => "switch_primary_agent",
TurnHandlerOutcome::BreakWithPolicy { .. } => "break",
},
"turn metric"
);
Ok(balancer_outcome)
}
TurnProcessingResult::TextResponse { text, reasoning, reasoning_details, proposed_plan } => {
let is_planning_synthesis = proposed_plan.is_some() || params.ctx.is_planning_active();
let tool_free_synthesis = params.ctx.in_tool_free_recovery_synthesis();
if params.repeated_tool_attempts.verification_is_pending() && !is_planning_synthesis && !tool_free_synthesis
{
match params
.ctx
.handle_pending_verification_text_response(params.repeated_tool_attempts, &text)?
{
PendingVerificationTextOutcome::Continue => return Ok(TurnHandlerOutcome::Continue),
PendingVerificationTextOutcome::Block { reason } => {
return Ok(TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(reason) }));
}
PendingVerificationTextOutcome::AutoVerify { command } => {
return execute_harness_auto_verification(
&mut *params.ctx,
&mut *params.repeated_tool_attempts,
&mut *params.turn_modified_files,
params.step_count,
params.max_tool_loops,
params.tool_repeat_limit,
command,
)
.await;
}
}
}
if params.ctx.is_recovery_active()
&& params.ctx.recovery_pass_used()
&& params.ctx.recovery_is_tool_free()
&& (crate::agent::runloop::text_tools::detect_textual_tool_call(&text).is_some()
|| crate::agent::runloop::text_tools::contains_pseudo_tool_call_markers(&text))
{
let attempted_complete_tool_call =
crate::agent::runloop::text_tools::detect_textual_tool_call(&text).is_some();
if !attempted_complete_tool_call {
let cleaned = crate::agent::runloop::text_tools::strip_dsml_markup(&text).trim().to_string();
if !cleaned.is_empty()
&& crate::agent::runloop::text_tools::detect_textual_tool_call(&cleaned).is_none()
&& !crate::agent::runloop::text_tools::contains_pseudo_tool_call_markers(&cleaned)
{
let _ = params
.ctx
.renderer
.line(MessageStyle::Info, "[i] Cleaned recovery response (removed tool-call markup).");
return params
.ctx
.handle_text_response(
cleaned,
reasoning,
reasoning_details,
proposed_plan,
params.response_streamed,
)
.await;
}
let cleaned = crate::agent::runloop::text_tools::strip_textual_tool_call_regions(&text)
.trim()
.to_string();
if !cleaned.is_empty()
&& crate::agent::runloop::text_tools::detect_textual_tool_call(&cleaned).is_none()
&& !crate::agent::runloop::text_tools::contains_pseudo_tool_call_markers(&cleaned)
{
let _ = params
.ctx
.renderer
.line(MessageStyle::Info, "[i] Cleaned recovery response (removed tool-call markup).");
return params
.ctx
.handle_text_response(
cleaned,
reasoning,
reasoning_details,
proposed_plan,
params.response_streamed,
)
.await;
}
}
let salvage = crate::agent::runloop::text_tools::strip_textual_tool_call_regions(
&crate::agent::runloop::text_tools::strip_dsml_markup(&text),
)
.trim()
.to_string();
if params.ctx.is_planning_active() {
if params.ctx.try_planning_violation_repair(&salvage)? {
return Ok(TurnHandlerOutcome::Continue);
}
return params.ctx.break_planning_recovery_with_handoff(
"the synthesis response attempted tool-call markup",
(!salvage.is_empty()).then_some(salvage.as_str()),
);
}
params.ctx.harness_state.record_recovery_rejected_synthesis(salvage);
return Ok(TurnHandlerOutcome::Break(TurnLoopResult::Blocked {
reason: Some(RECOVERY_CONTRACT_VIOLATION_REASON.to_string()),
}));
}
params
.ctx
.handle_text_response(text, reasoning, reasoning_details, proposed_plan, params.response_streamed)
.await
}
TurnProcessingResult::Refusal { reason } => {
tracing::warn!(reason = %reason, "Provider refused the turn; ending it without recovery retries.");
params.ctx.harness_state.mark_turn_refused();
Ok(TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(reason) }))
}
TurnProcessingResult::Empty => {
if params.ctx.is_recovery_active() && params.ctx.recovery_pass_used() {
let recovery_mode = if params.ctx.recovery_is_tool_free() {
RecoveryMode::ToolFreeSynthesis
} else {
RecoveryMode::ToolEnabledRetry
};
if params.ctx.is_planning_active() && matches!(recovery_mode, RecoveryMode::ToolEnabledRetry) {
params.ctx.finish_recovery_pass();
params.ctx.switch_to_tool_free_recovery();
let directive = planning_empty_response_synthesis_directive(
params.ctx.working_history,
params.ctx.tool_registry.workspace_root().as_path(),
);
params.ctx.push_system_message(directive);
params
.ctx
.renderer
.line(
MessageStyle::Info,
"[!] Two empty planning responses detected; scheduling one tool-free plan synthesis pass.",
)
.unwrap_or(());
tracing::warn!(
"Two empty planning responses received; scheduling bounded tool-free plan synthesis."
);
return Ok(TurnHandlerOutcome::Continue);
}
if params.ctx.is_planning_active() && matches!(recovery_mode, RecoveryMode::ToolFreeSynthesis) {
return params
.ctx
.break_planning_recovery_with_handoff("the model returned an empty synthesis response", None);
}
let recovery_reason = if params.ctx.recovery_is_tool_free() {
"Recovery mode requested a final synthesis pass, but the model returned no answer."
} else {
"Recovery retry requested another autonomous pass, but the model still returned no answer."
};
let fallback_message = recovery_empty_response_fallback_message(recovery_mode);
let final_fallback = if fallback_message.trim().is_empty() {
recovery_empty_fallback_safety_message(recovery_mode)
} else {
fallback_message
};
params.ctx.harness_state.mark_final_response_fallback();
params.ctx.handle_assistant_response(
final_fallback.clone(),
Vec::new(),
None,
false,
Some(uni::AssistantPhase::FinalAnswer),
)?;
params.ctx.finish_recovery_pass();
tracing::warn!(
mode = ?recovery_mode,
reason = recovery_reason,
fallback_chars = final_fallback.len(),
"Recovery pass returned no content; emitted deterministic fallback answer."
);
return Ok(TurnHandlerOutcome::Break(TurnLoopResult::Completed {
plan_approved_execution_pending: false,
}));
}
let recovery_mode = empty_response_recovery_mode(
params.ctx.working_history,
params.ctx.is_planning_active(),
params.ctx.is_approved_plan_execution(),
);
let recovery_reason = empty_response_recovery_reason(recovery_mode).to_string();
params.ctx.activate_recovery_with_mode(recovery_reason.clone(), recovery_mode);
params
.ctx
.renderer
.line(MessageStyle::Info, empty_response_notice(recovery_mode))
.unwrap_or(());
params.ctx.working_history.push(uni::Message::system(recovery_reason));
Ok(TurnHandlerOutcome::Continue)
}
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use super::{
HandleTurnProcessingResultParams, handle_turn_processing_result, record_assistant_tool_calls,
should_suppress_pre_tool_result_claim,
};
use crate::agent::runloop::unified::run_loop_context::RecoveryMode;
use crate::agent::runloop::unified::turn::context::{
PreparedAssistantToolCall, TurnHandlerOutcome, TurnLoopResult, TurnProcessingResult,
};
use crate::agent::runloop::unified::turn::tool_outcomes::helpers::LoopTracker;
use crate::agent::runloop::unified::turn::turn_loop::RECOVERY_CONTRACT_VIOLATION_REASON;
use crate::agent::runloop::unified::turn::turn_processing::test_support::TestTurnProcessingBacking;
use vtcode_core::llm::provider as uni;
fn prepared_command_tool_call() -> PreparedAssistantToolCall {
PreparedAssistantToolCall::new(uni::ToolCall::function(
"call_1".to_string(),
"exec_command".to_string(),
r#"{"action":"run","command":"cargo clippy"}"#.to_string(),
))
}
#[test]
fn suppresses_result_claims_before_run_tool_output() {
let tool_calls = vec![prepared_command_tool_call()];
assert!(should_suppress_pre_tool_result_claim("Found 3 clippy warnings. Let me fix them.", &tool_calls));
}
#[test]
fn keeps_non_result_preamble_for_run_tools() {
let tool_calls = vec![prepared_command_tool_call()];
assert!(!should_suppress_pre_tool_result_claim("Running cargo clippy now.", &tool_calls));
}
#[test]
fn records_tool_calls_on_newly_added_assistant_message() {
let mut history = vec![uni::Message::user("u".to_string())];
let tool_calls = vec![PreparedAssistantToolCall::new(uni::ToolCall::function(
"call_1".to_string(),
"code_search".to_string(),
r#"{"query":"foo"}"#.to_string(),
))];
let len_before_assistant = history.len();
history.push(uni::Message::assistant("Searching now.".to_string()));
record_assistant_tool_calls(&mut history, &tool_calls, len_before_assistant);
assert_eq!(history.len(), 2);
let last = history.last().expect("assistant message");
assert_eq!(last.role, uni::MessageRole::Assistant);
assert_eq!(last.phase, Some(uni::AssistantPhase::Commentary));
assert_eq!(last.tool_calls.as_ref().map(|calls| calls[0].id.clone()).as_deref(), Some("call_1"));
}
#[test]
fn appends_tool_call_message_when_no_assistant_message_was_added() {
let mut history = vec![uni::Message::user("u".to_string())];
let tool_calls = vec![PreparedAssistantToolCall::new(uni::ToolCall::function(
"call_1".to_string(),
"code_search".to_string(),
r#"{"query":"foo"}"#.to_string(),
))];
let len_before_assistant = history.len();
record_assistant_tool_calls(&mut history, &tool_calls, len_before_assistant);
assert_eq!(history.len(), 2);
let last = history.last().expect("synthetic assistant tool call message");
assert_eq!(last.role, uni::MessageRole::Assistant);
assert_eq!(last.content.as_text(), "");
assert_eq!(last.phase, Some(uni::AssistantPhase::Commentary));
assert_eq!(last.tool_calls.as_ref().map(|calls| calls[0].id.clone()).as_deref(), Some("call_1"));
}
#[tokio::test]
async fn recovery_tool_calls_break_turn_as_blocked() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let tool_calls = vec![PreparedAssistantToolCall::new(uni::ToolCall::function(
"call_1".to_string(),
"code_search".to_string(),
r#"{"query":"loop"}"#.to_string(),
))];
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::ToolCalls {
tool_calls,
assistant_text: String::new(),
reasoning: Vec::new(),
reasoning_details: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("recovery tool calls should be handled");
assert!(matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(reason) })
if reason == RECOVERY_CONTRACT_VIOLATION_REASON
));
}
#[tokio::test]
async fn anti_blind_guard_does_not_allow_final_response_before_verification() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
let outcome = {
let mut ctx = backing.turn_processing_context();
handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: "The README update is complete.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("anti-blind guard should handle the unverified response")
};
assert!(matches!(outcome, TurnHandlerOutcome::Continue));
assert!(backing.last_history_message_contains(
crate::agent::runloop::unified::turn::tool_outcomes::helpers::ANTI_BLIND_EDITING_DIRECTIVE
));
}
#[tokio::test]
async fn anti_blind_guard_blocks_repeated_unverified_text_responses() {
use crate::agent::runloop::unified::turn::tool_outcomes::helpers::MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS;
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
let expected_texts = 2 * (1 + u32::from(MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS));
for step in 1..=expected_texts {
let outcome = {
let mut ctx = backing.turn_processing_context();
handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: "The change is complete.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: step as usize,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("anti-blind response should be handled")
};
if step < expected_texts {
assert!(matches!(outcome, TurnHandlerOutcome::Continue), "step {step} should continue");
} else {
assert!(
matches!(outcome, TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(_) })),
"step {step} should block after auto-recovery exhaustion"
);
}
}
assert!(!backing.last_history_message_contains("The change is complete."));
assert_eq!(
repeated_tool_attempts.verification_auto_recovery_attempts(),
MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS
);
}
#[tokio::test]
async fn anti_blind_auto_recovery_names_project_verifier() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
for step in 1..=2 {
let mut ctx = backing.turn_processing_context();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: "Still working.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: step,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("auto-recovery response should be handled");
assert!(matches!(outcome, TurnHandlerOutcome::Continue));
}
assert_eq!(
repeated_tool_attempts.verification_auto_recovery_attempts(),
1,
"second text response should consume the first auto-recovery attempt"
);
assert!(
backing.last_history_message_contains("Verification recovery (1/"),
"recovery directive must carry attempt counts"
);
assert!(
backing.last_history_message_contains("max_output_tokens"),
"recovery directive must name the truncation mechanism"
);
}
async fn drive_pending_texts(
backing: &mut TestTurnProcessingBacking,
repeated_tool_attempts: &mut LoopTracker,
turn_modified_files: &mut BTreeSet<std::path::PathBuf>,
steps: u32,
) -> TurnHandlerOutcome {
let mut outcome = TurnHandlerOutcome::Continue;
for step in 1..=steps {
let mut ctx = backing.turn_processing_context();
outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: "Still working.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: step as usize,
repeated_tool_attempts: &mut *repeated_tool_attempts,
turn_modified_files: &mut *turn_modified_files,
max_tool_loops: 8,
tool_repeat_limit: 4,
})
.await
.expect("pending-verification text should be handled");
if !matches!(outcome, TurnHandlerOutcome::Continue) {
break;
}
}
outcome
}
#[tokio::test]
async fn harness_auto_verification_success_clears_gate_and_continues() {
let mut backing = TestTurnProcessingBacking::new(8).await;
backing.set_verification_override_for_test("rustc --version");
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
let outcome = drive_pending_texts(&mut backing, &mut repeated_tool_attempts, &mut turn_modified_files, 6).await;
assert!(matches!(outcome, TurnHandlerOutcome::Continue));
assert!(
!repeated_tool_attempts.verification_is_pending(),
"harness-executed `rustc --version` must clear the gate"
);
assert_eq!(repeated_tool_attempts.consecutive_mutations, 0);
assert!(
!repeated_tool_attempts.should_auto_execute_verifier(),
"a cleared gate must not re-arm harness execution"
);
assert!(
backing.last_history_message_contains("gate is cleared"),
"success must leave an explicit gate-cleared note"
);
}
#[tokio::test]
async fn harness_auto_verification_failure_grants_fix_window_and_records_episode() {
let mut backing = TestTurnProcessingBacking::new(8).await;
backing.set_verification_override_for_test("rustc --invalid-flag-xyz");
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
let outcome = drive_pending_texts(&mut backing, &mut repeated_tool_attempts, &mut turn_modified_files, 6).await;
assert!(matches!(outcome, TurnHandlerOutcome::Continue));
assert!(repeated_tool_attempts.verification_is_pending(), "a failed verifier must keep the gate pending");
assert_eq!(
repeated_tool_attempts.fix_edits_remaining,
crate::agent::runloop::unified::turn::tool_outcomes::helpers::FAILED_VERIFICATION_FIX_ALLOWANCE,
"harness-executed failure must grant the same fix window as a model-run verifier"
);
assert!(
repeated_tool_attempts.auto_verification_executed,
"the one-shot flag must stick while the gate stays pending"
);
assert!(
backing.last_history_message_contains("did not clear the gate"),
"failure must leave an explicit fix-window note"
);
}
async fn drive_model_tool_call(
backing: &mut TestTurnProcessingBacking,
repeated_tool_attempts: &mut LoopTracker,
turn_modified_files: &mut BTreeSet<std::path::PathBuf>,
tool_name: &str,
args_json: &str,
) {
use crate::agent::runloop::unified::turn::tool_outcomes::ToolOutcomeContext;
use crate::agent::runloop::unified::turn::tool_outcomes::handle_tool_calls;
let mut ctx = backing.turn_processing_context();
let call = PreparedAssistantToolCall::new(uni::ToolCall::function(
"call-rewrite-e2e".to_string(),
tool_name.to_string(),
args_json.to_string(),
));
let mut t_ctx = ToolOutcomeContext {
ctx: &mut ctx,
repeated_tool_attempts: &mut *repeated_tool_attempts,
turn_modified_files: &mut *turn_modified_files,
};
handle_tool_calls(&mut t_ctx, std::slice::from_ref(&call))
.await
.expect("model tool call should dispatch");
}
#[tokio::test]
async fn piped_verifier_executes_standalone_with_truthful_status() {
let mut backing = TestTurnProcessingBacking::new(8).await;
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
assert!(repeated_tool_attempts.verification_is_pending());
let mut turn_modified_files = BTreeSet::new();
drive_model_tool_call(
&mut backing,
&mut repeated_tool_attempts,
&mut turn_modified_files,
"exec_command",
r#"{"cmd": "rustc --version | head -c 5"}"#,
)
.await;
assert!(
!repeated_tool_attempts.verification_is_pending(),
"truthful exit 0 from the elided verifier must clear the gate"
);
assert_eq!(repeated_tool_attempts.consecutive_mutations, 0);
}
#[tokio::test]
async fn piped_verifier_failure_surfaces_instead_of_tail_success() {
let mut backing = TestTurnProcessingBacking::new(8).await;
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
drive_model_tool_call(
&mut backing,
&mut repeated_tool_attempts,
&mut turn_modified_files,
"exec_command",
r#"{"cmd": "rustc --invalid-flag-xyz | tail -5"}"#,
)
.await;
assert!(
repeated_tool_attempts.verification_is_pending(),
"truthful verifier failure must keep the gate pending"
);
assert_eq!(
repeated_tool_attempts.fix_edits_remaining,
crate::agent::runloop::unified::turn::tool_outcomes::helpers::FAILED_VERIFICATION_FIX_ALLOWANCE,
"truthful failure must grant the fix window"
);
}
#[test]
fn auto_verification_attribution_ignores_prior_turn_responses() {
use super::last_tool_response_text;
let history = vec![
uni::Message::user("do the work".to_string()),
uni::Message::tool_response("harness-auto-verify".to_string(), "stale failure".to_string()),
uni::Message::assistant("commentary".to_string()),
];
assert_eq!(last_tool_response_text(&history, "harness-auto-verify", 3), None);
assert_eq!(last_tool_response_text(&history, "harness-auto-verify", 99), None);
assert_eq!(last_tool_response_text(&history, "harness-auto-verify", 0).as_deref(), Some("stale failure"));
assert_eq!(last_tool_response_text(&history, "call-other", 0), None);
}
#[test]
fn completion_claim_detector_targets_done_assertions_not_progress_chatter() {
use super::is_completion_claim_text;
for claim in [
"The change is complete.",
"All done — implementation complete.",
"Done. All tests pass.",
"The build passes with no errors.",
"Fixed and verified.",
"READY FOR REVIEW",
] {
assert!(is_completion_claim_text(claim), "should detect claim: {claim}");
}
for chatter in [
"Still working on the refactor.",
"Let me check the remaining files.",
"Running the next batch of edits now.",
"",
"ok",
] {
assert!(!is_completion_claim_text(chatter), "must not fire on chatter: {chatter}");
}
}
#[tokio::test]
async fn completion_claim_jumps_straight_to_harness_verification() {
let mut backing = TestTurnProcessingBacking::new(8).await;
backing.set_verification_override_for_test("rustc --version");
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
let mut ctx = backing.turn_processing_context();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: "The change is complete.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 8,
tool_repeat_limit: 4,
})
.await
.expect("completion claim should be handled");
assert!(matches!(outcome, TurnHandlerOutcome::Continue));
assert!(!repeated_tool_attempts.verification_is_pending(), "executed `rustc --version` must clear the gate");
assert_eq!(
repeated_tool_attempts.verification_auto_recovery_attempts(),
0,
"fast path must not consume directive budget"
);
assert!(!repeated_tool_attempts.auto_verification_executed, "success clears the one-shot flag with the gate");
}
#[tokio::test]
async fn harness_auto_execute_disabled_falls_back_to_block() {
let mut backing = TestTurnProcessingBacking::new(8).await;
let mut vt_cfg = vtcode_core::config::loader::VTCodeConfig::default();
vt_cfg.agent.harness.verification.auto_execute = false;
vt_cfg.agent.harness.verification.default_verifier_override = Some("rustc --version".to_string());
backing.set_vt_cfg_for_test(vt_cfg);
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
let outcome = drive_pending_texts(&mut backing, &mut repeated_tool_attempts, &mut turn_modified_files, 6).await;
assert!(
matches!(outcome, TurnHandlerOutcome::Break(TurnLoopResult::Blocked { .. })),
"disabled auto-execute must preserve the manual blocked handoff"
);
assert!(!repeated_tool_attempts.auto_verification_executed);
assert!(repeated_tool_attempts.verification_is_pending());
}
#[tokio::test]
async fn harness_auto_verification_escalated_suite_blocks_without_executing() {
let mut backing = TestTurnProcessingBacking::new(8).await;
backing.set_verification_override_for_test("rustc --version");
{
let ctx = backing.turn_processing_context();
for _ in 0..3 {
ctx.session_stats
.record_verification_auto_failure("rustc --version".to_string(), "boom");
}
assert_eq!(ctx.session_stats.verification_consecutive_failures(), 3);
}
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
let outcome = drive_pending_texts(&mut backing, &mut repeated_tool_attempts, &mut turn_modified_files, 6).await;
assert!(
matches!(outcome, TurnHandlerOutcome::Break(TurnLoopResult::Blocked { .. })),
"an escalated never-passing suite must block instead of executing again"
);
assert!(!repeated_tool_attempts.auto_verification_executed);
}
#[tokio::test]
async fn tool_free_recovery_texts_bypass_verification_accounting() {
let mut backing = TestTurnProcessingBacking::new(8).await;
backing.set_verification_override_for_test("rustc --version");
let mut repeated_tool_attempts = LoopTracker::new();
repeated_tool_attempts.consecutive_mutations =
crate::agent::runloop::unified::turn::tool_outcomes::helpers::BLIND_EDITING_THRESHOLD;
let mut turn_modified_files = BTreeSet::new();
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("post-tool follow-up failure");
assert!(ctx.consume_recovery_pass());
assert!(ctx.in_tool_free_recovery_synthesis());
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: "Synthesizing the gathered evidence into a final answer.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 8,
tool_repeat_limit: 4,
})
.await
.expect("recovery synthesis should be handled");
assert!(matches!(outcome, TurnHandlerOutcome::Break(TurnLoopResult::Completed { .. })));
assert!(repeated_tool_attempts.verification_is_pending());
assert_eq!(repeated_tool_attempts.verification_auto_recovery_attempts(), 0);
assert!(!repeated_tool_attempts.auto_verification_executed);
}
#[tokio::test]
async fn pending_verification_notice_reports_failed_verifier_during_fix_window() {
use crate::agent::runloop::unified::turn::tool_outcomes::helpers::{
ANTI_BLIND_EDITING_DIRECTIVE, FAILED_VERIFICATION_FIX_ALLOWANCE,
};
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut repeated_tool_attempts =
LoopTracker::with_verification_snapshot((true, FAILED_VERIFICATION_FIX_ALLOWANCE));
let mut turn_modified_files = BTreeSet::new();
let outcome = {
let mut ctx = backing.turn_processing_context();
handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: "The build failure is in the parser module.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("fix-window notice should be handled")
};
assert!(matches!(outcome, TurnHandlerOutcome::Continue));
assert!(backing.last_history_message_contains("verification command ran and failed"));
assert!(
!backing.last_history_message_contains(ANTI_BLIND_EDITING_DIRECTIVE),
"generic never-ran directive must not be used while fix edits are granted"
);
}
#[tokio::test]
async fn refusal_blocks_turn_with_reason_even_during_recovery() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let reason = "The model declined this request (category: cyber).".to_string();
let blocked_with_reason = {
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Refusal { reason: reason.clone() },
response_streamed: true,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("refusal should be handled");
matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(ref blocked) }) if *blocked == reason
)
};
assert!(blocked_with_reason);
assert!(backing.turn_refused(), "refusal must mark the turn for history rollback");
}
#[tokio::test]
async fn recovery_empty_response_emits_fallback_and_completes_turn() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("recovery empty response should be handled");
assert!(matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Completed { plan_approved_execution_pending: _ })
));
assert!(backing.last_history_message_contains(
"I couldn't produce a final synthesis because the model returned no answer on the recovery pass."
));
}
#[tokio::test]
async fn recovery_empty_response_fallback_stays_concise_without_evidence_dump() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.working_history.push(uni::Message::user("tell me more".to_string()));
ctx.working_history
.push(uni::Message::tool_response("call_1".to_string(), "first tool output".to_string()));
ctx.working_history
.push(uni::Message::tool_response("call_2".to_string(), "second tool output".to_string()));
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("recovery empty response should be handled");
assert!(matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Completed { plan_approved_execution_pending: _ })
));
assert!(backing.last_history_message_contains(
"I couldn't produce a final synthesis because the model returned no answer on the recovery pass."
));
assert!(!backing.last_history_message_contains("Latest user request:"));
assert!(!backing.last_history_message_contains("Tool output 1:"));
}
#[tokio::test]
async fn recovery_empty_response_fallback_omits_spool_excerpt() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.working_history
.push(uni::Message::user("summarize the failed read".to_string()));
ctx.working_history.push(uni::Message::tool_response(
"call_1".to_string(),
serde_json::json!({
"path": "src/main.rs",
"spool_path": ".vtcode/context/tool_outputs/read_1.txt",
"preview": "fallback-line-1\nfallback-line-2"
})
.to_string(),
));
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("recovery empty response should be handled");
assert!(matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Completed { plan_approved_execution_pending: _ })
));
assert!(backing.last_history_message_contains(
"I couldn't produce a final synthesis because the model returned no answer on the recovery pass."
));
assert!(!backing.last_history_message_contains("Spool excerpt:"));
assert!(!backing.last_history_message_contains("fallback-line-1"));
}
#[tokio::test]
async fn recovery_retry_empty_response_emits_fallback_and_completes_turn() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.push_system_message("prior context");
ctx.activate_recovery_with_mode("empty response", RecoveryMode::ToolEnabledRetry);
assert!(ctx.consume_recovery_pass());
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("recovery retry empty response should be handled");
assert!(matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Completed { plan_approved_execution_pending: _ })
));
assert!(
backing.last_history_message_contains(
"I couldn't continue because the model returned no answer twice in a row."
)
);
}
#[tokio::test]
async fn recovery_textual_tool_markup_breaks_turn_as_blocked() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: r#"
<minimax:tool_call>
<invoke name="apply_patch">
<parameter name="action">read</parameter>
<parameter name="path">crates/codegen/vtcode-core/src/core/agent/runtime/mod.rs</parameter>
</invoke>
</minimax:tool_call>
"#
.to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("recovery textual tool markup should be handled");
assert!(matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(reason) })
if reason == RECOVERY_CONTRACT_VIOLATION_REASON
));
}
#[tokio::test]
async fn recovery_complete_tool_call_with_disclosure_breaks_as_violation() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: r#"The requested change was not applied because tools were disabled.
<minimax:tool_call>
<invoke name="apply_patch">
<parameter name="action">write</parameter>
<parameter name="path">README.md</parameter>
</invoke>
</minimax:tool_call>
Please re-run with tools enabled."#
.to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("complete tool-call markup should take the violation path");
assert!(
matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(reason) })
if reason == RECOVERY_CONTRACT_VIOLATION_REASON
),
"a complete textual tool call must break with a contract violation even with honest disclosure prose"
);
let salvaged = backing
.take_recovery_rejected_synthesis_for_test()
.expect("violation should record salvaged prose");
assert!(
salvaged.contains("The requested change was not applied because tools were disabled."),
"honest disclosure prose must be preserved for the fallback, got: {salvaged}"
);
assert!(
salvaged.contains("Please re-run with tools enabled."),
"trailing guidance must be preserved for the fallback, got: {salvaged}"
);
assert!(!backing.last_history_message_contains("<invoke"));
}
#[tokio::test]
async fn empty_response_schedules_tool_enabled_retry_without_prior_tool_activity() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = {
let mut ctx = backing.turn_processing_context();
handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: true,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("empty response should schedule recovery")
};
assert!(matches!(outcome, TurnHandlerOutcome::Continue));
assert!(!backing.recovery_is_tool_free());
assert!(backing.last_history_message_contains("Tools remain available"));
}
#[tokio::test]
async fn empty_response_after_tool_activity_schedules_tool_free_recovery() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = {
let mut ctx = backing.turn_processing_context();
ctx.working_history
.push(uni::Message::assistant("Running cargo fmt now.".to_string()).with_tool_calls(vec![
uni::ToolCall::function(
"call_1".to_string(),
"exec_command".to_string(),
r#"{"action":"run","command":"cargo fmt"}"#.to_string(),
),
]));
ctx.working_history
.push(uni::Message::tool_response("call_1".to_string(), "formatted".to_string()));
handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: true,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("empty response after tool activity should schedule synthesis recovery")
};
assert!(matches!(outcome, TurnHandlerOutcome::Continue));
assert!(backing.recovery_is_tool_free());
}
#[tokio::test]
async fn planning_two_empty_responses_schedule_one_tool_free_synthesis() {
let mut backing = TestTurnProcessingBacking::new(4).await;
backing.activate_planning_for_test();
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let first = {
let mut ctx = backing.turn_processing_context();
handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("first empty response should arm recovery")
};
assert!(matches!(first, TurnHandlerOutcome::Continue));
assert!(!backing.recovery_is_tool_free());
{
let mut ctx = backing.turn_processing_context();
assert!(ctx.consume_recovery_pass());
}
let second = {
let mut ctx = backing.turn_processing_context();
handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: false,
step_count: 2,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("second empty response should schedule synthesis")
};
assert!(matches!(second, TurnHandlerOutcome::Continue));
assert!(backing.recovery_is_tool_free());
assert!(backing.last_history_message_contains("exactly one completed `<proposed_plan>` block"));
{
let mut ctx = backing.turn_processing_context();
assert!(ctx.consume_recovery_pass());
}
let third = {
let mut ctx = backing.turn_processing_context();
handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::Empty,
response_streamed: false,
step_count: 3,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("failed synthesis should produce a blocked handoff")
};
assert!(matches!(third, TurnHandlerOutcome::Break(TurnLoopResult::Blocked { .. })));
assert!(backing.last_history_message_contains("Planning remains active"));
}
#[tokio::test]
async fn recovery_non_parseable_tool_call_marker_breaks_turn() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let malformed = "Since tools are disabled in this recovery pass, here is what I would \
apply:\n\
<tool_call>\n\
<function=apply_patch>\n\
<parameter=patch>--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-old\n+new\n</parameter=patch>\n\
</function=apply_patch>\n\
</tool_call>";
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: malformed.to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("malformed tool-call markup should not panic");
assert!(
matches!(outcome, TurnHandlerOutcome::Break(_)),
"recovery with non-parseable tool_call tag should break, not continue"
);
}
#[tokio::test]
async fn recovery_parseable_tool_call_with_preamble_breaks_turn() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let text = "• I have sufficient evidence from prior reads. Applying the section rewrite now.\
<tool_call>exec_command<arg_key>cmd\n\
</arg_key><arg_value>grep -n \"## Why VT Code\" README.md</arg_value></tool_call>";
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: text.to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("parseable tool-call markup should not panic");
assert!(
matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(reason) })
if reason == RECOVERY_CONTRACT_VIOLATION_REASON
),
"recovery with a complete textual tool call must break with a contract violation, not complete with the preamble"
);
assert!(
!backing.last_history_message_contains("Applying the section rewrite"),
"stripped preamble must not be published as the final answer"
);
let salvaged = backing
.take_recovery_rejected_synthesis_for_test()
.expect("violation should record salvaged prose");
assert!(
salvaged.contains("Applying the section rewrite"),
"preamble must be preserved for the labeled fallback, got: {salvaged}"
);
}
#[tokio::test]
async fn recovery_complete_tool_call_with_appended_disclosure_still_breaks() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let text = "Applying the section rewrite now.\
<tool_call>exec_command<arg_key>cmd</arg_key><arg_value>grep -n x README.md</arg_value></tool_call> \
(tools were disabled, so this could not run)";
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: text.to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("appended disclosure must not bypass the violation path");
assert!(
matches!(
outcome,
TurnHandlerOutcome::Break(TurnLoopResult::Blocked { reason: Some(reason) })
if reason == RECOVERY_CONTRACT_VIOLATION_REASON
),
"a complete textual tool call with appended disclosure must still break with a contract violation"
);
}
#[tokio::test]
async fn recovery_complete_tool_call_in_planning_takes_handoff() {
let mut backing = TestTurnProcessingBacking::new(4).await;
backing.activate_planning_for_test();
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let text = "• I have sufficient evidence from prior reads. Applying the section rewrite now.\
<tool_call>exec_command<arg_key>cmd</arg_key><arg_value>grep -n x README.md</arg_value></tool_call>";
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: text.to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("planning complete tool-call markup should take the handoff path");
assert!(
matches!(outcome, TurnHandlerOutcome::Break(TurnLoopResult::Blocked { .. })),
"planning complete tool-call markup must break with a resumable handoff, not complete"
);
assert!(backing.last_history_message_contains("Planning remains active"));
}
#[tokio::test]
async fn recovery_clean_prose_is_not_intercepted_by_marker_guard() {
let mut backing = TestTurnProcessingBacking::new(4).await;
let mut ctx = backing.turn_processing_context();
ctx.activate_recovery("loop detector");
assert!(ctx.consume_recovery_pass());
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let outcome = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: TurnProcessingResult::TextResponse {
text: "The search found 3 matches. Next step: update the failing test.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
},
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("clean recovery prose should be handled");
assert!(
matches!(outcome, TurnHandlerOutcome::Break(TurnLoopResult::Completed { .. })),
"clean prose in recovery should complete the turn normally"
);
assert!(backing.last_history_message_contains("3 matches"));
}
#[tokio::test]
async fn denied_interview_retries_plain_text_once_before_approval() {
let mut backing = TestTurnProcessingBacking::new(4).await;
backing.activate_planning_for_test();
backing.mark_interview_denied_for_test();
let mut ctx = backing.turn_processing_context();
let mut repeated_tool_attempts = LoopTracker::new();
let mut turn_modified_files = BTreeSet::new();
let response = TurnProcessingResult::TextResponse {
text: "Next open decision: none from available evidence. Type yes to start this plan.".to_string(),
reasoning: Vec::new(),
reasoning_details: None,
proposed_plan: None,
};
let first = handle_turn_processing_result(HandleTurnProcessingResultParams {
ctx: &mut ctx,
processing_result: response,
response_streamed: false,
step_count: 1,
repeated_tool_attempts: &mut repeated_tool_attempts,
turn_modified_files: &mut turn_modified_files,
max_tool_loops: 4,
tool_repeat_limit: 4,
})
.await
.expect("denied interview response should schedule synthesis");
assert!(matches!(first, TurnHandlerOutcome::Continue));
}
}