use std::path::{Path, PathBuf};
use std::time::Instant;
use rustc_hash::{FxHashMap, FxHashSet};
use vtcode_core::core::agent::refusal;
use vtcode_core::llm::provider as uni;
use vtcode_core::tools::names::canonical_tool_name;
use vtcode_core::tools::tool_intent::{
ShellActivity, classify_shell_activity, shell_args_as_executed, shell_command_is_admitted_verification_attempt,
};
use crate::agent::runloop::unified::tool_pipeline::{ToolExecutionStatus, ToolPipelineOutcome};
use crate::agent::runloop::unified::turn::tool_outcomes::read_extent;
use crate::agent::runloop::unified::turn::tool_outcomes::{is_grep_style_no_match, output_field_is_empty};
/// Threshold: number of consecutive file mutations before the Anti-Blind-Editing
/// warning fires. NL2Repo-Bench recommends verifying after every few edits.
pub(crate) const BLIND_EDITING_THRESHOLD: usize = 6;
pub(crate) const ANTI_BLIND_EDITING_WARNING: &str = "[!] Anti-Blind-Editing: run a verifier (build/test/lint — e.g. `cargo check`, `go test`, or `pytest`) and let it exit 0 before further edits.";
/// Ends with the text of [`VERIFIER_SHELL_FORM_NOTE`] so the shell forms it
/// describes match what the execution kernel elides; a test keeps the two in
/// lockstep because `concat!` cannot splice a cross-crate const.
pub(crate) const ANTI_BLIND_EDITING_DIRECTIVE: &str = "Several edits have landed without a build/test/lint run since the last check, so further code mutations are blocked until a verifier exits 0 (docs-only edits stay allowed). Run your project's build/test/lint tool with `exec_command` (e.g. `cargo check`, `go test`, `npm test`, or `pytest`), standalone or as a pure `&&` chain. Cap output with `max_output_tokens`. A verifier piped only into `head` or `tail` runs without the truncator and counts as standalone; filtering pipes (`| grep`), `;`, and `||` make the exit status another command's, so they do not clear the gate.";
/// Fix-up window granted after a failed verification attempt. A failed
/// `cargo check` / `cargo nextest run` must not deadlock the turn: the agent
/// needs a bounded number of edits to address the reported failure before
/// re-verifying. Each failed verifier refreshes this window, so blind editing
/// (many edits with no verifier attempt) stays blocked while fix-verify loops
/// can make progress.
pub(crate) const FAILED_VERIFICATION_FIX_ALLOWANCE: u8 = 2;
/// Warning rendered when a pending gate's verifier result was lost (the exec
/// session ended before the verifier's output was captured).
pub(crate) const VERIFICATION_RESULT_LOST_WARNING: &str =
"[!] Verification result lost: the exec session ended before the verifier's output was captured.";
/// Model-facing directive paired with [`VERIFICATION_RESULT_LOST_WARNING`]:
/// a standalone verifier re-run is the only way to clear the pending gate.
pub(crate) const VERIFICATION_RESULT_LOST_DIRECTIVE: &str = "Verification result lost: the exec session ended before the verifier's output was captured. Re-run the verification command standalone or as a pure `&&` chain to confirm or reject the recent edits.";
/// Warning rendered while the failed-verifier fix-up window is active. Distinct
/// from [`ANTI_BLIND_EDITING_WARNING`] so the pending-verification block notice
/// does not imply verification was never run when the verifier already failed.
pub(crate) const FAILED_VERIFICATION_FIX_WARNING: &str =
"[!] Verification failed: bounded fix edits granted before the gate re-arms.";
/// Model-facing directive paired with [`FAILED_VERIFICATION_FIX_WARNING`]:
/// the verifier ran and reported failure, so text responses must repair the
/// reported failure and re-run a standalone verifier instead of claiming
/// completion.
pub(crate) const FAILED_VERIFICATION_FIX_DIRECTIVE: &str = "The last verification command ran and failed. A bounded fix window is active: apply fixes for the reported failure, then re-run the verification command standalone or as a pure `&&` chain. The work is accepted once a verifier exits 0.";
/// Warning rendered when a verifier behind a filtering pipe or a `;`/`||`
/// join (e.g. `cargo check 2>&1 | grep error`) succeeded while the gate is
/// pending: the exit status belongs to another command, so the verifier's
/// success cannot clear the gate. Pure `head`/`tail` truncator shapes never
/// land here: the execution kernel runs them as standalone verifiers, and the
/// tracker classifies the command as executed
/// ([`vtcode_core::tools::tool_intent::shell_args_as_executed`]).
pub(crate) const PIPED_VERIFICATION_WARNING: &str = "[!] Piped verifier did not clear the verification gate: the exit status belongs to another command, not the verifier.";
/// Model-facing directive paired with [`PIPED_VERIFICATION_WARNING`]: without
/// this feedback a piped success reads as "verified" to the model and the
/// pending gate deadlocks the turn on unverified text responses.
pub(crate) const PIPED_VERIFICATION_DIRECTIVE: &str = "The verification command ran behind a filtering pipe or a `;`/`||` join, so its exit status belongs to another command (e.g. `grep`) and it did not clear the verification gate. Re-run the verifier standalone or as a pure `&&` chain of verifiers; a pipe only into `head` or `tail` also counts as standalone. Cap output with `max_output_tokens` instead of filtering it.";
/// Bounded in-turn autonomous recovery attempts when the model emits text
/// instead of a verifier while the gate is pending.
///
/// Without this, two explanatory text responses end the turn as `Blocked` and
/// force the user to type `continue` — a manual step that stalls long-running
/// autonomous work. Each attempt resets the text-response streak once and
/// injects a project-aware directive naming the exact verifier command (see
/// `default_verifier_for_workspace`), giving the model one more bounded
/// chance to verify before the turn blocks. Mirrors Codex's Stop-hook test
/// gate philosophy: the harness keeps the turn alive until verification is
/// attempted, rather than punishing the first explanatory responses.
pub(crate) const MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS: u8 = 2;
/// Renderer line paired with the autonomous verification-recovery directive.
/// Distinct from [`ANTI_BLIND_EDITING_WARNING`] so transcripts show that the
/// harness granted an automatic retry (with attempt counts) instead of
/// repeating the initial warning.
pub(crate) const VERIFICATION_AUTO_RECOVERY_WARNING: &str =
"[i] Verification still pending — autonomous recovery: run the named verifier now instead of replying with text.";
/// Warning rendered when the harness executes the project verifier itself
/// after the model exhausted its directive retries. Distinct from
/// [`VERIFICATION_AUTO_RECOVERY_WARNING`] (a directive grant) so transcripts
/// show the harness took action rather than asking once more.
pub(crate) const HARNESS_AUTO_VERIFICATION_WARNING: &str =
"[i] Harness auto-verification: running the project verifier now instead of blocking.";
/// Cross-turn counterpart to [`MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS`]:
/// how many additional autonomous turns the session loop may schedule after
/// a verification-blocked turn before requiring manual `continue`.
pub(crate) const MAX_VERIFICATION_AUTO_RECOVERY_TURNS: u8 = 2;
/// Consecutive failed harness auto-verifications before the harness stops
/// executing verifiers itself and escalates to a manual blocked handoff
/// carrying the failure log. Reset by any success, completed turn, or fresh
/// user input, so only a genuinely stuck suite trips it.
pub(crate) const MAX_VERIFICATION_CONSECUTIVE_FAILURES: u8 = 3;
/// Bound (in chars) for the harness auto-verification failure excerpt kept
/// for the escalated blocked handoff. The full tool output stays in history
/// and the spool; the handoff carries only the tail needed to triage.
pub(crate) const VERIFICATION_FAILURE_EXCERPT_CHARS: usize = 2000;
/// Tool-call id for the harness-synthesized verifier execution. Fixed (not
/// model-issued) so transcripts and history unambiguously attribute the call
/// to autonomous recovery rather than the model.
pub(crate) const HARNESS_AUTO_VERIFY_CALL_ID: &str = "harness-auto-verify";
/// Effective in-turn directive-retry budget, honoring
/// `[agent.harness.verification].in_turn_attempts` with the compiled constant
/// as fallback when no workspace config is present (tests, headless paths).
pub(crate) fn verification_in_turn_attempts(vt_cfg: Option<&vtcode_core::config::loader::VTCodeConfig>) -> u8 {
vt_cfg
.map(|cfg| cfg.agent.harness.verification.in_turn_attempts)
.unwrap_or(MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS)
}
/// Effective cross-turn recovery-turn budget, honoring
/// `[agent.harness.verification].cross_turn_turns`.
pub(crate) fn verification_cross_turn_turns(vt_cfg: Option<&vtcode_core::config::loader::VTCodeConfig>) -> u8 {
vt_cfg
.map(|cfg| cfg.agent.harness.verification.cross_turn_turns)
.unwrap_or(MAX_VERIFICATION_AUTO_RECOVERY_TURNS)
}
/// Whether tracker-aware auto-continuation is enabled
/// (`[agent.harness.continuation].auto_continue_tracker`).
pub(crate) fn tracker_auto_continue_enabled(vt_cfg: Option<&vtcode_core::config::loader::VTCodeConfig>) -> bool {
vt_cfg
.map(|cfg| cfg.agent.harness.continuation.auto_continue_tracker)
.unwrap_or(true)
}
/// Effective cross-turn tracker auto-continue budget
/// (`[agent.harness.continuation].cross_turn_turns`).
pub(crate) fn tracker_cross_turn_turns(vt_cfg: Option<&vtcode_core::config::loader::VTCodeConfig>) -> u8 {
vt_cfg.map(|cfg| cfg.agent.harness.continuation.cross_turn_turns).unwrap_or(32)
}
/// Cap on incomplete tracker items listed in continuation prompts.
const TRACKER_CONTINUE_ITEM_CAP: usize = 4;
/// Outcome of a live `task_tracker` probe, distinguishing completion from
/// probe failure so caches do not retain stale incomplete steps.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TrackerProbeOutcome {
/// Checklist exists and at least one step is not `completed`.
Incomplete(Vec<String>),
/// Tracker is empty or every step is `completed` — authoritative clear.
Complete,
/// Tool missing / execute failed / malformed payload — keep last cache.
Unavailable,
}
/// Classify a `task_tracker` `action=list` payload for cache/gate decisions.
pub(crate) fn tracker_probe_outcome(payload: &serde_json::Value) -> TrackerProbeOutcome {
let Some(status) = payload.get("status").and_then(serde_json::Value::as_str) else {
return TrackerProbeOutcome::Unavailable;
};
if status == "empty" {
return TrackerProbeOutcome::Complete;
}
let Some(checklist) = payload.get("checklist") else {
return TrackerProbeOutcome::Unavailable;
};
let Some(raw_items) = checklist.get("items").and_then(serde_json::Value::as_array) else {
// Malformed checklist without items: do not treat as Complete (that
// would clear caches and stop auto-continue on a broken probe shape).
return TrackerProbeOutcome::Unavailable;
};
let items: Vec<String> = raw_items
.iter()
.filter(|item| item.get("status").and_then(serde_json::Value::as_str) != Some("completed"))
.filter_map(|item| {
let description = item.get("description").and_then(serde_json::Value::as_str)?;
let status = item.get("status").and_then(serde_json::Value::as_str).unwrap_or("pending");
let index = item.get("index").and_then(serde_json::Value::as_u64);
Some(match index {
Some(index) if index > 0 => format!("#{} {} ({})", index, description, status),
_ => format!("{} ({})", description, status),
})
})
.take(TRACKER_CONTINUE_ITEM_CAP)
.collect();
if items.is_empty() {
TrackerProbeOutcome::Complete
} else {
TrackerProbeOutcome::Incomplete(items)
}
}
/// Parse incomplete step labels from a `task_tracker` list payload.
///
/// Thin wrapper over [`tracker_probe_outcome`] for tests and call sites that
/// only need the incomplete `Option` shape.
#[cfg(test)]
pub(crate) fn parse_incomplete_tracker_items(payload: &serde_json::Value) -> Option<Vec<String>> {
match tracker_probe_outcome(payload) {
TrackerProbeOutcome::Incomplete(items) => Some(items),
TrackerProbeOutcome::Complete | TrackerProbeOutcome::Unavailable => None,
}
}
/// Count completed checklist items in a `task_tracker` `action=list` payload.
///
/// Used for progress-reset of the cross-turn auto-continue episode budget.
/// Returns `0` when the tracker is empty/absent.
pub(crate) fn parse_tracker_completed_count(payload: &serde_json::Value) -> u32 {
let Some(status) = payload.get("status").and_then(serde_json::Value::as_str) else {
return 0;
};
if status == "empty" {
return 0;
}
payload
.get("checklist")
.and_then(|c| c.get("items"))
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter(|item| item.get("status").and_then(serde_json::Value::as_str) == Some("completed"))
.count() as u32
}
/// Apply a live probe to an incomplete-items cache.
///
/// `Incomplete` replaces the cache; `Complete` **clears** it; `Unavailable`
/// leaves the previous value so transient probe failures do not drop
/// auto-continue. Returns the effective incomplete slice after the update.
pub(crate) fn apply_tracker_probe_to_cache(
cache: &mut Option<Vec<String>>,
probe: TrackerProbeOutcome,
) -> Option<&[String]> {
match probe {
TrackerProbeOutcome::Incomplete(items) => {
*cache = Some(items);
}
TrackerProbeOutcome::Complete => {
*cache = None;
}
TrackerProbeOutcome::Unavailable => {}
}
cache.as_deref()
}
/// Load incomplete `task_tracker` step descriptions via the live tool registry.
///
/// Returns `None` when the tracker is absent, empty, fully completed, or the
/// probe fails. Prefer [`probe_tracker_incomplete`] when a cache must
/// distinguish complete from unavailable.
pub(crate) async fn incomplete_tracker_items(
tool_registry: &vtcode_core::tools::registry::ToolRegistry,
) -> Option<Vec<String>> {
match probe_tracker_incomplete(tool_registry).await {
TrackerProbeOutcome::Incomplete(items) => Some(items),
TrackerProbeOutcome::Complete | TrackerProbeOutcome::Unavailable => None,
}
}
/// Live tracker probe that distinguishes complete from unavailable.
pub(crate) async fn probe_tracker_incomplete(
tool_registry: &vtcode_core::tools::registry::ToolRegistry,
) -> TrackerProbeOutcome {
let Some(tool) = tool_registry.get_tool(vtcode_core::config::constants::tools::TASK_TRACKER) else {
return TrackerProbeOutcome::Unavailable;
};
let Ok(payload) = tool.execute(serde_json::json!({ "action": "list" })).await else {
return TrackerProbeOutcome::Unavailable;
};
tracker_probe_outcome(&payload)
}
/// Live completed-item count for progress-reset. Returns `None` on probe failure.
pub(crate) async fn tracker_completed_count(tool_registry: &vtcode_core::tools::registry::ToolRegistry) -> Option<u32> {
let tool = tool_registry.get_tool(vtcode_core::config::constants::tools::TASK_TRACKER)?;
let payload = tool.execute(serde_json::json!({ "action": "list" })).await.ok()?;
Some(parse_tracker_completed_count(&payload))
}
/// Build the model-facing auto-continue follow-up for incomplete tracker work.
/// Stable opening shared with `is_internal_harness_follow_up`, which keys the
/// quiet path off this constant instead of a duplicated literal.
pub(crate) const TRACKER_CONTINUE_FOLLOW_UP_PREFIX: &str = "The task tracker still has incomplete steps:";
pub(crate) fn tracker_continue_follow_up(incomplete: &[String]) -> String {
let joined = incomplete.join(", ");
format!(
"{TRACKER_CONTINUE_FOLLOW_UP_PREFIX} {joined}. This follow-up is the harness resuming \
the work, so no user reply is needed. The next step is to continue with the next incomplete step \
using tools and update task_tracker as steps complete. A status-only recap does not advance the \
tracker. The turn can end when the tracker is complete, or when a user decision or a \
permission/policy block stops progress."
)
}
/// Stable opening for recoverable blocked-end auto-continue when no tracker
/// items remain (or none were created). Session evidence: build/auto turns
/// ending with recovery fallback / blocked-tool fuse parked at `Continue…`.
pub(crate) const RECOVERABLE_BLOCKED_CONTINUE_FOLLOW_UP_PREFIX: &str =
"The previous turn ended on a recoverable block:";
pub(crate) fn recoverable_blocked_continue_follow_up(reason: &str) -> String {
format!(
"{RECOVERABLE_BLOCKED_CONTINUE_FOLLOW_UP_PREFIX} {reason} This follow-up is the harness resuming \
the work, so no user reply is needed. Retry the requested work with tools; if a policy block \
repeated, switch to a read-only approach or ask the user. A status-only recap does not advance \
the work. The turn can end when the request is complete, or when a user decision or a \
permission/policy block stops progress."
)
}
pub(crate) fn recoverable_blocked_auto_continue_directive(reason: &str) -> String {
format!(
"Blocked-end auto-continue: the harness queued this turn after a recoverable block ({reason}). \
No user reply is needed. Resume the original request; do not restate a status recap as the answer."
)
}
/// Label of the system directive paired with a session-resume tracker continuation.
pub(crate) const TRACKER_RESUME_DIRECTIVE_LABEL: &str = "Resume continuation";
/// Label of the system directive paired with an in-session tracker auto-continue.
pub(crate) const TRACKER_AUTO_CONTINUE_DIRECTIVE_LABEL: &str = "Tracker auto-continue";
/// System directive paired with a queued tracker continuation. Both the
/// session-resume and in-session paths share this wording so the stated
/// consequence (the harness resumed; a recap does not advance the tracker)
/// cannot drift between them.
pub(crate) fn tracker_continue_directive(label: &str, incomplete: &[String]) -> String {
format!(
"{label}: task_tracker still has incomplete steps: {}. The harness queued this continuation, so no \
user reply is needed. The next step is the next concrete tracker step; a status-only recap does not \
advance the tracker while work remains.",
incomplete.join(", ")
)
}
/// Shared recoverable blocked-reason shapes for every mode's auto-continue.
/// Keep this the single allow-list so plan-mode and tracker classifiers cannot
/// drift (fuse / no-final / budget / recovery-fallback wording).
/// Deny lists stay per-mode: evaluation order differs (plan allows first so
/// `PLANNING_COMPLETED_TURN_FALLBACK_REASON` is not shadowed by
/// "approval-ready plan").
pub(crate) const RECOVERABLE_BLOCK_ALLOW_TOKENS: &[&str] = &[
"recovery fallback",
"recovery could not confirm",
"recovery exhausted",
"recovery was exhausted",
"reached the safety cap",
"safety cap",
"preview budget",
"tool preview budget",
"turn budget",
"tool budget",
"tool loop budget",
"tool loop",
"tool-call budget",
"tool follow-up",
"tool-free recovery",
"wall clock",
"blocked due to repeated",
"blocked after repeated",
"without a harness-visible final assistant response",
"blocked tool-call limit",
"recovery tool-call limit",
"consecutive blocked calls",
"tool-call safety limit",
"max tool",
"per-turn tool",
"read cap",
"work budget",
"budget exhausted",
"budget ran out",
];
fn matches_recoverable_block_shape(lower: &str) -> bool {
RECOVERABLE_BLOCK_ALLOW_TOKENS.iter().any(|token| lower.contains(token))
}
/// Whether a blocked/completed turn reason is recoverable for tracker auto-queue
/// (budget/preview/tool-free recovery) rather than a user-input handoff.
///
/// Matches **production blocked-reason constants** (turn_loop / post_tool
/// recovery), not paraphrases. Unknown / missing reasons are not auto-queued
/// when the turn did not complete.
pub(crate) fn tracker_auto_continue_is_recoverable_block(reason: Option<&str>) -> bool {
// A provider refusal is terminal for the refused request: resending it is
// refused again. Checked before the substring classifiers because the
// notice may quote provider or response text containing any token.
if reason.is_some_and(refusal::is_refusal_notice) {
return false;
}
let Some(reason) = reason.map(str::to_ascii_lowercase) else {
// Only used when the outer gate already marked the turn Completed.
// Blocked { reason: None } must not auto-queue.
return false;
};
// Deny production constants that must never auto-queue (true handoffs).
// RECOVERY_CONTRACT_VIOLATION_REASON: "...final tool-free synthesis pass...attempted more tool calls."
// PENDING_VERIFICATION_BLOCK_REASON: "...verification is still pending."
// POST_TOOL_CONTEXT_COMPACTION_FAILED_REASON: "context exceeded...compaction could not reduce"
// STALE_APPROVED_PLAN_PAUSE_BLOCK_REASON: "stale recovery state"
// UNMATCHED_TOOL_RESULT / planning interview-approval handoffs / permission / safety fuse.
if reason.contains("permission")
|| reason.contains("user input")
|| reason.contains("request_user_input")
|| reason.contains("safety fuse")
|| reason.contains("manual intervention")
|| reason.contains("verification is still pending")
|| reason.contains("unverified assistant responses")
|| reason.contains("anti-blind")
|| reason.contains("verification gate")
|| reason.contains("context exceeded")
|| reason.contains("compaction could not reduce")
|| reason.contains("unmatched tool result")
|| reason.contains("attempted more tool calls")
|| reason.contains("final tool-free synthesis pass")
|| reason.contains("stale recovery state")
|| reason.contains("interview")
|| reason.contains("awaiting approval")
|| reason.contains("approval-ready plan remains")
{
return false;
}
matches_recoverable_block_shape(&reason)
}
/// Pure gate for outer-loop tracker auto-continue after a turn end.
///
/// `auto_continue_enabled` is the raw kill-switch (do not pre-AND
/// `planning_active` — this gate owns that check).
/// `final_text_is_safety_handoff` is true when the turn's final assistant
/// text is a permission/policy/safety handoff — those never auto-queue, even
/// on `Completed` ends with incomplete tracker work.
/// `final_text_requires_user_input` is true when the final text asks the user
/// a genuine question/decision — Completed ends must not auto-queue past the ask.
///
/// Completed turns require incomplete tracker work. Recoverable **blocked**
/// ends continue even without tracker items: session evidence shows
/// recovery-fallback and blocked-tool fuse turns parking at the user
/// `Continue…` prompt in build/auto when no tracker is active.
pub(crate) fn should_queue_tracker_auto_continue(
auto_continue_enabled: bool,
planning_active: bool,
turn_completed: bool,
blocked_reason: Option<&str>,
is_verification_block: bool,
incomplete_items: Option<&[String]>,
cross_turn_turns: u8,
final_text_is_safety_handoff: bool,
final_text_requires_user_input: bool,
) -> bool {
if !auto_continue_enabled || planning_active || cross_turn_turns == 0 {
return false;
}
if final_text_is_safety_handoff || final_text_requires_user_input {
return false;
}
if turn_completed {
return incomplete_items.is_some_and(|items| !items.is_empty());
}
if is_verification_block || blocked_reason.is_none() {
return false;
}
tracker_auto_continue_is_recoverable_block(blocked_reason)
}
/// Pure gate for resume auto-queue of incomplete tracker work.
pub(crate) fn should_queue_tracker_resume_continuation(
auto_continue_enabled: bool,
cross_turn_turns: u8,
incomplete_items: Option<&[String]>,
) -> bool {
auto_continue_enabled && cross_turn_turns > 0 && incomplete_items.is_some_and(|items| !items.is_empty())
}
/// Pure gate for plan-mode outer auto-continue.
///
/// Continues incomplete planning only on recoverable **blocked** ends when the
/// plan is not yet ready for approval. Ordinary completed planning turns are
/// never auto-continued (they may be interview or approval handoffs). Never
/// auto-approves.
///
/// `consecutive_empty_fallbacks` breaks the empty-turn self-loop: turns that
/// end with the deterministic `PLANNING_COMPLETED_FALLBACK_RESPONSE` (no LLM
/// synthesis, no tool activity) must not re-queue forever. After
/// [`MAX_PLAN_EMPTY_FALLBACK_AUTO_CONTINUE`] consecutive empties the gate
/// closes and the user must `continue` manually.
pub(crate) const MAX_PLAN_EMPTY_FALLBACK_AUTO_CONTINUE: u8 = 2;
/// Stable marker of the deterministic empty-turn fallback text in
/// `turn_loop::PLANNING_COMPLETED_FALLBACK_RESPONSE`. Matched by substring so
/// the gate stays pure (no cross-module constant import) and robust to
/// surrounding file-list appends.
pub(crate) fn is_plan_empty_fallback_text(text: &str) -> bool {
text.contains("without a final plan synthesis")
&& text.contains("the next turn can reuse it without re-reading files")
}
pub(crate) fn should_queue_plan_mode_auto_continue(
auto_continue_enabled: bool,
planning_active: bool,
plan_ready_for_approval: bool,
turn_completed: bool,
blocked_reason: Option<&str>,
is_verification_block: bool,
cross_turn_turns: u8,
consecutive_empty_fallbacks: u8,
) -> bool {
if !auto_continue_enabled || !planning_active || cross_turn_turns == 0 {
return false;
}
if plan_ready_for_approval || is_verification_block || turn_completed {
return false;
}
if consecutive_empty_fallbacks >= MAX_PLAN_EMPTY_FALLBACK_AUTO_CONTINUE {
return false;
}
blocked_reason.is_some_and(plan_mode_recoverable_block)
}
/// Recoverable planning blocked-reason classifier.
///
/// Allow-list is evaluated **first**. Production
/// `PLANNING_COMPLETED_TURN_FALLBACK_REASON` ("Planning turn ended via
/// recovery fallback … approval-ready plan …") must auto-queue; a deny-first
/// match on "planning turn ended" / "approval-ready plan" incorrectly blocked
/// that path and forced a user `continue` nudge. Interview/approval/permission
/// handoffs stay denied after the allow-list misses.
pub(crate) fn plan_mode_recoverable_block(reason: &str) -> bool {
// Refusals never auto-continue; see `tracker_auto_continue_is_recoverable_block`.
if refusal::is_refusal_notice(reason) {
return false;
}
let lower = reason.to_ascii_lowercase();
// True handoffs deny even when recovery/budget tokens are also present
// (compound reasons must not auto-queue past a permission/interview wait).
if lower.contains("request_user_input")
|| lower.contains("permission")
|| lower.contains("user input")
|| lower.contains("awaiting")
|| lower.contains("interview")
|| lower.contains("attempted more tool calls")
|| lower.contains("final tool-free synthesis pass")
|| lower.contains("verification is still pending")
|| lower.contains("compaction could not reduce")
|| lower.contains("unmatched tool result")
{
return false;
}
// Production recovery constants (including PLANNING_COMPLETED_TURN_FALLBACK_REASON)
// are recoverable. Deny tokens like "planning turn ended" / "approval-ready
// plan" must not shadow "recovery fallback".
//
// Entry-turn Blocked shapes common after mid-turn `start_planning`: the
// model hits the read-only gate (blocked-tool fuse) or ends tools without a
// published final. Both must auto-continue planning research instead of
// parking at the user `Continue…` prompt.
if matches_recoverable_block_shape(&lower) {
return true;
}
// Remaining planning handoffs (interview/approval without recovery tokens).
if lower.contains("planning turn ended") || lower.contains("approval-ready plan") {
return false;
}
false
}
/// User-facing plan progress line (title + phase/status only).
pub(crate) fn plan_progress_line(
title: &str,
ready_for_approval: bool,
open_decisions: usize,
step_count: usize,
) -> String {
let label = title.trim();
let name = if label.is_empty() { None } else { Some(label) };
if ready_for_approval {
return match name {
Some(name) if step_count > 0 => format!("• Plan {name} — ready for approval ({step_count} steps)"),
Some(name) => format!("• Plan {name} — ready for approval"),
None if step_count > 0 => format!("• Plan — ready for approval ({step_count} steps)"),
None => "• Plan — ready for approval".to_string(),
};
}
if open_decisions > 0 {
return match name {
Some(name) => format!("• Plan {name} — open decisions: {open_decisions}"),
None => format!("• Plan — open decisions: {open_decisions}"),
};
}
match name {
Some(name) => format!("• Plan {name} — research/synthesis"),
None => "• Plan — research/synthesis".to_string(),
}
}
/// Stable opening marker for the harness-generated plan-mode auto-continue
/// directive. The planning exit trigger treats any user message carrying this
/// marker as machine-generated (not a genuine user turn), so the two sites
/// must share one literal instead of drifting.
pub(crate) const PLAN_MODE_AUTO_CONTINUE_MARKER: &str = "Plan-mode auto-continue:";
/// Shared tail of every plan-mode continuation message. States the
/// consequence (planning stays read-only, the harness resumed the turn, code
/// changes wait for approval) and the next step. It deliberately contains the
/// stay phrase `continue planning` and no implementation cue, so even without
/// the [`PLAN_MODE_AUTO_CONTINUE_MARKER`] guard it could never read as an
/// exit-and-implement intent.
const PLAN_MODE_CONTINUE_DIRECTIVE_TAIL: &str = "Planning stays active and read-only, so the next step is to continue \
planning: read-only research and synthesis toward one compact `<proposed_plan>`. The harness queued this \
continuation, so no user reply is needed, and code changes wait for plan approval.";
/// Follow-up prompt for plan-mode auto-continue turns.
pub(crate) fn plan_mode_continue_follow_up() -> String {
format!(
"{PLAN_MODE_AUTO_CONTINUE_MARKER} no validated persisted plan is ready for approval yet. \
{PLAN_MODE_CONTINUE_DIRECTIVE_TAIL}"
)
}
/// System directive paired with an in-session plan-mode auto-continue.
pub(crate) fn plan_mode_auto_continue_directive() -> String {
format!(
"{PLAN_MODE_AUTO_CONTINUE_MARKER} planning remains active and no validated plan is ready for approval. \
{PLAN_MODE_CONTINUE_DIRECTIVE_TAIL}"
)
}
/// System directive paired with a plan-mode continuation queued on session
/// resume after a recoverable blocked handoff.
pub(crate) fn plan_mode_resume_directive() -> String {
format!(
"Resume continuation: planning remains active after a recoverable blocked handoff. \
{PLAN_MODE_CONTINUE_DIRECTIVE_TAIL}"
)
}
#[cfg(test)]
mod tracker_continue_tests {
use super::*;
#[test]
fn parse_incomplete_tracker_items_shapes() {
assert!(parse_incomplete_tracker_items(&serde_json::json!({"status":"empty"})).is_none());
let complete = serde_json::json!({
"status":"ok",
"checklist":{"items":[{"description":"a","status":"completed"}]}
});
assert!(parse_incomplete_tracker_items(&complete).is_none());
let mixed = serde_json::json!({
"status":"ok",
"checklist":{"items":[
{"index":1,"description":"analyze","status":"completed"},
{"index":2,"description":"change","status":"in_progress"},
{"index":3,"description":"verify","status":"pending"}
]}
});
let items = parse_incomplete_tracker_items(&mixed).expect("incomplete");
assert_eq!(items, vec!["#2 change (in_progress)".to_string(), "#3 verify (pending)".to_string()]);
}
#[test]
fn tracker_follow_up_lists_items_and_forbids_nudge() {
let prompt =
tracker_continue_follow_up(&["#2 change (pending)".to_string(), "#3 verify (blocked)".to_string()]);
assert!(prompt.contains("#2 change (pending)"));
assert!(prompt.contains("#3 verify (blocked)"));
assert!(prompt.contains("no user reply is needed"));
assert!(prompt.contains("A status-only recap does not advance the tracker"));
}
#[test]
fn tracker_continue_directive_is_shared_calm_prose() {
let incomplete = ["#2 change (pending)".to_string(), "#3 verify (pending)".to_string()];
for label in [TRACKER_RESUME_DIRECTIVE_LABEL, TRACKER_AUTO_CONTINUE_DIRECTIVE_LABEL] {
let directive = tracker_continue_directive(label, &incomplete);
assert!(directive.starts_with(&format!("{label}: task_tracker still has incomplete steps:")));
assert!(directive.contains("#2 change (pending), #3 verify (pending)"));
assert!(directive.contains("no user reply is needed"));
assert!(directive.contains("status-only recap does not advance the tracker"));
assert!(!directive.contains("do not"), "directive states consequences, not prohibitions: {directive}");
}
}
#[test]
fn plan_mode_auto_continue_gate_respects_user_gates_and_budget() {
// Ready-for-approval is a user gate.
assert!(!should_queue_plan_mode_auto_continue(true, true, true, true, None, false, 8, 0));
// Ordinary completed planning turns never auto-continue (interview risk).
assert!(!should_queue_plan_mode_auto_continue(true, true, false, true, None, false, 8, 0));
// Recoverable blocked planning continues.
assert!(should_queue_plan_mode_auto_continue(
true,
true,
false,
false,
Some("reached the safety cap"),
false,
8,
0
));
// Planning handoff / verification / blocked-without-reason stay off.
// Production PLANNING_COMPLETED_TURN_FALLBACK_REASON is recoverable
// (allow-list after true-handoff deny) so planning auto-continues.
assert!(should_queue_plan_mode_auto_continue(
true,
true,
false,
false,
Some(
"Planning turn ended via recovery fallback without confirming an approval-ready plan; planning remains active."
),
false,
8,
0
));
// Compound permission+recovery stays denied.
assert!(!should_queue_plan_mode_auto_continue(
true,
true,
false,
false,
Some("recovery fallback; permission denied for exec_command"),
false,
8,
0
));
assert!(!should_queue_plan_mode_auto_continue(
true,
true,
false,
false,
Some("pending verification"),
true,
8,
0
));
assert!(!should_queue_plan_mode_auto_continue(true, true, false, false, None, false, 8, 0));
// Kill-switch / zero budget / inactive planning stay off.
assert!(!should_queue_plan_mode_auto_continue(false, true, false, false, Some("turn budget"), false, 8, 0));
assert!(!should_queue_plan_mode_auto_continue(true, true, false, false, Some("turn budget"), false, 0, 0));
assert!(!should_queue_plan_mode_auto_continue(true, false, false, false, Some("turn budget"), false, 8, 0));
}
#[test]
fn plan_mode_auto_continue_recovers_entry_turn_blocked_shapes() {
// Mid-turn start_planning + mutating attempts trip the blocked-tool fuse;
// the entry turn must not park at the user Continue prompt.
let fuse = "Blocked tool-call limit reached after 3 consecutive blocked calls (streak 3, total 3). Last blocked call: 'apply_patch'. Rebase the patch on current file contents and confirm edit approval before retrying. A bounded recovery response will run without more tool calls. History and outputs are retained. Type 'continue' with new guidance, or run `vtcode --resume <session>`; details: .vtcode/tasks/current_blocked.md.";
assert!(plan_mode_recoverable_block(fuse));
assert!(should_queue_plan_mode_auto_continue(true, true, false, false, Some(fuse), false, 8, 0));
let recovery_fuse = "Recovery tool-call limit reached after 3 blocked calls (streak 3, total 3) (last blocked call: 'exec_command'). Check sandbox/approval policy, narrow the command, or request approval instead of retrying verbatim. History and outputs are retained. Type 'continue' with new guidance, or run `vtcode --resume <session>`; details: .vtcode/tasks/current_blocked.md.";
assert!(plan_mode_recoverable_block(recovery_fuse));
assert!(should_queue_plan_mode_auto_continue(true, true, false, false, Some(recovery_fuse), false, 8, 0));
// Tools ended without a published final after planning entry.
let no_final = "Turn ended without a harness-visible final assistant response, so successful completion could not be confirmed.";
assert!(plan_mode_recoverable_block(no_final));
assert!(should_queue_plan_mode_auto_continue(true, true, false, false, Some(no_final), false, 8, 0));
// Tools outside the named remedy set use the default remedy. Keep that
// prose free of the deny token `permission` so a fuse trip on e.g.
// `write_file` still auto-continues plan-mode research.
let default_remedy_fuse = "Blocked tool-call limit reached after 3 consecutive blocked calls (streak 3, total 3). Last blocked call: 'write_file'. Adjust arguments, policy, or approvals instead of retrying the identical call. A bounded recovery response will run without more tool calls. History and outputs are retained. Type 'continue' with new guidance, or run `vtcode --resume <session>`; details: .vtcode/tasks/current_blocked.md.";
assert!(plan_mode_recoverable_block(default_remedy_fuse));
assert!(should_queue_plan_mode_auto_continue(
true,
true,
false,
false,
Some(default_remedy_fuse),
false,
8,
0
));
// True permission handoffs still never auto-continue.
assert!(!plan_mode_recoverable_block(
"Blocked tool-call limit reached; permission denied for apply_patch; user input required."
));
assert!(!should_queue_plan_mode_auto_continue(
true,
true,
false,
false,
Some("permission denied for apply_patch"),
false,
8,
0
));
}
#[test]
fn plan_mode_auto_continue_stops_after_consecutive_empty_fallbacks() {
let reason = Some(
"Planning turn ended via recovery fallback without confirming an approval-ready plan; planning remains active.",
);
assert!(should_queue_plan_mode_auto_continue(true, true, false, false, reason, false, 32, 0));
assert!(should_queue_plan_mode_auto_continue(true, true, false, false, reason, false, 32, 1));
assert!(!should_queue_plan_mode_auto_continue(
true,
true,
false,
false,
reason,
false,
32,
MAX_PLAN_EMPTY_FALLBACK_AUTO_CONTINUE
));
assert!(!should_queue_plan_mode_auto_continue(true, true, false, false, reason, false, 32, 3));
}
#[test]
fn detects_plan_empty_fallback_text() {
let empty = "Planning remains active, but this turn ended without a final plan synthesis. The research gathered above is preserved, so the next turn can reuse it without re-reading files. Type `keep planning`.";
assert!(is_plan_empty_fallback_text(empty));
// The gate must recognize the production fallback text, not only the fixture.
assert!(is_plan_empty_fallback_text(
crate::agent::runloop::unified::turn::turn_loop::PLANNING_COMPLETED_FALLBACK_RESPONSE
));
assert!(!is_plan_empty_fallback_text("Planning turn ended via recovery fallback without confirming plan."));
assert!(!is_plan_empty_fallback_text(""));
}
#[test]
fn plan_mode_recoverable_block_allow_list() {
assert!(plan_mode_recoverable_block("turn budget exhausted"));
assert!(plan_mode_recoverable_block("reached the safety cap"));
assert!(plan_mode_recoverable_block("tool-free recovery after safety cap"));
assert!(plan_mode_recoverable_block(
"Turn ended with a recovery fallback; the requested work was not confirmed."
));
// Production planning fallback must auto-queue (allow-list first).
assert!(plan_mode_recoverable_block(
"Planning turn ended via recovery fallback without confirming an approval-ready plan; planning remains active."
));
assert!(plan_mode_recoverable_block(
"Tool loop budget exhausted before a final response; planning remains active."
));
// Interview / approval / permission handoffs stay denied.
assert!(!plan_mode_recoverable_block("request_user_input pending"));
assert!(!plan_mode_recoverable_block("permission required"));
assert!(!plan_mode_recoverable_block(
"Recovery mode requested a final tool-free synthesis pass, but the model attempted more tool calls."
));
// Compound recovery+permission must deny (true handoff first).
assert!(!plan_mode_recoverable_block("recovery fallback after permission denied for exec_command"));
assert!(!plan_mode_recoverable_block("tool budget exhausted while awaiting user approval"));
}
#[test]
fn plan_progress_line_shapes() {
assert_eq!(plan_progress_line("Release", false, 0, 0), "• Plan Release — research/synthesis");
assert_eq!(plan_progress_line("Release", false, 2, 4), "• Plan Release — open decisions: 2");
assert_eq!(plan_progress_line("Release", true, 0, 4), "• Plan Release — ready for approval (4 steps)");
assert_eq!(plan_progress_line("Release", true, 0, 0), "• Plan Release — ready for approval");
assert_eq!(plan_progress_line("", true, 0, 0), "• Plan — ready for approval");
assert_eq!(plan_progress_line("", false, 0, 0), "• Plan — research/synthesis");
assert_eq!(plan_progress_line("", true, 0, 4), "• Plan — ready for approval (4 steps)");
assert_eq!(plan_progress_line("", false, 1, 0), "• Plan — open decisions: 1");
}
#[test]
fn plan_mode_continue_messages_keep_planning_read_only_without_a_user_nudge() {
for prompt in [
plan_mode_continue_follow_up(),
plan_mode_auto_continue_directive(),
plan_mode_resume_directive(),
] {
assert!(prompt.contains("no user reply is needed"), "{prompt}");
assert!(prompt.contains("read-only"), "{prompt}");
assert!(prompt.contains("code changes wait for plan approval"), "{prompt}");
assert!(prompt.contains("<proposed_plan>"), "{prompt}");
let normalized = vtcode_core::planning::normalize_plan_intent(&prompt);
assert!(vtcode_core::planning::matches_stay_intent(&normalized), "{prompt}");
assert!(!vtcode_core::planning::contains_implementation_cue(&normalized), "{prompt}");
}
assert!(plan_mode_continue_follow_up().starts_with(PLAN_MODE_AUTO_CONTINUE_MARKER));
assert!(plan_mode_auto_continue_directive().starts_with(PLAN_MODE_AUTO_CONTINUE_MARKER));
}
#[test]
fn refusal_notices_never_auto_continue() {
// A refusal explanation may quote text that matches recoverable
// tokens ("recovery fallback", "tool budget"); the refusal still wins.
let reason = format!(
"{}: the request looked like a recovery fallback for a tool budget bypass. \
The request was not retried; rephrase it or switch models.",
refusal::REFUSAL_NOTICE_PREFIX
);
assert!(!tracker_auto_continue_is_recoverable_block(Some(&reason)));
assert!(!plan_mode_recoverable_block(&reason));
assert!(!should_queue_tracker_auto_continue(
true,
false,
false,
Some(&reason),
false,
Some(&["step".to_string()]),
3,
false,
false,
));
assert!(!should_queue_plan_mode_auto_continue(true, true, false, false, Some(&reason), false, 3, 0));
}
#[test]
fn recoverable_block_classification_allow_list() {
// Production RECOVERY_CONTRACT_VIOLATION_REASON — must NOT auto-queue
// despite containing "tool-free".
assert!(!tracker_auto_continue_is_recoverable_block(Some(
"Recovery mode requested a final tool-free synthesis pass, but the model attempted more tool calls."
)));
// Blocked with unknown/missing reason is not auto-queued.
assert!(!tracker_auto_continue_is_recoverable_block(None));
// Production PENDING_VERIFICATION_BLOCK_REASON.
assert!(!tracker_auto_continue_is_recoverable_block(Some(
"Turn blocked after repeated unverified assistant responses; verification is still pending."
)));
// Production POST_TOOL_CONTEXT_COMPACTION_FAILED_REASON.
assert!(!tracker_auto_continue_is_recoverable_block(Some(
"The provider rejected the follow-up because the context exceeded its capacity, and the bounded recovery compaction could not reduce the request."
)));
// Recoverable production constants.
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Turn ended with a recovery fallback; the requested work was not confirmed. The current plan and task state were retained."
)));
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Turn blocked after repeated assistant responses reached the safety cap; the latest response was preserved."
)));
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Post-tool recovery could not confirm the requested work after one bounded tool-enabled retry. The completed tool outputs and resume handoff were retained; retry from the pending step."
)));
assert!(tracker_auto_continue_is_recoverable_block(Some("preview budget exhausted")));
assert!(tracker_auto_continue_is_recoverable_block(Some("Turn blocked due to repeated failing behavior.")));
// Session/production budget phrases from residual UX work.
assert!(tracker_auto_continue_is_recoverable_block(Some("Task 7 blocked by the turn's preview budget")));
assert!(tracker_auto_continue_is_recoverable_block(Some("tool budget ran out")));
assert!(tracker_auto_continue_is_recoverable_block(Some("hit the per-file read cap")));
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Tool loop budget exhausted before a final response."
)));
// True handoffs stay terminal.
assert!(!tracker_auto_continue_is_recoverable_block(Some(
"Anti-blind checkpoint: verification is still pending"
)));
assert!(!tracker_auto_continue_is_recoverable_block(Some("verification gate remains open")));
// Unknown / policy handoffs stay terminal.
assert!(!tracker_auto_continue_is_recoverable_block(Some("some unknown block")));
assert!(!tracker_auto_continue_is_recoverable_block(Some("exec_command is denied by permission policy")));
assert!(!tracker_auto_continue_is_recoverable_block(Some(
"I hit the tool-call safety fuse mid-verification"
)));
assert!(!tracker_auto_continue_is_recoverable_block(Some("request_user_input is required")));
assert!(!tracker_auto_continue_is_recoverable_block(Some(
"Turn blocked after repeated unverified assistant responses; verification is still pending."
)));
// Session/production recoverable vocabulary parity.
assert!(tracker_auto_continue_is_recoverable_block(Some("Task 7 blocked by the turn's preview budget")));
assert!(tracker_auto_continue_is_recoverable_block(Some("tool budget ran out")));
assert!(tracker_auto_continue_is_recoverable_block(Some("per-file read cap")));
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Tool loop budget exhausted before a final response"
)));
assert!(tracker_auto_continue_is_recoverable_block(Some("work budget exhausted")));
}
#[test]
fn outer_queue_gate_blocks_unknown_and_contract_violation() {
let incomplete = ["#2 change (pending)".to_string()];
// Completed + incomplete tracker → queue when not a handoff/question.
assert!(should_queue_tracker_auto_continue(
true,
false,
true,
None,
false,
Some(&incomplete),
8,
false,
false
));
// Completed + genuine user question → do not queue past the ask.
assert!(!should_queue_tracker_auto_continue(
true,
false,
true,
None,
false,
Some(&incomplete),
8,
false,
true
));
assert!(!should_queue_tracker_auto_continue(
true,
false,
false,
None,
false,
Some(&incomplete),
8,
false,
false
));
assert!(!should_queue_tracker_auto_continue(
true,
false,
false,
Some("Recovery mode requested a final tool-free synthesis pass, but the model attempted more tool calls."),
false,
Some(&incomplete),
8,
false,
false
));
assert!(should_queue_tracker_auto_continue(
true,
false,
false,
Some(
"Turn ended with a recovery fallback; the requested work was not confirmed. The current plan and task state were retained."
),
false,
Some(&incomplete),
8,
false,
false
));
}
#[test]
fn recoverable_blocked_continues_without_tracker_items() {
// Session evidence: build/auto recovery-fallback and fuse trips parked
// at Continue when no tracker was active. Recoverable blocked ends must
// still auto-continue (bounded by cross_turn_turns).
assert!(should_queue_tracker_auto_continue(
true,
false,
false,
Some(
"Turn ended with a recovery fallback; the requested work was not confirmed. The current plan and task state were retained."
),
false,
None,
8,
false,
false
));
let empty: &[String] = &[];
assert!(should_queue_tracker_auto_continue(
true,
false,
false,
Some("Turn ended with a recovery fallback; the requested work was not confirmed."),
false,
Some(empty),
8,
false,
false
));
// Fuse / no-final shapes (common after read-only policy blocks).
assert!(should_queue_tracker_auto_continue(
true,
false,
false,
Some("Blocked tool-call limit reached after 3 consecutive blocked calls."),
false,
None,
8,
false,
false
));
assert!(should_queue_tracker_auto_continue(
true,
false,
false,
Some(
"Turn ended without a harness-visible final assistant response, so successful completion could not be confirmed."
),
false,
None,
8,
false,
false
));
// Completed without tracker still requires incomplete items.
assert!(!should_queue_tracker_auto_continue(true, false, true, None, false, None, 8, false, false));
// Verification / unknown blocked stay off.
assert!(!should_queue_tracker_auto_continue(
true,
false,
false,
Some("Turn blocked after repeated unverified assistant responses; verification is still pending."),
true,
None,
8,
false,
false
));
assert!(!should_queue_tracker_auto_continue(true, false, false, None, false, None, 8, false, false));
}
#[test]
fn outer_queue_gate_respects_planning_verification_and_budget() {
let incomplete = ["#2 change (pending)".to_string()];
assert!(should_queue_tracker_auto_continue(
true,
false,
true,
None,
false,
Some(&incomplete),
8,
false,
false
));
assert!(!should_queue_tracker_auto_continue(
false,
false,
true,
None,
false,
Some(&incomplete),
8,
false,
false
));
assert!(!should_queue_tracker_auto_continue(
true,
true,
true,
None,
false,
Some(&incomplete),
8,
false,
false
));
assert!(!should_queue_tracker_auto_continue(true, false, true, None, false, None, 8, false, false));
assert!(!should_queue_tracker_auto_continue(
true,
false,
true,
None,
false,
Some(&incomplete),
0,
false,
false
));
assert!(!should_queue_tracker_auto_continue(
true,
false,
true,
None,
false,
Some(&incomplete),
8,
true,
false,
// safety-handoff final text
));
assert!(!should_queue_tracker_auto_continue(
true,
false,
false,
Some("pending verification; type continue"),
true,
Some(&incomplete),
8,
false,
false
));
assert!(should_queue_tracker_auto_continue(
true,
false,
false,
Some("preview budget exhausted"),
false,
Some(&incomplete),
8,
false,
false
));
assert!(should_queue_tracker_auto_continue(
true,
false,
false,
Some(
"Turn ended with a recovery fallback; the requested work was not confirmed. The current plan and task state were retained."
),
false,
Some(&incomplete),
8,
false,
false
));
assert!(should_queue_tracker_auto_continue(
true,
false,
false,
Some(
"Turn blocked after repeated assistant responses reached the safety cap; the latest response was preserved."
),
false,
Some(&incomplete),
8,
false,
false
));
assert!(!should_queue_tracker_auto_continue(
true,
false,
false,
Some("permission denied"),
false,
Some(&incomplete),
8,
false,
false
));
assert!(!should_queue_tracker_auto_continue(
true,
false,
false,
Some("unknown block reason"),
false,
Some(&incomplete),
8,
false,
false
));
}
#[test]
fn resume_gate_honors_zero_cross_turn_budget() {
let incomplete = ["#1 analyze (in_progress)".to_string()];
assert!(should_queue_tracker_resume_continuation(true, 8, Some(&incomplete)));
assert!(!should_queue_tracker_resume_continuation(true, 0, Some(&incomplete)));
assert!(!should_queue_tracker_resume_continuation(false, 8, Some(&incomplete)));
assert!(!should_queue_tracker_resume_continuation(true, 8, None));
}
#[test]
fn tracker_config_defaults() {
assert!(tracker_auto_continue_enabled(None));
assert_eq!(tracker_cross_turn_turns(None), 32);
}
#[test]
fn recoverable_block_includes_tool_budget_and_plan_fallback_shapes() {
// Production TOOL_LOOP_LIMIT_RECOVERY_REASON.
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Tool loop budget exhausted before a final response. Tools are disabled for one bounded synthesis pass."
)));
// Tool-call budget / follow-up recovery constants.
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Tool follow-up failed. Tools disabled; respond with text using context and recent tool outputs."
)));
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Turn ended without a harness-visible final assistant response, so successful completion could not be confirmed."
)));
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Approved-plan execution stopped after recovery was exhausted. The approved plan and task checklist were retained."
)));
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Per-turn tool limit reached (max: 32). Wait or adjust config."
)));
// Planning fallback contains "recovery fallback" (recoverable token);
// outer tracker queue is separately gated by planning_active.
assert!(tracker_auto_continue_is_recoverable_block(Some(
"Planning turn ended via recovery fallback without confirming an approval-ready plan; planning remains active."
)));
}
#[test]
fn plan_mode_recoverable_allows_planning_fallback_constant() {
// PLANNING_COMPLETED_TURN_FALLBACK_REASON must auto-queue (allow-list first).
assert!(plan_mode_recoverable_block(
"Planning turn ended via recovery fallback without confirming an approval-ready plan; planning remains active. The current plan and task state were retained."
));
assert!(plan_mode_recoverable_block(
"Turn ended with a recovery fallback; the requested work was not confirmed."
));
// Interview / approval / permission handoffs stay denied.
assert!(!plan_mode_recoverable_block("request_user_input is required for the planning interview"));
assert!(!plan_mode_recoverable_block("permission denied for exec_command"));
assert!(!plan_mode_recoverable_block(
"Recovery mode requested a final tool-free synthesis pass, but the model attempted more tool calls."
));
assert!(!plan_mode_recoverable_block("recovery fallback; interview waiting on user decision"));
}
#[test]
fn session_stats_progress_resets_tracker_budget() {
let mut stats = crate::agent::runloop::unified::state::SessionStats::default();
assert!(stats.record_tracker_continuation_turn_with_limit(32));
assert!(stats.record_tracker_continuation_turn_with_limit(32));
assert_eq!(stats.tracker_continuation_turns(), 2);
// No first observation yet — initial 0 → 0 is not progress.
assert!(!stats.note_tracker_completed_count(0));
assert_eq!(stats.tracker_continuation_turns(), 2);
// Progress → reset episode budget.
assert!(stats.note_tracker_completed_count(1));
stats.reset_tracker_continuation_budget();
assert_eq!(stats.tracker_continuation_turns(), 0);
// Same count is not further progress.
assert!(!stats.note_tracker_completed_count(1));
// Tracker recreate with a lower completed count still resets the episode.
assert!(stats.record_tracker_continuation_turn_with_limit(32));
assert!(stats.note_tracker_completed_count(0));
stats.reset_tracker_continuation_budget();
assert_eq!(stats.tracker_continuation_turns(), 0);
}
#[test]
fn tracker_probe_cache_clears_on_complete_and_keeps_on_unavailable() {
let mut cache: Option<Vec<String>> = Some(vec!["#1 a (pending)".to_string()]);
// Complete is an authoritative clear.
let effective = apply_tracker_probe_to_cache(&mut cache, TrackerProbeOutcome::Complete);
assert!(effective.is_none());
assert!(cache.is_none());
// Incomplete replaces the cache.
let effective = apply_tracker_probe_to_cache(
&mut cache,
TrackerProbeOutcome::Incomplete(vec!["#2 b (in_progress)".to_string()]),
);
assert_eq!(effective, Some(["#2 b (in_progress)".to_string()].as_slice()));
// Unavailable keeps the last incomplete set.
let effective = apply_tracker_probe_to_cache(&mut cache, TrackerProbeOutcome::Unavailable);
assert_eq!(effective, Some(["#2 b (in_progress)".to_string()].as_slice()));
// Parse distinguishes complete vs incomplete vs unavailable shapes.
let complete = serde_json::json!({
"status": "ok",
"checklist": {"items": [{"index": 1, "description": "a", "status": "completed"}]}
});
assert_eq!(tracker_probe_outcome(&complete), TrackerProbeOutcome::Complete);
assert!(parse_incomplete_tracker_items(&complete).is_none());
let incomplete = serde_json::json!({
"status": "ok",
"checklist": {"items": [{"index": 1, "description": "a", "status": "pending"}]}
});
assert!(matches!(tracker_probe_outcome(&incomplete), TrackerProbeOutcome::Incomplete(_)));
assert_eq!(tracker_probe_outcome(&serde_json::json!({"status": "empty"})), TrackerProbeOutcome::Complete);
assert_eq!(tracker_probe_outcome(&serde_json::json!({"no_status": true})), TrackerProbeOutcome::Unavailable);
// Checklist without items is malformed → Unavailable (keep cache), not Complete.
assert_eq!(
tracker_probe_outcome(&serde_json::json!({"status": "ok", "checklist": {}})),
TrackerProbeOutcome::Unavailable
);
}
#[test]
fn session_stats_apply_tracker_probe_clears_stale_incomplete() {
let mut stats = crate::agent::runloop::unified::state::SessionStats::default();
let after_incomplete =
stats.apply_tracker_probe(TrackerProbeOutcome::Incomplete(vec!["#1 a (pending)".to_string()]));
assert_eq!(after_incomplete, Some(["#1 a (pending)".to_string()].as_slice()));
// Successful complete must clear — do not auto-continue after tracker finishes.
assert!(stats.apply_tracker_probe(TrackerProbeOutcome::Complete).is_none());
// Unavailable after a new incomplete keeps that incomplete set.
stats.apply_tracker_probe(TrackerProbeOutcome::Incomplete(vec!["#2 b (pending)".to_string()]));
assert_eq!(
stats.apply_tracker_probe(TrackerProbeOutcome::Unavailable),
Some(["#2 b (pending)".to_string()].as_slice())
);
}
#[test]
fn parse_tracker_completed_count_from_list_payload() {
let payload = serde_json::json!({
"status": "ok",
"checklist": {
"items": [
{"index": 1, "description": "a", "status": "completed"},
{"index": 2, "description": "b", "status": "in_progress"},
{"index": 3, "description": "c", "status": "pending"},
]
}
});
assert_eq!(parse_tracker_completed_count(&payload), 1);
assert_eq!(parse_incomplete_tracker_items(&payload).map(|items| items.len()), Some(2));
let empty = serde_json::json!({"status": "empty"});
assert_eq!(parse_tracker_completed_count(&empty), 0);
}
#[test]
fn session_stats_resets_tracker_budget_with_verification_episode() {
let mut stats = crate::agent::runloop::unified::state::SessionStats::default();
assert!(stats.record_tracker_continuation_turn_with_limit(8));
assert!(stats.record_tracker_continuation_turn_with_limit(8));
assert_eq!(stats.tracker_continuation_turns(), 2);
// Verification-episode reset must NOT wipe tracker/plan continuation budgets.
stats.reset_verification_recovery_episode();
assert_eq!(stats.tracker_continuation_turns(), 2);
assert!(stats.record_plan_continuation_turn_with_limit(1));
assert!(!stats.record_plan_continuation_turn_with_limit(1));
stats.reset_tracker_continuation_budget();
assert_eq!(stats.tracker_continuation_turns(), 0);
assert_eq!(stats.plan_continuation_turns(), 1);
stats.reset_plan_continuation_budget();
assert_eq!(stats.plan_continuation_turns(), 0);
}
}
/// Whether the harness may execute the project verifier itself when the model
/// exhausts its directive retries. Kill-switch:
/// `[agent.harness.verification].auto_execute = false` restores
/// directive-only recovery.
pub(crate) fn verification_auto_execute_enabled(vt_cfg: Option<&vtcode_core::config::loader::VTCodeConfig>) -> bool {
vt_cfg.map(|cfg| cfg.agent.harness.verification.auto_execute).unwrap_or(true)
}
/// Effective consecutive-failure escalation threshold, honoring
/// `[agent.harness.verification].max_consecutive_failures`.
pub(crate) fn verification_max_consecutive_failures(vt_cfg: Option<&vtcode_core::config::loader::VTCodeConfig>) -> u8 {
vt_cfg
.map(|cfg| cfg.agent.harness.verification.max_consecutive_failures)
.unwrap_or(MAX_VERIFICATION_CONSECUTIVE_FAILURES)
}
/// Resolve the verifier command the harness should run itself: the explicit
/// `[agent.harness.verification].default_verifier_override` first (validated
/// as a standalone verifier or pure `&&` chain — anything else falls back to
/// detection so a misconfigured override can never smuggle a mutation or a
/// status-masking pipeline into autonomous execution), then
/// [`vtcode_core::tools::tool_intent::default_verifier_for_workspace`].
/// Returns `None` when neither yields a runnable verifier; callers must then
/// fall through to the manual blocked handoff.
pub(crate) fn resolve_harness_verifier_command(
vt_cfg: Option<&vtcode_core::config::loader::VTCodeConfig>,
workspace_root: &Path,
) -> Option<String> {
if let Some(override_command) = vt_cfg
.and_then(|cfg| cfg.agent.harness.verification.default_verifier_override.as_deref())
.map(str::trim)
.filter(|command| !command.is_empty())
{
let args = serde_json::json!({"cmd": override_command});
if matches!(
classify_shell_activity(vtcode_core::config::constants::tools::EXEC_COMMAND, &args),
ShellActivity::Verification
) {
return Some(override_command.to_string());
}
tracing::warn!(
override_command,
"Ignoring [agent.harness.verification].default_verifier_override: not a standalone verifier or pure && chain; falling back to workspace detection"
);
}
vtcode_core::tools::tool_intent::default_verifier_for_workspace(workspace_root)
}
/// Threshold: number of consecutive read/search operations before the Navigation
/// Loop warning fires.
pub(crate) const NAVIGATION_LOOP_THRESHOLD: usize = 15;
/// Trip count for same-binary, same-root directory listings (`ls`/`find`/`fd`)
/// before the turn balancer schedules recovery. Each binary+root pair keeps
/// its own coarse family (`exec::inspection::<base>::<root>`) across argument
/// variations, so rescanning one target three times signals churn even when
/// no exact request repeats, while scans of distinct trees stay below the
/// tripwire (legitimate exploration).
pub(crate) const LISTING_LOOP_TRIP_COUNT: usize = 3;
/// Planning listing tripwire: planning owns dedicated convergence guards (6
/// consecutive / 10 total low-signal), so three
/// successful listings are legitimate exploration there rather than churn.
pub(crate) const PLANNING_LISTING_LOOP_TRIP_COUNT: usize = 5;
/// Planning recovery thresholds for low-signal navigation. These are kept
/// below the hard planning tool-call ceiling so the model gets one bounded,
/// tool-free synthesis pass while the evidence is still useful.
pub(crate) const PLANNING_CONSECUTIVE_LOW_SIGNAL_THRESHOLD: u8 = 6;
pub(crate) const PLANNING_TOTAL_LOW_SIGNAL_THRESHOLD: u8 = 10;
/// Execution-mode total low-signal guard. Planning converges via its adaptive
/// thresholds (6 consecutive / 10 total); execution mode previously converged
/// only through per-family repeats, the 15-step navigation loop, or the final
/// balancer window, so diverse churn (a new query each time) ran until the
/// turn budget. The counter's window resets on any mutation or verification,
/// so this fires only for churn uninterrupted by productive work.
pub(crate) const EXECUTION_TOTAL_LOW_SIGNAL_THRESHOLD: u8 = 12;
/// Optimized loop detection with bounded signature keys and exponential backoff.
pub(crate) struct LoopTracker {
attempts: FxHashMap<String, (usize, Instant)>,
low_signal_attempts: FxHashMap<String, (usize, Instant)>,
coarse_inspection_attempts: FxHashMap<String, (usize, Instant)>,
/// Counter for consecutive mutating file operations without execution/verification
pub consecutive_mutations: usize,
/// True after the mutation threshold until a verification command completes.
pub verification_pending: bool,
/// Bounded fix-up edits allowed while verification stays pending.
/// Set to [`FAILED_VERIFICATION_FIX_ALLOWANCE`] after a failed verifier so
/// a broken build can be repaired; consumed by successful fix-up mutations.
/// Persisted in `SessionStats` so `continue` turns keep the same window.
pub fix_edits_remaining: u8,
/// Prevent repeated warning output while verification remains pending.
pub verification_warning_emitted: bool,
/// Prevent repeated inline block notices for a single verification checkpoint.
pub verification_block_notice_emitted: bool,
/// Set when a pending gate's verifier result was lost (verifier-level
/// Failure/Timeout, or a lost exec session). Consumed once by the
/// tool-outcome handlers so the lost-result directive is surfaced after
/// the tool response lands.
pub verification_result_lost_notice_pending: bool,
/// Set when an admitted piped verifier (e.g. `cargo check 2>&1 | tail -5`)
/// succeeded while the gate was pending. A pipeline's exit status cannot
/// clear the gate, and without feedback the piped success reads as
/// "verified" to the model. Consumed once by the tool-outcome handlers;
/// never persisted in [`Self::verification_snapshot`] because it is
/// turn-scoped coaching, not gate state.
pub piped_verification_notice_pending: bool,
/// Bounded in-turn autonomous recovery attempts consumed when the model
/// emits text instead of a verifier while the gate is pending. Turn-scoped
/// (reset each turn, cleared on verification success); never persisted in
/// [`Self::verification_snapshot`] because it is retry budget, not gate
/// state. Survives [`Self::reset_after_balancer_recovery`] like the other
/// verification fields — navigation recovery is not verification.
pub verification_auto_recovery_attempts: u8,
/// Whether the harness has already executed the project verifier itself
/// this turn (one shot per turn). Set when the auto-execute path fires,
/// regardless of outcome, so a failing verifier cannot trigger an
/// unbounded execute→text→execute cycle inside one turn. Cleared on
/// verification success with the rest of the gate; never persisted.
pub auto_verification_executed: bool,
/// Counter for consecutive read/search operations without action or synthesis
pub consecutive_navigations: usize,
/// Number of times navigation-loop recovery has fired in this session.
pub navigation_loop_recoveries: usize,
/// Consecutive low-signal navigation outcomes in this turn.
pub consecutive_low_signal_navigations: u8,
/// Total low-signal navigation outcomes in this turn.
pub total_low_signal_navigations: u8,
/// Lifetime low-signal outcomes for checkpoint diagnostics. Unlike the
/// adaptive window counters, this never resets within the turn.
pub low_signal_tool_calls: u32,
/// At most one adaptive planning synthesis pass is scheduled per turn.
pub planning_low_signal_synthesis_triggered: bool,
/// At most one execution-mode total low-signal synthesis pass per turn.
/// Like the planning latch, this survives [`Self::reset_after_balancer_recovery`].
pub execution_total_low_signal_triggered: bool,
/// Unique normalized navigation signatures in the current consecutive
/// window. Non-semantic output controls (for example, a preview budget)
/// must not make the same inspection look like a new request.
nav_signatures: FxHashSet<String>,
}
impl LoopTracker {
pub(crate) fn new() -> Self {
Self {
attempts: FxHashMap::with_capacity_and_hasher(16, Default::default()),
low_signal_attempts: FxHashMap::with_capacity_and_hasher(8, Default::default()),
coarse_inspection_attempts: FxHashMap::with_capacity_and_hasher(8, Default::default()),
consecutive_mutations: 0,
verification_pending: false,
fix_edits_remaining: 0,
verification_warning_emitted: false,
verification_block_notice_emitted: false,
verification_result_lost_notice_pending: false,
piped_verification_notice_pending: false,
verification_auto_recovery_attempts: 0,
auto_verification_executed: false,
consecutive_navigations: 0,
navigation_loop_recoveries: 0,
consecutive_low_signal_navigations: 0,
total_low_signal_navigations: 0,
low_signal_tool_calls: 0,
planning_low_signal_synthesis_triggered: false,
execution_total_low_signal_triggered: false,
nav_signatures: FxHashSet::default(),
}
}
/// Tuple counterpart to `SessionStats::verification_snapshot`, so turn
/// setup and persistence share one call shape instead of threading two
/// loosely-coupled halves across five call sites. A zero-pending snapshot
/// never carries fix-ups; the clamp keeps a stale caller from building an
/// inconsistent gate.
pub(crate) fn with_verification_snapshot(snapshot: (bool, u8)) -> Self {
let mut tracker = Self::new();
tracker.verification_pending = snapshot.0;
tracker.fix_edits_remaining = if snapshot.0 { snapshot.1 } else { 0 };
tracker
}
/// Record an attempt and return the count
pub(crate) fn record(&mut self, signature: String) -> usize {
let entry = self.attempts.entry(signature).or_insert((0, Instant::now()));
entry.0 += 1;
entry.1 = Instant::now();
entry.0
}
fn record_low_signal(&mut self, signature: String) -> usize {
let entry = self.low_signal_attempts.entry(signature).or_insert((0, Instant::now()));
entry.0 += 1;
entry.1 = Instant::now();
entry.0
}
/// Get the maximum repetition count, optionally filtering by a predicate on the signature
pub(crate) fn max_count_filtered<F>(&self, exclude: F) -> usize
where
F: Fn(&str) -> bool,
{
self.attempts
.iter()
.filter_map(|(sig, (count, _))| if exclude(sig) { None } else { Some(*count) })
.max()
.unwrap_or(0)
}
pub(crate) fn max_low_signal_count(&self) -> usize {
self.low_signal_attempts.values().map(|(count, _)| *count).max().unwrap_or(0)
}
/// Highest repeat count among coarse directory-listing families
/// (`exec::inspection::<base>::<root>` with base `ls`/`find`/`fd`), taking
/// the max across binary+root pairs. Repeated bare listings of the same
/// target carry no new semantic question (unlike distinct `rg`/`grep`
/// queries or scans of different trees), so one tool rescanning a root
/// three times counts as loop churn even when every command string
/// differs. Mixed targets (`ls src` + `ls crates` + `ls tests`) stay
/// below the trip count: each root is tracked in its own family.
pub(crate) fn max_coarse_listing_count(&self) -> usize {
self.coarse_inspection_attempts
.iter()
.filter_map(|(family, (count, _))| {
// Family shape: exec::inspection::<base>::<root>
family
.split("::")
.nth(2)
.is_some_and(|base| matches!(base, "ls" | "find" | "fd"))
.then_some(*count)
})
.max()
.unwrap_or(0)
}
/// Dominant churn signature for recovery-reason annotations: the highest
/// repeat count across the low-signal ledger and the coarse listing
/// ledger, preferring whichever is larger so listing-triggered recovery
/// still names the looped family even though the low-signal promotion
/// only records the final repeat. Returns owned data so callers can keep
/// the annotation alive while mutating the tracker.
pub(crate) fn dominant_churn(&self) -> Option<(String, usize)> {
let low_signal = self
.low_signal_attempts
.iter()
.max_by_key(|(_, (count, _))| *count)
.map(|(family, (count, _))| (family.clone(), *count));
let coarse = self
.coarse_inspection_attempts
.iter()
.max_by_key(|(_, (count, _))| *count)
.map(|(family, (count, _))| (family.clone(), *count));
match (low_signal, coarse) {
(Some(low), Some(coarse)) if coarse.1 > low.1 => Some(coarse),
(Some(low), _) => Some(low),
(None, Some(coarse)) => Some(coarse),
(None, None) => None,
}
}
/// Number of redundant navigations (total - unique) in the current window.
/// The navigation-loop guard requires at least 3 redundant requests.
pub(crate) fn repeated_navigation_count(&self) -> usize {
self.consecutive_navigations.saturating_sub(self.nav_signatures.len())
}
fn reset_low_signal_attempts(&mut self) {
self.low_signal_attempts.clear();
self.coarse_inspection_attempts.clear();
}
fn reset_low_signal_navigation_counters(&mut self) {
self.consecutive_low_signal_navigations = 0;
self.total_low_signal_navigations = 0;
}
/// Clear the per-turn navigation window after a non-navigation tool.
/// Callers pass `low_signal_family.is_none()` so diverse productive reads
/// keep their repetition history while low-signal churn resets.
fn reset_navigation_window(&mut self, clear_low_signal_attempts: bool) {
self.consecutive_navigations = 0;
self.nav_signatures.clear();
self.reset_low_signal_navigation_counters();
if clear_low_signal_attempts {
self.reset_low_signal_attempts();
}
}
fn record_navigation_signal(&mut self, is_low_signal: bool) {
if is_low_signal {
self.low_signal_tool_calls = self.low_signal_tool_calls.saturating_add(1);
self.consecutive_low_signal_navigations = self.consecutive_low_signal_navigations.saturating_add(1);
self.total_low_signal_navigations = self.total_low_signal_navigations.saturating_add(1);
} else {
// Productive inspection breaks only the consecutive streak. The
// total remains turn-scoped so diverse empty searches still
// converge on synthesis.
self.consecutive_low_signal_navigations = 0;
}
}
pub(crate) fn reset_after_balancer_recovery(&mut self) {
self.attempts.clear();
self.reset_low_signal_attempts();
self.nav_signatures.clear();
// Navigation recovery is not verification. Preserve mutation pressure,
// an active verification checkpoint, its bounded fix window, the
// in-turn auto-recovery budget, the harness-executed once-flag, and
// the associated one-shot notices. Only a successful standalone
// verifier may clear those fields.
self.consecutive_navigations = 0;
self.reset_low_signal_navigation_counters();
}
pub(crate) fn verification_is_pending(&self) -> bool {
self.verification_pending || self.consecutive_mutations >= BLIND_EDITING_THRESHOLD
}
/// Snapshot the session-persisted gate state for `SessionStats`.
/// Persist both halves together so resumed turns reconstruct the same
/// gate instead of drifting (a pending gate with a lost fix window
/// deadlocks a broken build).
pub(crate) fn verification_snapshot(&self) -> (bool, u8) {
(self.verification_is_pending(), self.fix_edits_remaining)
}
pub(crate) fn mark_verification_pending(&mut self) {
self.verification_pending = true;
}
/// One-shot accessor for the lost-verification-result notice queued by
/// [`update_repetition_tracker`]. Handlers consume it after the tool
/// response lands so the directive never splits an assistant batch.
pub(crate) fn take_verification_result_lost_notice(&mut self) -> bool {
std::mem::take(&mut self.verification_result_lost_notice_pending)
}
/// One-shot accessor for the piped-verifier notice queued by
/// [`update_repetition_tracker`]. Handlers consume it after the tool
/// response lands so the directive never splits an assistant batch.
pub(crate) fn take_piped_verification_notice(&mut self) -> bool {
std::mem::take(&mut self.piped_verification_notice_pending)
}
/// Grant a bounded fix-up window after a failed verifier. The gate stays
/// pending (completion still requires a successful standalone verifier),
/// but the next [`FAILED_VERIFICATION_FIX_ALLOWANCE`] successful mutations
/// are admitted so a broken build can be repaired instead of deadlocking.
pub(crate) fn record_failed_verification(&mut self) {
self.verification_pending = true;
self.fix_edits_remaining = FAILED_VERIFICATION_FIX_ALLOWANCE;
}
fn record_successful_mutation(&mut self) {
// Consume the fix-up window first: repair edits must not grow the
// blind-editing counter while the gate already requires re-verify.
if self.verification_pending && self.fix_edits_remaining > 0 {
self.fix_edits_remaining = self.fix_edits_remaining.saturating_sub(1);
return;
}
self.consecutive_mutations = self.consecutive_mutations.saturating_add(1);
if self.consecutive_mutations >= BLIND_EDITING_THRESHOLD {
self.verification_pending = true;
}
}
/// Config-aware attempt recorder; prefer it at call sites with workspace
/// config access so `[agent.harness.verification].in_turn_attempts` is
/// honored. Returns `true` when budget remained (the caller should reset
/// the text-response streak, inject the project-aware recovery directive,
/// and `Continue` the turn instead of `Block`ing); `false` once exhausted.
pub(crate) fn record_verification_auto_recovery_with_limit(&mut self, max_attempts: u8) -> bool {
if self.verification_auto_recovery_attempts >= max_attempts {
return false;
}
self.verification_auto_recovery_attempts = self.verification_auto_recovery_attempts.saturating_add(1);
true
}
pub(crate) fn verification_auto_recovery_attempts(&self) -> u8 {
self.verification_auto_recovery_attempts
}
/// Whether the harness may execute the project verifier itself this turn.
/// One shot per turn: once fired (any outcome), further text responses
/// consume only directive retries, then the turn blocks for cross-turn
/// recovery. Never true when the gate is clear.
pub(crate) fn should_auto_execute_verifier(&self) -> bool {
self.verification_is_pending() && !self.auto_verification_executed
}
/// Record that the harness executed the project verifier itself this
/// turn. Unconditional: every outcome (success, failure, lost result)
/// flows through the normal tracker paths, which clear or preserve the
/// gate; the flag only prevents a second harness execution this turn.
pub(crate) fn record_auto_verification_executed(&mut self) {
self.auto_verification_executed = true;
}
fn mark_verification_complete(&mut self) {
self.consecutive_mutations = 0;
self.verification_pending = false;
self.fix_edits_remaining = 0;
self.verification_warning_emitted = false;
self.verification_block_notice_emitted = false;
self.verification_result_lost_notice_pending = false;
self.piped_verification_notice_pending = false;
self.verification_auto_recovery_attempts = 0;
self.auto_verification_executed = false;
}
}
/// Check if an identical tool call (same name + same args) was already executed
/// recently in the working history. Returns the output of the most recent
/// matching tool response if found.
///
/// This catches cross-turn duplicates that the per-turn `LoopTracker` misses
/// because it is reset at the start of each turn. Scans the last
/// `MAX_HISTORY_SCAN` messages to keep the check bounded.
///
/// File-read pagination is normalised so that re-reading the same file with a
/// different `offset` or `limit` is recognised as the same logical read.
/// `code_search` uses a separate replay identity that retains the effective
/// `max_results`; its loop identity is separate.
///
/// Tool-call IDs are scoped to the nearest preceding Assistant batch. A later
/// batch may reuse an ID for another tool, so both the batch and tool name must
/// match before its Tool response can satisfy this replay lookup.
pub(crate) fn find_duplicate_in_history(
history: &[uni::Message],
tool_name: &str,
args: &serde_json::Value,
workspace_root: &Path,
) -> Option<String> {
const MAX_HISTORY_SCAN: usize = 120;
let target_signature = read_normalized_signature_key(tool_name, args);
let scan_start = history.len().saturating_sub(MAX_HISTORY_SCAN);
let target_tool_name = canonical_tool_name(tool_name);
let mut current_batch: FxHashMap<String, (String, serde_json::Value)> = FxHashMap::default();
let mut matching_responses = Vec::new();
for (offset, msg) in history[scan_start..].iter().enumerate() {
let abs_idx = scan_start + offset;
match msg.role {
uni::MessageRole::Assistant => {
current_batch.clear();
if let Some(ref tool_calls) = msg.tool_calls {
for tc in tool_calls {
if let Some(ref func) = tc.function {
let tc_args: serde_json::Value = serde_json::from_str(&func.arguments)
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
current_batch.insert(tc.id.clone(), (canonical_tool_name(&func.name).to_string(), tc_args));
}
}
}
}
uni::MessageRole::Tool => {
let Some(call_id) = msg.tool_call_id.as_deref() else {
continue;
};
let Some((batch_tool_name, tc_args)) = current_batch.get(call_id) else {
continue;
};
if batch_tool_name == target_tool_name
&& read_normalized_signature_key(batch_tool_name, tc_args) == target_signature
&& read_extent::extent_covers(tc_args, args)
&& tool_response_is_replayable(msg)
{
matching_responses.push((abs_idx, tc_args.clone(), msg));
}
}
_ => {}
}
}
for (response_index, tc_args, msg) in matching_responses.into_iter().rev() {
let invalidated = tool_name == vtcode_core::config::constants::tools::CODE_SEARCH
&& history_has_scoped_mutation_after(history, response_index, &tc_args, workspace_root);
if !invalidated {
return Some(msg.content.as_text().to_string());
}
}
None
}
fn tool_response_is_replayable(message: &uni::Message) -> bool {
let content = message.content.as_text();
let trimmed = content.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.len() > 128 * 1024 {
return false;
}
match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(serde_json::Value::Object(output)) => {
if output.contains_key("error") || output.contains_key("error_type") || output.contains_key("failure_kind")
{
return false;
}
if output.get("blocked").and_then(serde_json::Value::as_bool) == Some(true)
|| output.get("verification_required").and_then(serde_json::Value::as_bool) == Some(true)
{
return false;
}
if matches!(output.get("success"), Some(serde_json::Value::Bool(false)) | Some(serde_json::Value::Null)) {
return false;
}
if output.get("success").is_some_and(|value| !value.is_boolean()) {
return false;
}
!output.get("status").and_then(serde_json::Value::as_str).is_some_and(|status| {
matches!(
status.to_ascii_lowercase().as_str(),
"failed"
| "failure"
| "error"
| "denied"
| "permission_denied"
| "rejected"
| "timeout"
| "timed_out"
| "cancelled"
| "canceled"
| "interrupted"
| "aborted"
| "blocked"
| "skipped"
| "not_started"
| "not_executed"
| "pending"
| "in_progress"
| "not_run"
)
})
}
Ok(serde_json::Value::String(value)) => text_response_is_replayable(&value),
Ok(serde_json::Value::Array(_) | serde_json::Value::Number(_) | serde_json::Value::Bool(_)) => true,
Ok(serde_json::Value::Null) => false,
Err(_) => text_response_is_replayable(trimmed),
}
}
fn text_response_is_replayable(content: &str) -> bool {
let trimmed = content.trim();
const FAILURE_PREFIXES: &[&str] = &[
"error:",
"execution denied",
"permission denied",
"timeout",
"timed out",
"cancelled",
"canceled",
"failed",
"failure",
"denied",
"rejected",
"blocked",
"aborted",
"interrupted",
"skipped",
"not started",
"not executed",
"not run",
"pending",
"in progress",
];
!FAILURE_PREFIXES.iter().any(|prefix| {
trimmed
.get(..prefix.len())
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
})
}
fn history_has_scoped_mutation_after(
history: &[uni::Message],
response_index: usize,
search_args: &serde_json::Value,
workspace_root: &Path,
) -> bool {
let mut pending_mutations: FxHashMap<String, Vec<PathBuf>> = FxHashMap::default();
for message in history.iter().skip(response_index.saturating_add(1)) {
match message.role {
uni::MessageRole::Assistant => {
// Tool-call IDs are scoped to one Assistant batch and may be
// reused later. Unanswered calls from an earlier batch were
// never executed, so they must not survive this boundary.
pending_mutations.clear();
let Some(tool_calls) = message.tool_calls.as_ref() else {
continue;
};
for tool_call in tool_calls {
let Some(function) = tool_call.function.as_ref() else {
continue;
};
let Ok(args) = serde_json::from_str::<serde_json::Value>(&function.arguments) else {
continue;
};
if !vtcode_core::tools::tool_intent::classify_tool_intent(&function.name, &args).mutating {
continue;
}
let paths = vtcode_core::tools::mutation_target_paths(&function.name, &args);
if !paths.is_empty() {
pending_mutations.insert(tool_call.id.clone(), paths);
}
}
}
uni::MessageRole::Tool => {
let Some(call_id) = message.tool_call_id.as_deref() else {
continue;
};
let Some(paths) = pending_mutations.remove(call_id) else {
continue;
};
if tool_response_is_success(message)
&& paths.iter().any(|path| {
vtcode_core::tools::code_search_scope_contains_mutated_path(search_args, path, workspace_root)
})
{
return true;
}
}
_ => {}
}
}
false
}
fn tool_response_is_success(message: &uni::Message) -> bool {
let Ok(output) = serde_json::from_str::<serde_json::Value>(&message.content.as_text()) else {
return false;
};
let Some(output) = output.as_object() else {
return false;
};
if output.contains_key("error") || output.contains_key("error_type") || output.contains_key("failure_kind") {
return false;
}
if output.get("status").is_some_and(|status| status.as_str() != Some("success")) {
return false;
}
match output.get("success") {
Some(serde_json::Value::Bool(success)) => *success,
Some(_) => false,
None => output
.get("status")
.and_then(serde_json::Value::as_str)
.is_some_and(|status| status == "success"),
}
}
fn output_has_empty_search_results(output: &serde_json::Value) -> bool {
output
.get("results")
.and_then(serde_json::Value::as_array)
.is_some_and(|results| results.is_empty())
&& !output_has_actionable_recovery_guidance(output)
&& !output_has_error_signal(output)
}
fn output_has_actionable_recovery_guidance(output: &serde_json::Value) -> bool {
["hint", "next_action", "critical_note", "warning"].iter().any(|key| {
output
.get(*key)
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
}) || output
.get("fallback_tool")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| !value.trim().is_empty())
|| output.get("hints").and_then(serde_json::Value::as_array).is_some_and(|hints| {
hints
.iter()
.any(|hint| hint.as_str().is_some_and(|value| !value.trim().is_empty()))
})
}
fn output_has_error_signal(output: &serde_json::Value) -> bool {
["error", "error_type", "stderr", "stderr_preview", "message"]
.iter()
.any(|key| !output_field_is_empty(output.get(*key)))
}
fn output_reuses_recent_result(output: &serde_json::Value) -> bool {
[
"loop_detected",
"reused_recent_result",
"spool_ref_only",
"result_ref_only",
]
.iter()
.any(|key| output.get(*key).and_then(serde_json::Value::as_bool) == Some(true))
}
fn error_is_missing_resource(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
[
"not found",
"no such file",
"resource not found",
"spool file not found",
"session output file not found",
]
.iter()
.any(|needle| lower.contains(needle))
}
/// Detect the session-loss error text emitted by the exec session manager
/// ("exec session '<id>' not found. ...") and the PTY session manager
/// ("PTY session '<id>' not found"), mirroring the phrasing in `vtcode-core`
/// exec_session and pty session_ops tool errors.
fn error_text_indicates_lost_session(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
lower.contains("not found") && (lower.contains("exec session") || lower.contains("pty session"))
}
/// Return whether `(canonical_name, args)` is a follow-up on an existing
/// exec/PTY session rather than a fresh command run.
///
/// `canonical_name` must already be canonicalized (see [`canonical_tool_name`]).
/// Any non-`run` `unified_exec` action counts as a follow-up
/// (poll/wait/inspect/continue/input, plus list/code/write/close), as does
/// any non-`run` PTY session tool (`read_pty_session`, `send_pty_input`,
/// `close_pty_session`, `list_pty_sessions`, `exec_pty_cmd`): session
/// follow-ups carry a `session_id`, not command text, so they never classify
/// as [`ShellActivity::Verification`]. A missing-session failure on one of
/// them therefore needs its own lost-result branch in
/// [`update_repetition_tracker`]: the verifier output it was waiting on died
/// with the session. Fresh `run` calls are excluded: a run creates its
/// session, so it cannot lose a prior verifier's result.
fn is_session_follow_up(canonical_name: &str, args: &serde_json::Value) -> bool {
use vtcode_core::config::constants::tools;
if canonical_name == tools::WRITE_STDIN {
return true;
}
if matches!(
canonical_name,
tools::UNIFIED_EXEC
| tools::READ_PTY_SESSION
| tools::SEND_PTY_INPUT
| tools::CLOSE_PTY_SESSION
| tools::LIST_PTY_SESSIONS
| tools::EXEC_PTY_CMD
) && !vtcode_core::tools::tool_intent::is_command_run_tool_call(canonical_name, args)
{
return true;
}
false
}
fn is_low_signal_outcome(outcome: &ToolPipelineOutcome, canonical_tool_name: &str, args: &serde_json::Value) -> bool {
match &outcome.status {
ToolExecutionStatus::Success { output, command_success, .. } => {
output_has_empty_search_results(output)
|| output_reuses_recent_result(output)
|| (matches!(
canonical_tool_name,
vtcode_core::config::constants::tools::UNIFIED_EXEC
| vtcode_core::config::constants::tools::EXEC_COMMAND
) && !*command_success
&& is_grep_style_no_match(canonical_tool_name, args, output))
}
ToolExecutionStatus::Failure { error } => error_is_missing_resource(&error.message),
ToolExecutionStatus::Timeout { .. } | ToolExecutionStatus::Cancelled => false,
}
}
/// Coarse inspection family for duplicate-listing detection. Unlike the exact
/// `low_signal_family_key` (full normalized command), this groups overlapping
/// scans such as three `find` invocations over the same tree with different
/// flags, so successful but redundant rescans of one target still count
/// toward diagnostics. The family is scoped by binary AND search root:
/// `ls src` / `find src …` / `ls -1 src` share a target and count together,
/// while scans of distinct trees (`ls src` / `ls crates` / `ls tests`) are
/// legitimate exploration and never group.
fn coarse_inspection_family_key(canonical_tool_name: &str, args: &serde_json::Value) -> Option<String> {
use vtcode_core::config::constants::tools;
// Only bare directory listings suffer from overlapping-but-distinct
// invocations (e.g. three `find` calls over the same tree with different
// flags) that the exact family key never groups. File reads (`cat`/`head`/
// `tail` via shell included) and semantic search (`rg`/`grep`, `code_search`)
// already carry precise family keys; grouping them coarsely would mislabel
// diverse productive exploration (different files/queries) as looping.
// In particular `rg`/`grep` must stay out: their first positional is the
// search pattern, not the search root, so five distinct queries such as
// `grep -n "enum Commands" ...`, `grep -rn "enum ExecSubcommand" ...`
// (turn_1303/turn_1304: `exec::inspection::grep::enum ×5`) or five `rg`
// searches for `pub` (turn_1291: `exec::inspection::rg::pub ×5`, e.g.
// `rg -n 'pub enum Commands' ...`) all collapse into
// one coarse family and get promoted to low-signal, tripping early
// recovery on legitimate research. Distinct patterns/paths keep distinct
// exact families and converge via the total low-signal guard instead.
match canonical_tool_name {
tools::UNIFIED_EXEC | tools::EXEC_COMMAND => {
let command = vtcode_core::tools::command_args::command_text(args).ok()??;
let first = command.split_whitespace().next().unwrap_or("");
let base = first
.rsplit('/')
.next()
.unwrap_or(first)
.trim_matches(|ch| ch == '\'' || ch == '"')
.to_ascii_lowercase();
if matches!(base.as_str(), "find" | "ls" | "fd") {
Some(format!("exec::inspection::{base}::{}", coarse_inspection_root(&command)))
} else {
None
}
}
_ => None,
}
}
/// Extract the search-root segment of a listing command: the first
/// non-flag argument, with surrounding quotes and trailing slashes stripped.
/// Deliberately heuristic — an option value (`ls --width 80 src` → root "80")
/// can be picked up and fragment a family, which only makes detection more
/// conservative. Commands with no positional argument (`ls -la`) scan the
/// working directory and map to ".". Only `ls`/`find`/`fd` reach this helper;
/// `rg`/`grep` are excluded above because their first positional is the
/// search pattern, not a path root.
fn coarse_inspection_root(command: &str) -> String {
let root = command
.split_whitespace()
.skip(1)
.find(|token| !token.starts_with('-'))
.unwrap_or(".");
let root = root.trim_matches(|ch| ch == '\'' || ch == '"').trim_end_matches('/');
if root.is_empty() { "." } else { root }.to_string()
}
/// Upsert a tool result into `history`, keyed on `tool_call_id`.
///
/// This is a **bounded** upsert: the reverse scan stops as soon as it reaches
/// ANY Assistant message (regardless of its tool_calls). This is critical:
/// Assistant messages represent turn boundaries. Tool responses from before an
/// Assistant must never be overwritten by Tool responses from after it, even
/// when fabricated tool_call_ids collide across turns.
///
/// If a Tool message with a matching id is found *before* the nearest
/// Assistant boundary, it is a legitimate same-call update (e.g. an
/// auto-permission probe replaying a result) and gets overwritten in place.
/// If the boundary is hit first, the id has been reused across turns, so we
/// append instead of clobbering an unrelated, earlier Tool result.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolResponseHistoryUpdate {
Appended,
Replaced { previous_text_len: usize },
}
pub(crate) fn push_tool_response<S>(
history: &mut Vec<uni::Message>,
tool_call_id: S,
tool_name: Option<&str>,
content: String,
) -> ToolResponseHistoryUpdate
where
S: AsRef<str> + Into<String>,
{
let tool_call_id_ref = tool_call_id.as_ref();
let mut overwrite_index = None;
for (index, message) in history.iter().enumerate().rev() {
match message.role {
uni::MessageRole::Tool => {
if message.tool_call_id.as_deref() == Some(tool_call_id_ref) {
overwrite_index = Some(index);
break;
}
}
// Stop at ANY Assistant message — it marks a turn boundary.
// Tool responses from before this Assistant must not be overwritten.
uni::MessageRole::Assistant => {
break;
}
_ => {}
}
}
if let Some(index) = overwrite_index {
let previous_text_len = history[index].content.as_text().len();
history[index].content = uni::MessageContent::Text(content);
if let Some(tool_name) = tool_name {
history[index].origin_tool = Some(tool_name.to_string());
}
return ToolResponseHistoryUpdate::Replaced { previous_text_len };
}
let tool_call_id = tool_call_id.into();
history.push(match tool_name {
Some(name) => uni::Message::tool_response_with_origin(tool_call_id, content, name.to_string()),
None => uni::Message::tool_response(tool_call_id, content),
});
ToolResponseHistoryUpdate::Appended
}
/// Generate a tool signature key with predictable structure for loop tracking.
pub(crate) fn signature_key_for(name: &str, args: &serde_json::Value) -> String {
// Keep keys compact on hot paths: hash bounded argument bytes instead of
// allocating full JSON payloads for large tool arguments.
let mut hash: u64 = 0xcbf29ce484222325;
let mut input_len = 0usize;
let mutability_tag = if vtcode_core::tools::tool_intent::classify_tool_intent(name, args).mutating {
"rw"
} else {
"ro"
};
if serde_json::to_writer(HashingWriter::new(&mut hash, &mut input_len), args).is_err() {
for byte in b"{}" {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
input_len = input_len.saturating_add(1);
}
}
format!("{name}:{mutability_tag}:len{input_len}-fnv{hash:016x}")
}
/// Generate a read-normalized signature key for cross-turn dedup.
///
/// File-read tools (`file_operation` with `read` action, `read_file`,
/// `grep_file`, `list_files`) omit pagination and read-offset fields so that
/// re-reading the same target groups under one logical read. `code_search`
/// uses its normalised result-replay identity, which preserves the effective
/// `max_results`; its separate loop identity may group searches across limits.
///
/// For mutating tools the original `signature_key_for` is returned unchanged.
pub(crate) fn read_normalized_signature_key(name: &str, args: &serde_json::Value) -> String {
if name == vtcode_core::config::constants::tools::CODE_SEARCH
&& let Some(identity) = vtcode_core::tools::normalised_code_search_identity(args)
{
return format!("{name}:ro:{identity}");
}
if !is_read_only_tool_args(name, args) {
return signature_key_for(name, args);
}
let Some(mut obj) = args.as_object().cloned() else {
return signature_key_for(name, args);
};
// Strip pagination / read-offset fields that don't change *what* is read.
for key in read_extent::normalization_strip_keys() {
obj.remove(key);
}
let normalized = serde_json::Value::Object(obj);
signature_key_for(name, &normalized)
}
/// Returns `true` when `(name, args)` describe a read-only tool invocation.
fn is_read_only_tool_args(name: &str, args: &serde_json::Value) -> bool {
use vtcode_core::config::constants::tools;
match name {
tools::READ_FILE | tools::GREP_FILE | tools::LIST_FILES => true,
tools::CODE_SEARCH => true,
tools::UNIFIED_SEARCH | "search_dispatch" => true,
tools::UNIFIED_FILE | "file_operation" => {
matches!(args.get("action").and_then(|v| v.as_str()), Some("read"))
}
_ => false,
}
}
struct HashingWriter<'a> {
hash: &'a mut u64,
input_len: &'a mut usize,
}
impl<'a> HashingWriter<'a> {
fn new(hash: &'a mut u64, input_len: &'a mut usize) -> Self {
Self { hash, input_len }
}
}
impl std::io::Write for HashingWriter<'_> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
for byte in buf {
*self.hash ^= u64::from(*byte);
*self.hash = self.hash.wrapping_mul(0x100000001b3);
*self.input_len = self.input_len.saturating_add(1);
}
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub(crate) fn resolve_max_tool_retries(
_tool_name: &str,
vt_cfg: Option<&vtcode_core::config::loader::VTCodeConfig>,
) -> usize {
vt_cfg
.map(|cfg| cfg.agent.harness.max_tool_retries as usize)
.unwrap_or(vtcode_config::constants::defaults::DEFAULT_MAX_TOOL_RETRIES as usize)
}
fn path_targets_plan_artifact(path: &str) -> bool {
let normalized = path.trim().replace('\\', "/");
normalized == ".vtcode/plans"
|| normalized.starts_with(".vtcode/plans/")
|| normalized.contains("/.vtcode/plans/")
|| normalized == "/tmp/vtcode-plans"
|| normalized.starts_with("/tmp/vtcode-plans/")
|| normalized.contains("/tmp/vtcode-plans/")
}
fn path_is_docs_only(path: &str) -> bool {
let normalized = path.trim().replace('\\', "/");
let lower = normalized.to_ascii_lowercase();
let file_name = lower.rsplit('/').next().unwrap_or_default();
// Well-known prose filenames count only with a docs extension or no
// extension at all, so `README.py` and `docs/script.py` stay code edits.
let stem = file_name.split('.').next().unwrap_or_default();
if matches!(stem, "readme" | "changelog" | "license") {
let ext = file_name.rsplit('.').next().unwrap_or_default();
if ext == stem || matches!(ext, "md" | "mdx" | "txt" | "rst") {
return true;
}
}
matches!(file_name.rsplit('.').next(), Some("md" | "mdx" | "txt" | "rst"))
}
/// Docs-only writes are low-risk prose edits. They neither trip nor clear the
/// anti-blind gate: while pending they stay allowed (warn-only), and they never
/// increment `consecutive_mutations`. Mixed docs+code patches, empty path sets,
/// and exec-tool mutations fail closed as code edits.
pub(crate) fn is_docs_only_write(name: &str, args: &serde_json::Value) -> bool {
use vtcode_core::config::constants::tools as tool_names;
use vtcode_core::tools::names::canonical_tool_name;
use vtcode_core::tools::tool_intent::file_operation_action;
let canonical = canonical_tool_name(name);
let is_file_write = matches!(
canonical,
tool_names::APPLY_PATCH
| tool_names::WRITE_FILE
| tool_names::EDIT_FILE
| tool_names::CREATE_FILE
| tool_names::SEARCH_REPLACE
| tool_names::DELETE_FILE
| tool_names::MOVE_FILE
| tool_names::COPY_FILE
| tool_names::UNIFIED_FILE
);
if !is_file_write {
return false;
}
if canonical == tool_names::UNIFIED_FILE
&& file_operation_action(args)
.map(|action| action.eq_ignore_ascii_case("read"))
.unwrap_or(false)
{
return false;
}
let mut paths = vtcode_core::tools::apply_patch::mutation_target_paths(canonical, args);
// `mutation_target_paths` SINGULAR_KEYS lacks camelCase `filePath`
// (covered by `is_plan_artifact_write`); supplement it so the same
// call is not docs-only via `path` but blocked via `filePath`.
// Duplicates are harmless: every path must still be docs-only.
if let Some(extra) = args.get("filePath").and_then(|value| value.as_str()) {
let trimmed = extra.trim();
if !trimmed.is_empty() {
paths.push(PathBuf::from(trimmed));
}
}
if paths.is_empty() {
return false;
}
paths.iter().all(|path| path.to_str().is_some_and(path_is_docs_only))
}
pub(crate) fn is_plan_artifact_write(name: &str, args: &serde_json::Value) -> bool {
use vtcode_core::config::constants::tools as tool_names;
use vtcode_core::tools::names::canonical_tool_name;
use vtcode_core::tools::tool_intent::file_operation_action;
let canonical = canonical_tool_name(name);
match canonical {
tool_names::TASK_TRACKER => true,
tool_names::UNIFIED_FILE => {
if !file_operation_action(args)
.map(|action| action.eq_ignore_ascii_case("read"))
.unwrap_or(false)
{
[
"path",
"file_path",
"filepath",
"filePath",
"target_path",
"destination",
"destination_path",
]
.iter()
.filter_map(|key| args.get(*key).and_then(|value| value.as_str()))
.any(path_targets_plan_artifact)
} else {
false
}
}
tool_names::WRITE_FILE | tool_names::EDIT_FILE | tool_names::CREATE_FILE | tool_names::SEARCH_REPLACE => {
["path", "file_path", "filepath", "filePath"]
.iter()
.filter_map(|key| args.get(*key).and_then(|value| value.as_str()))
.any(path_targets_plan_artifact)
}
_ => false,
}
}
fn is_execution_tool(name: &str) -> bool {
use vtcode_core::config::constants::tools as tool_names;
matches!(
name,
tool_names::UNIFIED_EXEC
| tool_names::EXEC_COMMAND
| tool_names::EXEC_PTY_CMD
| tool_names::RUN_PTY_CMD
| tool_names::EXECUTE_CODE
| tool_names::SHELL
)
}
/// Return whether a tool call must wait for a successful verification step.
///
/// Reads, inspections, verification commands, task tracking, dedicated
/// plan-artifact writes, and docs-only prose writes remain available while the
/// checkpoint is pending.
/// A failed verifier grants a bounded fix-up window ([`FAILED_VERIFICATION_FIX_ALLOWANCE`])
/// so a broken build can be repaired, and piped verifier attempts
/// (e.g. `cargo check 2>&1 | grep error`) are admitted to run even though
/// they cannot clear the gate. A verifier piped only into `head`/`tail` runs
/// as a standalone verifier (see [`shell_args_as_executed`]).
pub(crate) fn mutation_blocked_until_verification(
loop_tracker: &LoopTracker,
name: &str,
args: &serde_json::Value,
) -> bool {
if !loop_tracker.verification_is_pending() || is_plan_artifact_write(name, args) || is_docs_only_write(name, args) {
return false;
}
let canonical_name = canonical_tool_name(name);
if is_execution_tool(canonical_name) {
// Classify the command the kernel will run: a verifier piped only
// into `head`/`tail` executes standalone and is a verification.
let executed = shell_args_as_executed(canonical_name, args);
let activity = classify_shell_activity(canonical_name, &executed);
// Other piped verifier attempts (`cargo check 2>&1 | grep error`)
// still run so the model can see the failure; they never clear the
// gate (see update_repetition_tracker). The admission predicate
// requires every shell segment to be verification-or-readonly, so a
// smuggled mutation such as `cargo check && rm -rf target` stays
// blocked.
if !matches!(activity, ShellActivity::Mutation) || shell_command_is_admitted_verification_attempt(&executed) {
return false;
}
// Fix-up window: allow bounded repair edits after a failed verifier.
return loop_tracker.fix_edits_remaining == 0;
}
if !vtcode_core::tools::tool_intent::classify_tool_intent(canonical_name, args).mutating {
return false;
}
loop_tracker.fix_edits_remaining == 0
}
/// Updates the tool repetition tracker based on the execution outcome.
///
/// Count completed attempts for repetition detection, but only successful
/// mutations contribute to anti-blind-editing verification pressure.
///
/// Returns `true` when this outcome freshly granted (or refreshed) the
/// failed-verifier fix-up window. Callers must reset the assistant text-response
/// streak so the model gets one diagnostic explanation before the
/// pending-verification text cap re-applies; otherwise a failed build blocks
/// the turn before the agent can describe the failure and use its fix-up edits.
///
/// A `true` return also covers *lost* verifier results: while the gate is
/// pending, a verifier-level Failure/Timeout (or a session follow-up
/// failure — `write_stdin`, a non-run `unified_exec` action, or a PTY
/// session follow-up — reporting a dead exec/PTY session) grants the same
/// bounded window because the verifier never produced an observable
/// verdict. In that case the tracker queues
/// [`VERIFICATION_RESULT_LOST_DIRECTIVE`] for the handlers to surface.
///
/// A `true` return finally covers a *piped* verifier success while the gate
/// is pending (`cargo check 2>&1 | grep error`): the exit status belongs to
/// another command and cannot clear the gate, so the tracker queues
/// [`PIPED_VERIFICATION_DIRECTIVE`] instead of leaving the model to believe
/// the check verified the edits.
pub(crate) fn update_repetition_tracker(
loop_tracker: &mut LoopTracker,
outcome: &ToolPipelineOutcome,
name: &str,
args: &serde_json::Value,
) -> bool {
if matches!(&outcome.status, ToolExecutionStatus::Cancelled) {
return false;
}
let canonical_name = canonical_tool_name(name);
let signature_key = signature_key_for(canonical_name, args);
loop_tracker.record(signature_key.clone());
let navigation_family =
crate::agent::runloop::unified::turn::tool_outcomes::handlers::low_signal_family_key(canonical_name, args);
let low_signal_family = navigation_family
.clone()
.filter(|_| is_low_signal_outcome(outcome, canonical_name, args));
let navigation_signature_key = navigation_family
.map(|family| format!("navigation::{family}"))
.unwrap_or_else(|| signature_key.clone());
// Successful but redundant scans (e.g. three overlapping `find` calls)
// never match the exact family key. Track a coarse inspection family so
// the third repeat surfaces in diagnostics and in the turn balancer's
// listing-loop tripwire without changing admission behavior.
let coarse_family = coarse_inspection_family_key(canonical_name, args);
let coarse_repeat = coarse_family.as_ref().map(|family| {
let entry = loop_tracker
.coarse_inspection_attempts
.entry(family.clone())
.or_insert((0, Instant::now()));
entry.0 = entry.0.saturating_add(1);
entry.1 = Instant::now();
entry.0
});
let is_coarse_duplicate =
coarse_repeat.is_some_and(|count| count >= 3) && matches!(&outcome.status, ToolExecutionStatus::Success { .. });
let mut low_signal_family = low_signal_family;
if low_signal_family.is_none() && is_coarse_duplicate {
low_signal_family = coarse_family.clone();
}
let is_low_signal_navigation = low_signal_family.is_some();
if let Some(low_signal_family) = low_signal_family.as_ref() {
loop_tracker.record_low_signal(low_signal_family.clone());
}
// Lost verifier results via session follow-ups: a `write_stdin`,
// `unified_exec` poll/wait/inspect/continue, or PTY session follow-up
// failure that reports a missing session means the session (and its
// pending verifier output) died before the result was captured, so the
// follow-up never classifies as ShellActivity::Verification. While the
// gate is pending, treat it like a failed verifier so the model gets a
// bounded fix/diagnostic window instead of deadlocking behind a gate
// that can no longer observe a successful verifier.
if is_session_follow_up(canonical_name, args)
&& loop_tracker.verification_is_pending()
&& let ToolExecutionStatus::Failure { error } = &outcome.status
&& error_text_indicates_lost_session(&error.message)
{
loop_tracker.verification_result_lost_notice_pending = true;
loop_tracker.record_failed_verification();
loop_tracker.reset_navigation_window(low_signal_family.is_none());
return true;
}
// Update NL2Repo-Bench metrics based on tool intent.
//
// IMPORTANT: Check execution tools FIRST. `classify_tool_intent` marks
// `command_session(action=run)` as `mutating: true` because shell commands *can*
// mutate state, but for the Edit-Test heuristic, any execution/verification
// step (cargo check, cargo test, etc.) should RESET the mutation counter,
// not increment it.
if is_execution_tool(canonical_name) {
// Classify the command the kernel ran, not the typed text: a verifier
// piped only into `head`/`tail` executes standalone, so its truthful
// exit status is the verifier's and it counts as verification.
let executed = shell_args_as_executed(canonical_name, args);
match classify_shell_activity(canonical_name, &executed) {
ShellActivity::Inspection => {
loop_tracker.consecutive_navigations = loop_tracker.consecutive_navigations.saturating_add(1);
loop_tracker.nav_signatures.insert(navigation_signature_key);
loop_tracker.record_navigation_signal(is_low_signal_navigation);
}
ShellActivity::Verification => {
if matches!(&outcome.status, ToolExecutionStatus::Success { command_success: true, .. }) {
loop_tracker.mark_verification_complete();
} else if matches!(&outcome.status, ToolExecutionStatus::Success { command_success: false, .. }) {
// Only a verifier that actually ran and reported non-zero
// opens the fix-up window. Tool-level Failure/Timeout (never
// executed, e.g. argument errors) must not grant edits.
loop_tracker.record_failed_verification();
loop_tracker.reset_navigation_window(low_signal_family.is_none());
return true;
} else if loop_tracker.verification_is_pending()
&& matches!(
&outcome.status,
ToolExecutionStatus::Failure { .. } | ToolExecutionStatus::Timeout { .. }
)
{
// While the gate is pending, a verifier-level
// Failure/Timeout almost always means the result was lost
// (e.g. the exec session ended before the verifier
// finished) rather than a genuine non-zero exit: the
// verifier never produced an observable verdict. A bounded
// fix window that still requires a successful standalone
// verifier to clear is strictly better than a permanent
// stall. Without the gate pending this stays a no-grant
// arg-error path.
loop_tracker.verification_result_lost_notice_pending = true;
loop_tracker.record_failed_verification();
loop_tracker.reset_navigation_window(low_signal_family.is_none());
return true;
}
loop_tracker.reset_navigation_window(low_signal_family.is_none());
}
ShellActivity::Mutation => {
// Piped verifier attempts that the kernel cannot elide (e.g.
// `cargo check 2>&1 | grep error`, or a `;` join) are admitted
// to run but never clear the gate: the exit status belongs to
// another command, not the verifier. Don't count them as
// blind edits; a failed piped attempt still
// opens the fix window so the agent can repair and re-run a
// standalone verifier. Chained mutations smuggled behind a
// verifier prefix are rejected by the admission predicate and
// take the blind-edit path below.
if shell_command_is_admitted_verification_attempt(&executed) {
let ran_and_failed =
matches!(&outcome.status, ToolExecutionStatus::Success { command_success: false, .. });
if ran_and_failed {
loop_tracker.record_failed_verification();
loop_tracker.reset_navigation_window(low_signal_family.is_none());
return true;
}
// A piped verifier's exit status belongs to another
// command, so a success cannot clear the gate. While the
// gate is pending that silence reads as "verified" to
// the model (checkpoint session-vtcode-20260912T083718Z:
// a piped verifier exited 0 and the turn still
// deadlocked). Queue the one-shot piped-verifier
// directive so the handlers surface it after the tool
// response lands.
if loop_tracker.verification_is_pending()
&& matches!(&outcome.status, ToolExecutionStatus::Success { command_success: true, .. })
{
loop_tracker.piped_verification_notice_pending = true;
loop_tracker.reset_navigation_window(low_signal_family.is_none());
return true;
}
loop_tracker.reset_navigation_window(low_signal_family.is_none());
} else {
if mutation_was_applied(outcome) {
loop_tracker.record_successful_mutation();
}
loop_tracker.reset_navigation_window(low_signal_family.is_none());
}
}
}
} else if is_plan_artifact_write(canonical_name, args) || is_docs_only_write(canonical_name, args) {
// Plan artifact writes in dedicated plan storage and docs-only prose
// writes are allowed while pending and should not trigger
// anti-blind-editing verification pressure.
// Low-signal repetition history is preserved: plan/docs writes are not
// navigation, so they neither advance nor clear that window.
loop_tracker.reset_navigation_window(false);
} else {
let intent = vtcode_core::tools::tool_intent::classify_tool_intent(canonical_name, args);
if intent.mutating {
if mutation_was_applied(outcome) {
loop_tracker.record_successful_mutation();
}
loop_tracker.reset_navigation_window(low_signal_family.is_none());
} else {
// Read-only / navigation tool
loop_tracker.consecutive_navigations += 1;
loop_tracker.nav_signatures.insert(navigation_signature_key);
loop_tracker.record_navigation_signal(is_low_signal_navigation);
}
}
false
}
fn mutation_was_applied(outcome: &ToolPipelineOutcome) -> bool {
match &outcome.status {
ToolExecutionStatus::Success { output, command_success, modified_files, .. } => {
if let Some(effective_change) = vtcode_core::tools::file_ops::diff_output_has_effective_change(output) {
return effective_change;
}
*command_success || !modified_files.is_empty()
}
ToolExecutionStatus::Failure { .. } | ToolExecutionStatus::Timeout { .. } | ToolExecutionStatus::Cancelled => {
false
}
}
}
pub(crate) fn serialize_output(output: &serde_json::Value) -> String {
if let Some(s) = output.as_str() {
s.to_string()
} else {
serde_json::to_string(output).unwrap_or_else(|_| "{}".to_string())
}
}
pub(crate) fn check_is_argument_error(error_str: &str) -> bool {
error_str.contains("Missing required")
|| error_str.contains("Invalid arguments")
|| error_str.contains("Tool argument validation failed")
|| error_str.contains("required path parameter")
|| error_str.contains("is required for '")
|| error_str.contains("is required for \"")
|| error_str.contains("'index' is required")
|| error_str.contains("'index_path' is required")
|| error_str.contains("'status' is required")
|| error_str.contains("expected ")
|| error_str.contains("Expected:")
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vtcode_core::config::constants::tools;
use super::*;
#[test]
fn push_tool_response_replaces_existing_tool_call_entry() {
let mut history = vec![uni::Message::tool_response(
"call_1".to_string(),
"{\"output\":\"first\"}".to_string(),
)];
let update =
push_tool_response(&mut history, "call_1".to_string(), None, "{\"output\":\"latest\"}".to_string());
assert_eq!(history.len(), 1);
assert_eq!(history[0].content.as_text_borrowed(), Some("{\"output\":\"latest\"}"));
assert_eq!(update, ToolResponseHistoryUpdate::Replaced { previous_text_len: "{\"output\":\"first\"}".len() });
}
#[test]
fn push_tool_response_sets_origin_tool_when_provided() {
let mut history = Vec::new();
let update = push_tool_response(
&mut history,
"call_1".to_string(),
Some("read_file"),
"{\"output\":\"first\"}".to_string(),
);
assert_eq!(history.len(), 1);
assert_eq!(history[0].origin_tool.as_deref(), Some("read_file"));
assert_eq!(update, ToolResponseHistoryUpdate::Appended);
}
#[test]
fn push_tool_response_refreshes_origin_tool_when_replacing_same_call() {
let mut history = vec![uni::Message::tool_response("call_1".to_string(), "old".to_string())];
let update = push_tool_response(&mut history, "call_1".to_string(), Some("exec_command"), "new".to_string());
assert_eq!(update, ToolResponseHistoryUpdate::Replaced { previous_text_len: 3 });
assert_eq!(history[0].origin_tool.as_deref(), Some("exec_command"));
}
#[test]
fn push_tool_response_appends_when_id_reused_across_assistant_boundary() {
// Fabricated ids can collide across turns (e.g. index-based fallbacks).
// A later assistant message re-declaring the same id must not cause a
// new result to clobber the earlier, unrelated Tool response.
let mut history = vec![
uni::Message::assistant_with_tools(
"first".into(),
vec![uni::ToolCall::function(
"call_1".into(),
"file_operation".into(),
"{}".into(),
)],
),
uni::Message::tool_response("call_1".to_string(), "{\"output\":\"first\"}".into()),
uni::Message::assistant_with_tools(
"second".into(),
vec![uni::ToolCall::function(
"call_1".into(),
tools::CODE_SEARCH.into(),
"{}".into(),
)],
),
];
let update = push_tool_response(
&mut history,
"call_1".to_string(),
Some(tools::CODE_SEARCH),
"{\"output\":\"second\"}".to_string(),
);
let tool_messages: Vec<&uni::Message> = history
.iter()
.filter(|message| matches!(message.role, uni::MessageRole::Tool))
.collect();
assert_eq!(tool_messages.len(), 2, "must append, not overwrite");
assert_eq!(
tool_messages[0].content.as_text_borrowed(),
Some("{\"output\":\"first\"}"),
"earlier unrelated Tool result must remain intact"
);
assert_eq!(tool_messages[1].content.as_text_borrowed(), Some("{\"output\":\"second\"}"));
assert_eq!(update, ToolResponseHistoryUpdate::Appended);
}
#[test]
fn push_tool_response_appends_when_assistant_has_no_tool_calls() {
// When an Assistant message has no tool_calls (e.g. commentary-only
// message between tool calls), the boundary must STILL stop the scan.
// Otherwise a later Tool response with a colliding fabricated id would
// overwrite an earlier, unrelated Tool result.
let mut history = vec![
uni::Message::assistant_with_tools(
String::new(),
vec![uni::ToolCall::function(
"call_0".into(),
"file_operation".into(),
"{}".into(),
)],
),
uni::Message::tool_response("call_0".to_string(), "{\"output\":\"file content\"}".into()),
// Commentary Assistant with no tool_calls — must act as boundary
uni::Message::assistant("I need to retry.".into()),
uni::Message::assistant_with_tools(
String::new(),
vec![uni::ToolCall::function(
"call_0".into(),
"apply_patch".into(),
"{}".into(),
)],
),
];
let update = push_tool_response(
&mut history,
"call_0".to_string(),
Some("apply_patch"),
"{\"output\":\"patch result\"}".to_string(),
);
let tool_messages: Vec<&uni::Message> = history
.iter()
.filter(|message| matches!(message.role, uni::MessageRole::Tool))
.collect();
assert_eq!(tool_messages.len(), 2, "must append, not overwrite the earlier file read");
assert_eq!(
tool_messages[0].content.as_text_borrowed(),
Some("{\"output\":\"file content\"}"),
"earlier file read result must remain intact"
);
assert_eq!(tool_messages[1].content.as_text_borrowed(), Some("{\"output\":\"patch result\"}"));
assert_eq!(update, ToolResponseHistoryUpdate::Appended);
}
#[test]
fn repetition_tracker_counts_failures() {
let mut tracker = LoopTracker::new();
let outcome = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
"edit_file".to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"boom".to_string(),
),
});
update_repetition_tracker(&mut tracker, &outcome, "edit_file", &json!({"path":"src/main.rs"}));
assert_eq!(tracker.max_count_filtered(|_| false), 1);
}
#[test]
fn failed_file_mutations_do_not_trigger_verification_pressure() {
let mut tracker = LoopTracker::new();
let outcome = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
"apply_patch".to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"invalid patch path".to_string(),
),
});
update_repetition_tracker(
&mut tracker,
&outcome,
tools::APPLY_PATCH,
&json!({"input":"*** Begin Patch\n*** Update File: /absolute/path\n*** End Patch"}),
);
assert_eq!(tracker.consecutive_mutations, 0);
}
#[test]
fn no_op_write_does_not_trigger_verification_pressure() {
let mut tracker = LoopTracker::new();
let outcome = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: json!({
"success": true,
"path": "README.md",
"diff_preview": {
"content": "",
"truncated": false,
"omitted_line_count": 0,
"skipped": false,
"is_empty": true
},
"diff": [{
"path": "README.md",
"content": "",
"truncated": false,
"omitted_line_count": 0,
"skipped": false,
"is_empty": true
}]
}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&outcome,
tools::WRITE_FILE,
&json!({"path":"README.md","content":"same\n","mode":"overwrite"}),
);
assert_eq!(tracker.consecutive_mutations, 0);
}
#[test]
fn skipped_write_does_not_trigger_verification_pressure() {
let mut tracker = LoopTracker::new();
let outcome = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: json!({
"success": true,
"skipped": true,
"reason": "File already exists"
}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&outcome,
tools::WRITE_FILE,
&json!({"path":"README.md","content":"same\n","mode":"skip_if_exists"}),
);
assert_eq!(tracker.consecutive_mutations, 0);
}
#[test]
fn verification_gate_blocks_mutations_but_allows_reads_checks_and_plan_artifacts() {
let mut tracker = LoopTracker::new();
tracker.verification_pending = true;
assert!(mutation_blocked_until_verification(
&tracker,
tools::WRITE_FILE,
&json!({"path":"src/lib.rs","content":"new"})
));
// Docs-only prose stays allowed while pending and never trips the gate.
assert!(!mutation_blocked_until_verification(
&tracker,
tools::WRITE_FILE,
&json!({"path":"README.md","content":"new"})
));
assert!(!mutation_blocked_until_verification(
&tracker,
tools::WRITE_FILE,
&json!({"path":"docs/guide.md","content":"new"})
));
// Mixed docs+code patches stay blocked (fail closed).
assert!(mutation_blocked_until_verification(
&tracker,
tools::APPLY_PATCH,
&json!({"patch":"*** Begin Patch\n*** Update File: README.md\n@@\n-old\n+new\n*** Update File: src/lib.rs\n@@\n-old\n+new\n*** End Patch\n"})
));
assert!(mutation_blocked_until_verification(
&tracker,
tools::EXEC_COMMAND,
&json!({"cmd":"sed -i '' 's/old/new/' README.md"})
));
assert!(!mutation_blocked_until_verification(&tracker, tools::READ_FILE, &json!({"path":"README.md"})));
assert!(!mutation_blocked_until_verification(
&tracker,
tools::EXEC_COMMAND,
&json!({"cmd":"cargo check --locked"})
));
assert!(!mutation_blocked_until_verification(
&tracker,
tools::WRITE_FILE,
&json!({"path":".vtcode/plans/next.md","content":"plan"})
));
assert!(!mutation_blocked_until_verification(&tracker, tools::TASK_TRACKER, &json!({"action":"update"})));
}
#[test]
fn inspection_does_not_clear_mutations_waiting_for_verification() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
for command in ["git diff -- README.md", "git diff --check"] {
update_repetition_tracker(&mut tracker, &success, tools::EXEC_COMMAND, &json!({"cmd":command}));
}
assert_eq!(tracker.consecutive_mutations, BLIND_EDITING_THRESHOLD);
}
#[test]
fn failed_verification_does_not_clear_mutations_waiting_for_verification() {
let mut tracker = LoopTracker::new();
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
tracker.verification_pending = true;
let failed_check = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 1}),
stdout: None,
modified_files: vec![],
command_success: false,
});
update_repetition_tracker(&mut tracker, &failed_check, tools::EXEC_COMMAND, &json!({"cmd":"cargo check"}));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.consecutive_mutations, BLIND_EDITING_THRESHOLD);
// A failed verifier keeps the gate but opens a bounded fix-up window
// so the broken build can be repaired instead of deadlocking.
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(!mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
}
#[test]
fn failed_verification_fix_window_is_consumed_by_repair_edits() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let failed_check = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 1}),
stdout: None,
modified_files: vec![],
command_success: false,
});
update_repetition_tracker(&mut tracker, &failed_check, tools::EXEC_COMMAND, &json!({"cmd":"cargo check"}));
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
let edit = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for _ in 0..FAILED_VERIFICATION_FIX_ALLOWANCE {
assert!(!mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
update_repetition_tracker(&mut tracker, &edit, tools::EDIT_FILE, &json!({"path": "src/lib.rs"}));
assert!(tracker.verification_is_pending());
}
// Window exhausted: further mutations block again until a standalone
// verifier succeeds.
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
}
#[test]
fn piped_verifier_is_admitted_but_does_not_clear_gate() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
// Filtering-piped verifiers must run (not block) so the model sees
// output, but the exit status is the filter's — they never clear.
assert!(!mutation_blocked_until_verification(
&tracker,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo check --locked 2>&1 | grep error"})
));
let piped_success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&piped_success,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo check --locked 2>&1 | grep error"}),
);
assert!(tracker.verification_is_pending());
assert_eq!(tracker.consecutive_mutations, BLIND_EDITING_THRESHOLD);
}
#[test]
fn truncation_only_piped_verifier_success_clears_gate() {
// The kernel elides a pure `| head`/`| tail` tail and runs the
// standalone verifier, so its exit 0 is the verifier's own. The
// tracker must agree even when handed the typed (raw) arguments,
// e.g. after a PreToolUse hook rewrite.
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for command in [
"cargo check --locked 2>&1 | tail -5",
"cargo check --locked 2>&1 | head -c 4000",
] {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
assert!(
!mutation_blocked_until_verification(&tracker, tools::EXEC_COMMAND, &json!({"cmd": command})),
"{command}"
);
assert!(
!update_repetition_tracker(&mut tracker, &success, tools::EXEC_COMMAND, &json!({"cmd": command})),
"{command}"
);
assert!(!tracker.verification_is_pending(), "elided verifier success must clear: {command}");
assert_eq!(tracker.consecutive_mutations, 0, "{command}");
assert!(!tracker.take_piped_verification_notice(), "no piped notice for {command}");
}
}
#[test]
fn anti_blind_editing_directive_states_the_shared_shell_form_note() {
assert!(ANTI_BLIND_EDITING_DIRECTIVE.ends_with(vtcode_core::tools::tool_intent::VERIFIER_SHELL_FORM_NOTE));
assert!(!PIPED_VERIFICATION_DIRECTIVE.contains("`tail`/`head`"));
}
#[test]
fn smuggled_mutation_behind_verifier_prefix_stays_blocked() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
for command in [
"cargo check && rm -rf target",
"cargo check; rm foo.txt",
"cargo check --locked && cargo test && rm foo.txt",
] {
assert!(
mutation_blocked_until_verification(&tracker, tools::EXEC_COMMAND, &json!({"cmd": command})),
"smuggled mutation must stay blocked: {command}"
);
}
}
#[test]
fn pure_and_chained_verifiers_are_admitted_and_clear_gate() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
tracker.verification_result_lost_notice_pending = true;
tracker.piped_verification_notice_pending = true;
for command in [
"cargo fmt --all -- --check && cargo check --locked",
"cargo check --locked && cargo nextest run --locked -p vtcode-ui",
"cargo check --locked && cargo clippy --locked -p vtcode-ui -- -D warnings",
] {
assert!(
!mutation_blocked_until_verification(&tracker, tools::EXEC_COMMAND, &json!({"cmd": command})),
"pure && verifier chain must be admitted: {command}"
);
}
let chained_success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
assert!(!update_repetition_tracker(
&mut tracker,
&chained_success,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo fmt --all -- --check && cargo check --locked"}),
));
assert!(!tracker.verification_is_pending());
assert_eq!(tracker.consecutive_mutations, 0);
assert!(!tracker.take_verification_result_lost_notice());
assert!(!tracker.take_piped_verification_notice());
}
#[test]
fn non_and_chained_verifiers_do_not_clear_gate() {
for command in [
"cargo check --locked; cargo nextest run --locked -p vtcode-ui",
"cargo check --locked || cargo nextest run --locked -p vtcode-ui",
"cargo check --locked | grep -v warning",
] {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let chained_success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(&mut tracker, &chained_success, tools::EXEC_COMMAND, &json!({"cmd": command}));
assert!(tracker.verification_is_pending(), "`;`/`||`/`|` chains must not clear the gate: {command}");
}
}
#[test]
fn gate_trips_on_sixth_consecutive_code_mutation_not_fifth() {
let mut tracker = LoopTracker::new();
let edit = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for _ in 0..(BLIND_EDITING_THRESHOLD - 1) {
update_repetition_tracker(&mut tracker, &edit, tools::EDIT_FILE, &json!({"path": "src/lib.rs"}));
}
assert!(!tracker.verification_is_pending());
assert!(!mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
update_repetition_tracker(&mut tracker, &edit, tools::EDIT_FILE, &json!({"path": "src/lib.rs"}));
assert!(tracker.verification_is_pending());
assert!(mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
}
#[test]
fn docs_only_writes_stay_allowed_and_do_not_increment_counter() {
let mut tracker = LoopTracker::new();
let docs_edit = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for _ in 0..BLIND_EDITING_THRESHOLD {
update_repetition_tracker(
&mut tracker,
&docs_edit,
tools::WRITE_FILE,
&json!({"path": "README.md", "content": "prose"}),
);
}
assert_eq!(tracker.consecutive_mutations, 0);
assert!(!tracker.verification_is_pending());
let mut pending = LoopTracker::with_verification_snapshot((true, 0));
pending.consecutive_mutations = BLIND_EDITING_THRESHOLD;
update_repetition_tracker(&mut pending, &docs_edit, tools::EDIT_FILE, &json!({"path": "docs/guide.md"}));
assert!(pending.verification_is_pending());
assert_eq!(pending.consecutive_mutations, BLIND_EDITING_THRESHOLD);
assert!(!mutation_blocked_until_verification(&pending, tools::EDIT_FILE, &json!({"path": "docs/guide.md"})));
assert!(mutation_blocked_until_verification(&pending, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
}
#[test]
fn expanded_verifiers_clear_gate_while_mutating_lookalikes_do_not() {
for command in [
"bun test",
"deno lint",
"make test",
"just lint",
"ruff check src/",
"tsc --noEmit",
"eslint src/",
"python3 -m pytest",
"uv run pytest",
] {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
assert!(
!mutation_blocked_until_verification(&tracker, tools::EXEC_COMMAND, &json!({"cmd": command})),
"verifier must be admitted: {command}"
);
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(&mut tracker, &success, tools::EXEC_COMMAND, &json!({"cmd": command}));
assert!(!tracker.verification_is_pending(), "verifier must clear the gate: {command}");
}
for command in [
"make clean",
"make test clean",
"tsc",
"eslint --fix src/",
"ruff format src/",
"bun install",
] {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
assert!(
mutation_blocked_until_verification(&tracker, tools::EXEC_COMMAND, &json!({"cmd": command})),
"mutating lookalike must stay blocked: {command}"
);
}
}
#[test]
fn docs_only_boundary_cases_fail_closed() {
let pending = LoopTracker::with_verification_snapshot((true, 0));
// Code under docs/ stays a code edit.
for path in [
"docs/script.py",
"docs/app.ts",
"README.py",
"readme_script.py",
"LICENSE-MIT",
] {
assert!(
mutation_blocked_until_verification(&pending, tools::EDIT_FILE, &json!({"path": path})),
"code-looking path must stay blocked: {path}"
);
assert!(!is_docs_only_write(tools::EDIT_FILE, &json!({"path": path})), "{path}");
}
// Prose spellings stay allowed, including camelCase `filePath`
// (supplemented: `mutation_target_paths` lacks that key).
for args in [
json!({"path": "README"}),
json!({"path": "README.md"}),
json!({"path": "CHANGELOG.rst"}),
json!({"path": "LICENSE"}),
json!({"path": "docs/guide.md"}),
json!({"filePath": "docs/guide.md"}),
] {
assert!(!mutation_blocked_until_verification(&pending, tools::EDIT_FILE, &args), "{args}");
assert!(is_docs_only_write(tools::EDIT_FILE, &args), "{args}");
}
// Exec-tool mutations never qualify, even for prose paths.
assert!(!is_docs_only_write(tools::EXEC_COMMAND, &json!({"cmd": "echo hi > README.md"})));
}
#[test]
fn piped_verifier_success_while_pending_queues_notice_once() {
// Regression guard for session-vtcode-20260912T083718Z: a piped
// verifier success exited 0 while the gate was pending, cleared
// nothing, and said nothing — the model believed it had verified and
// the turn deadlocked on unverified text responses.
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let piped_success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
assert!(update_repetition_tracker(
&mut tracker,
&piped_success,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo check --locked -p vtcode 2>&1 | grep -E 'error|warning'"}),
));
assert!(tracker.verification_is_pending(), "piped success must not clear the gate");
assert!(tracker.take_piped_verification_notice(), "piped success must queue the notice");
assert!(!tracker.take_piped_verification_notice(), "notice is one-shot");
// A standalone pure-`&&` verifier success afterwards clears the gate
// together with any queued piped notice.
let chained_success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
assert!(!update_repetition_tracker(
&mut tracker,
&chained_success,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo fmt --all -- --check && cargo check --locked"}),
));
assert!(!tracker.verification_is_pending());
assert!(!tracker.take_piped_verification_notice());
}
#[test]
fn piped_verifier_success_without_pending_gate_stays_silent() {
let mut tracker = LoopTracker::new();
let piped_success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
assert!(!update_repetition_tracker(
&mut tracker,
&piped_success,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo check --locked 2>&1 | grep error"}),
));
assert!(!tracker.take_piped_verification_notice());
}
#[test]
fn fmt_check_clears_gate_but_plain_fmt_does_not() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
assert!(!mutation_blocked_until_verification(
&tracker,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo fmt --all -- --check"})
));
let fmt_check_success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&fmt_check_success,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo fmt --all -- --check"}),
);
assert!(!tracker.verification_is_pending());
// Plain `cargo fmt` rewrites files: it stays a mutation and never
// clears the gate.
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
assert!(mutation_blocked_until_verification(&tracker, tools::EXEC_COMMAND, &json!({"cmd": "cargo fmt"})));
}
#[test]
fn failed_verifier_reports_fix_window_for_text_streak_reset() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let failed_check = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 1}),
stdout: None,
modified_files: vec![],
command_success: false,
});
assert!(update_repetition_tracker(
&mut tracker,
&failed_check,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo check --locked"}),
));
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
let successful_check = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
assert!(!update_repetition_tracker(
&mut tracker,
&successful_check,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo check --locked"}),
));
}
#[test]
fn lost_verification_tool_failure_while_pending_grants_fix_window() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let tool_failure = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::EXEC_COMMAND.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"check could not start".to_string(),
),
});
assert!(update_repetition_tracker(
&mut tracker,
&tool_failure,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo check --locked"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(
!mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})),
"the lost-result grant must open the bounded fix window"
);
assert!(tracker.take_verification_result_lost_notice(), "the lost-result directive must be queued");
assert!(!tracker.take_verification_result_lost_notice(), "the notice is one-shot");
}
#[test]
fn lost_verification_tool_timeout_while_pending_grants_fix_window() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let tool_timeout = ToolPipelineOutcome::from_status(ToolExecutionStatus::Timeout {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::EXEC_COMMAND.to_string(),
vtcode_core::tools::registry::ToolErrorType::Timeout,
"verification command timed out".to_string(),
),
});
assert!(update_repetition_tracker(
&mut tracker,
&tool_timeout,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo nextest run"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
}
#[test]
fn verification_tool_failure_without_pending_gate_grants_no_fix_window() {
let mut tracker = LoopTracker::new();
let tool_failure = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::EXEC_COMMAND.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"check could not start".to_string(),
),
});
assert!(!update_repetition_tracker(
&mut tracker,
&tool_failure,
tools::EXEC_COMMAND,
&json!({"cmd": "cargo check --locked"}),
));
assert!(!tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(!tracker.take_verification_result_lost_notice());
}
#[test]
fn write_stdin_lost_exec_session_failure_while_pending_grants_fix_window() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let lost_session = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::WRITE_STDIN.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"exec session 'run-7' not found. Copy the exact `session_id` from the original run response"
.to_string(),
),
});
assert!(update_repetition_tracker(
&mut tracker,
&lost_session,
tools::WRITE_STDIN,
&json!({"session_id": "run-7", "chars": ""}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(!mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
assert!(tracker.take_verification_result_lost_notice());
}
#[test]
fn write_stdin_unrelated_failure_while_pending_grants_no_fix_window() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let unrelated = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::WRITE_STDIN.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"session is not writable".to_string(),
),
});
assert!(!update_repetition_tracker(
&mut tracker,
&unrelated,
tools::WRITE_STDIN,
&json!({"session_id": "run-7", "chars": "q"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(!tracker.take_verification_result_lost_notice());
}
#[test]
fn unified_exec_wait_on_lost_session_while_pending_grants_fix_window() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let lost_session = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::UNIFIED_EXEC.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"exec session 'run-7' not found. Copy the exact `session_id` from the original run response"
.to_string(),
),
});
assert!(update_repetition_tracker(
&mut tracker,
&lost_session,
tools::UNIFIED_EXEC,
&json!({"action": "wait", "session_id": "run-7"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(!mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
assert!(tracker.take_verification_result_lost_notice());
assert!(!tracker.take_verification_result_lost_notice(), "the notice is one-shot");
}
#[test]
fn unified_exec_poll_on_lost_session_while_pending_grants_fix_window() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let lost_session = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::UNIFIED_EXEC.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"exec session 'run-7' not found. Copy the exact `session_id` from the original run response"
.to_string(),
),
});
assert!(update_repetition_tracker(
&mut tracker,
&lost_session,
tools::UNIFIED_EXEC,
&json!({"action": "poll", "session_id": "run-7"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(tracker.take_verification_result_lost_notice());
}
#[test]
fn unified_exec_inferred_poll_on_lost_session_while_pending_grants_fix_window() {
// No explicit `action`: a bare `session_id` infers a poll follow-up,
// so the lost-session branch must still fire.
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let lost_session = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::UNIFIED_EXEC.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"exec session 'run-7' not found. Copy the exact `session_id` from the original run response"
.to_string(),
),
});
assert!(update_repetition_tracker(
&mut tracker,
&lost_session,
tools::UNIFIED_EXEC,
&json!({"session_id": "run-7"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(tracker.take_verification_result_lost_notice());
}
#[test]
fn unified_exec_wait_unrelated_failure_while_pending_grants_no_fix_window() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let unrelated = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::UNIFIED_EXEC.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"session_id is required for command session wait".to_string(),
),
});
assert!(!update_repetition_tracker(
&mut tracker,
&unrelated,
tools::UNIFIED_EXEC,
&json!({"action": "wait"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(!tracker.take_verification_result_lost_notice());
}
#[test]
fn unified_exec_wait_lost_session_without_pending_gate_grants_nothing() {
let mut tracker = LoopTracker::new();
let lost_session = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::UNIFIED_EXEC.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"exec session 'run-7' not found. Copy the exact `session_id` from the original run response"
.to_string(),
),
});
assert!(!update_repetition_tracker(
&mut tracker,
&lost_session,
tools::UNIFIED_EXEC,
&json!({"action": "wait", "session_id": "run-7"}),
));
assert!(!tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(!tracker.take_verification_result_lost_notice());
}
#[test]
fn unified_exec_run_action_with_lost_session_text_takes_no_follow_up_path() {
// A fresh `run` creates its session, so it is not a follow-up: the
// error text alone must not open the lost-result window. (A run whose
// command classifies as Verification still takes the generic
// verifier-loss branch; `echo` classifies as Inspection, so nothing
// is granted here.)
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let failure = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::UNIFIED_EXEC.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"exec session 'run-7' not found. Copy the exact `session_id` from the original run response"
.to_string(),
),
});
assert!(!update_repetition_tracker(
&mut tracker,
&failure,
tools::UNIFIED_EXEC,
&json!({"action": "run", "command": "echo hi"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(!tracker.take_verification_result_lost_notice());
}
#[test]
fn read_pty_session_poll_on_lost_pty_session_while_pending_grants_fix_window() {
// A verifier waited on via a PTY session poll whose session died
// reports "PTY session '<id>' not found" — the same lost-result shape
// as the exec-session manager, and it needs the same bounded window.
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let lost_session = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::READ_PTY_SESSION.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"PTY session 'pty-3' not found".to_string(),
),
});
assert!(update_repetition_tracker(
&mut tracker,
&lost_session,
tools::READ_PTY_SESSION,
&json!({"session_id": "pty-3"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(!mutation_blocked_until_verification(&tracker, tools::EDIT_FILE, &json!({"path": "src/lib.rs"})));
assert!(tracker.take_verification_result_lost_notice());
assert!(!tracker.take_verification_result_lost_notice(), "the notice is one-shot");
}
#[test]
fn send_pty_input_on_lost_pty_session_while_pending_grants_fix_window() {
// `send_pty_input` is a session follow-up (carries `session_id`, never
// classifies as Verification), so a dead session there grants too.
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let lost_session = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::SEND_PTY_INPUT.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"PTY session 'pty-3' not found".to_string(),
),
});
assert!(update_repetition_tracker(
&mut tracker,
&lost_session,
tools::SEND_PTY_INPUT,
&json!({"session_id": "pty-3", "input": "q"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(tracker.take_verification_result_lost_notice());
}
#[test]
fn create_pty_session_run_with_lost_session_text_takes_no_follow_up_path() {
// A fresh PTY `run` creates its session, so it is not a follow-up:
// the error text alone must not open the lost-result window.
// (`cargo check` classifies as Verification, so use `echo` which
// classifies as Inspection — nothing is granted here.)
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let failure = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::CREATE_PTY_SESSION.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"PTY session 'pty-3' not found".to_string(),
),
});
assert!(!update_repetition_tracker(
&mut tracker,
&failure,
tools::CREATE_PTY_SESSION,
&json!({"command": "echo hi"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(!tracker.take_verification_result_lost_notice());
}
#[test]
fn pty_follow_up_lost_session_without_pending_gate_grants_nothing() {
let mut tracker = LoopTracker::new();
let lost_session = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::READ_PTY_SESSION.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"PTY session 'pty-3' not found".to_string(),
),
});
assert!(!update_repetition_tracker(
&mut tracker,
&lost_session,
tools::READ_PTY_SESSION,
&json!({"session_id": "pty-3"}),
));
assert!(!tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(!tracker.take_verification_result_lost_notice());
}
#[test]
fn pty_follow_up_unrelated_failure_while_pending_grants_no_fix_window() {
// "no longer writable" is a live-session failure, not a lost session:
// it carries no "not found", so the lost-result branch must not fire.
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
let unrelated = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::READ_PTY_SESSION.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"PTY session 'pty-3' is no longer writable".to_string(),
),
});
assert!(!update_repetition_tracker(
&mut tracker,
&unrelated,
tools::READ_PTY_SESSION,
&json!({"session_id": "pty-3"}),
));
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, 0);
assert!(!tracker.take_verification_result_lost_notice());
}
#[test]
fn verification_snapshot_bundle_round_trips_without_drift() {
let tracker = LoopTracker::with_verification_snapshot((true, FAILED_VERIFICATION_FIX_ALLOWANCE));
assert_eq!(tracker.verification_snapshot(), (true, FAILED_VERIFICATION_FIX_ALLOWANCE));
let cleared = LoopTracker::with_verification_snapshot((false, FAILED_VERIFICATION_FIX_ALLOWANCE));
assert_eq!(cleared.verification_snapshot(), (false, 0));
}
#[test]
fn harness_verifier_override_must_be_standalone_or_pure_chain() {
use super::resolve_harness_verifier_command;
let dir = tempfile::TempDir::new().expect("workspace");
let config_with = |override_command: &str| {
let mut vt_cfg = vtcode_core::config::loader::VTCodeConfig::default();
vt_cfg.agent.harness.verification.default_verifier_override = Some(override_command.to_string());
vt_cfg
};
// Valid overrides win over detection (empty dir detects nothing).
let vt_cfg = config_with("cargo nextest run -p mycrate");
assert_eq!(
resolve_harness_verifier_command(Some(&vt_cfg), dir.path()).as_deref(),
Some("cargo nextest run -p mycrate")
);
// Pure-`&&` verifier chains are truthful and accepted.
let vt_cfg = config_with("cargo fmt --all -- --check && cargo check --locked");
assert!(resolve_harness_verifier_command(Some(&vt_cfg), dir.path()).is_some());
// Piped, joined, and mutating overrides fall back to detection, which
// finds nothing here — never executing attacker- or typo-shaped text.
for bad in [
"cargo check --locked | tail -5",
"cargo check; cargo test",
"cargo check || cargo test",
"rm -rf /tmp/scratch",
" ",
] {
let vt_cfg = config_with(bad);
assert_eq!(resolve_harness_verifier_command(Some(&vt_cfg), dir.path()), None, "must not resolve: {bad:?}");
}
// Fallback works when detection finds a marker: the override loses.
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").expect("Cargo.toml");
let vt_cfg = config_with("cargo check | tail -5");
assert_eq!(
resolve_harness_verifier_command(Some(&vt_cfg), dir.path()).as_deref(),
Some("cargo check --locked")
);
}
#[test]
fn verification_auto_recovery_budget_is_bounded_and_cleared_on_success() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
for _ in 0..MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS {
assert!(tracker.record_verification_auto_recovery_with_limit(MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS));
}
assert!(!tracker.record_verification_auto_recovery_with_limit(MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS));
assert_eq!(tracker.verification_auto_recovery_attempts(), MAX_VERIFICATION_AUTO_RECOVERY_ATTEMPTS);
// A fresh turn starts with a fresh budget.
let fresh = LoopTracker::with_verification_snapshot((true, 0));
assert_eq!(fresh.verification_auto_recovery_attempts(), 0);
// A successful standalone verifier clears the budget with the gate.
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(&mut tracker, &success, tools::EXEC_COMMAND, &json!({"cmd": "cargo check --locked"}));
assert!(!tracker.verification_is_pending());
assert_eq!(tracker.verification_auto_recovery_attempts(), 0);
}
#[test]
fn auto_execute_verifier_is_one_shot_per_turn_until_success() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
tracker.consecutive_mutations = BLIND_EDITING_THRESHOLD;
assert!(tracker.should_auto_execute_verifier());
tracker.record_auto_verification_executed();
assert!(!tracker.should_auto_execute_verifier());
// A failed verifier keeps the gate but does not re-arm execution.
let failed = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 1}),
stdout: None,
modified_files: vec![],
command_success: false,
});
update_repetition_tracker(&mut tracker, &failed, tools::EXEC_COMMAND, &json!({"cmd": "cargo check --locked"}));
assert!(tracker.verification_is_pending());
assert!(!tracker.should_auto_execute_verifier());
// A fresh turn re-arms; success clears the flag with the gate.
let fresh = LoopTracker::with_verification_snapshot((true, 0));
assert!(fresh.should_auto_execute_verifier());
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(&mut tracker, &success, tools::EXEC_COMMAND, &json!({"cmd": "cargo check --locked"}));
assert!(!tracker.verification_is_pending());
assert!(!tracker.auto_verification_executed);
}
#[test]
fn logged_compound_inspections_do_not_trigger_anti_blind_pressure() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for command in [
"cat README.md && printf '\\n--- git status ---\\n' && git status --short",
"wc -l README.md; rg -n '^#' README.md",
"git diff --stat; find docs -maxdepth 2 -type f | sort | head -40",
] {
update_repetition_tracker(&mut tracker, &success, tools::EXEC_COMMAND, &json!({"cmd":command}));
}
assert_eq!(tracker.consecutive_mutations, 0);
assert!(!tracker.verification_is_pending());
assert_eq!(tracker.consecutive_navigations, 3);
}
#[test]
fn awk_range_inspections_do_not_trigger_anti_blind_pressure() {
// Regression for session-vtcode-20260921T023834Z: six consecutive
// read-only `awk` page reads tripped the blind-editing gate because
// `awk` was missing from the read-only allow-list.
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for _ in 0..BLIND_EDITING_THRESHOLD {
update_repetition_tracker(
&mut tracker,
&success,
tools::EXEC_COMMAND,
&json!({"cmd": "awk 'NR>=297 && NR<=312' README.md"}),
);
}
assert_eq!(tracker.consecutive_mutations, 0);
assert!(!tracker.verification_is_pending());
assert!(!mutation_blocked_until_verification(
&tracker,
tools::EXEC_COMMAND,
&json!({"cmd": "awk 'NR>=297 && NR<=312' README.md"}),
));
}
#[test]
fn awk_write_primitives_still_trigger_anti_blind_pressure() {
// Asymmetric counterpart: real writes (including gawk `@` indirect
// calls) must still count as mutations so the fix cannot
// over-correct into a fail-open.
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for _ in 0..BLIND_EDITING_THRESHOLD {
update_repetition_tracker(
&mut tracker,
&success,
tools::EXEC_COMMAND,
&json!({"cmd": "awk -v f=system 'BEGIN{@f(\"id\")}' README.md"}),
);
}
assert_eq!(tracker.consecutive_mutations, BLIND_EDITING_THRESHOLD);
assert!(tracker.verification_is_pending());
assert!(mutation_blocked_until_verification(
&tracker,
tools::EXEC_COMMAND,
&json!({"cmd": "awk '{print > \"out.txt\"}' README.md"}),
));
}
#[cfg(unix)]
#[test]
fn logged_compound_inspection_with_unix_stderr_suppression_does_not_trigger_pressure() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
let command = r###"git diff --stat; find docs -maxdepth 2 -type f | sort | head -40; rg -n "vtcode init|vtcode models|full-auto|run-debug|cargo install" docs/user-guide docs/installation docs/development 2>/dev/null | head -50"###;
update_repetition_tracker(&mut tracker, &success, tools::EXEC_COMMAND, &json!({"cmd":command}));
assert_eq!(tracker.consecutive_mutations, 0);
assert_eq!(tracker.consecutive_navigations, 1);
}
#[test]
fn only_a_completed_verification_clears_pending_mutation_pressure() {
let mut tracker = LoopTracker::new();
let edit = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for _ in 0..BLIND_EDITING_THRESHOLD {
update_repetition_tracker(&mut tracker, &edit, tools::EDIT_FILE, &json!({"path":"src/lib.rs"}));
}
assert!(tracker.verification_is_pending());
let failed_check = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::EXEC_COMMAND.to_string(),
vtcode_core::tools::registry::ToolErrorType::ExecutionError,
"check could not start".to_string(),
),
});
update_repetition_tracker(
&mut tracker,
&failed_check,
tools::EXEC_COMMAND,
&json!({"cmd":"cargo nextest run"}),
);
assert!(tracker.verification_is_pending());
update_repetition_tracker(&mut tracker, &edit, tools::EXEC_COMMAND, &json!({"cmd":"cargo nextest run"}));
assert!(!tracker.verification_is_pending());
assert_eq!(tracker.consecutive_mutations, 0);
}
#[test]
fn carried_verification_checkpoint_clears_after_successful_check() {
let mut tracker = LoopTracker::with_verification_snapshot((true, 0));
let successful_check = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"exit_code": 0}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&successful_check,
tools::EXEC_COMMAND,
&json!({"cmd":"cargo check --locked"}),
);
assert!(!tracker.verification_is_pending());
}
#[test]
fn verification_snapshot_round_trips_through_session_state() {
let tracker = LoopTracker::with_verification_snapshot((true, FAILED_VERIFICATION_FIX_ALLOWANCE));
assert_eq!(tracker.verification_snapshot(), (true, FAILED_VERIFICATION_FIX_ALLOWANCE));
assert_eq!(LoopTracker::new().verification_snapshot(), (false, 0));
}
#[test]
fn repetition_tracker_ignores_cancellations() {
let mut tracker = LoopTracker::new();
let outcome = ToolPipelineOutcome::from_status(ToolExecutionStatus::Cancelled);
update_repetition_tracker(&mut tracker, &outcome, "edit_file", &json!({"path":"src/main.rs"}));
assert_eq!(tracker.max_count_filtered(|_| false), 0);
}
#[test]
fn reset_after_balancer_recovery_preserves_anti_blind_state() {
let mut tracker = LoopTracker::new();
tracker.record("code_search:{\"query\":\"Widget\"}".to_string());
tracker.record("code_search:{\"query\":\"Widget\"}".to_string());
tracker.consecutive_mutations = 2;
tracker.verification_pending = true;
tracker.fix_edits_remaining = FAILED_VERIFICATION_FIX_ALLOWANCE;
tracker.verification_warning_emitted = true;
tracker.verification_block_notice_emitted = true;
tracker.verification_result_lost_notice_pending = true;
tracker.piped_verification_notice_pending = true;
tracker.consecutive_navigations = 4;
tracker.consecutive_low_signal_navigations = 3;
tracker.total_low_signal_navigations = 7;
tracker.record_low_signal("code_search::Widget::src".to_string());
tracker.navigation_loop_recoveries = 3;
tracker.reset_after_balancer_recovery();
assert_eq!(tracker.max_count_filtered(|_| false), 0);
assert_eq!(tracker.max_low_signal_count(), 0);
assert_eq!(tracker.consecutive_mutations, 2);
assert!(tracker.verification_pending);
assert!(tracker.verification_is_pending());
assert_eq!(tracker.fix_edits_remaining, FAILED_VERIFICATION_FIX_ALLOWANCE);
assert!(tracker.verification_warning_emitted);
assert!(tracker.verification_block_notice_emitted);
assert!(tracker.verification_result_lost_notice_pending);
assert!(tracker.piped_verification_notice_pending);
assert_eq!(tracker.consecutive_navigations, 0);
assert_eq!(tracker.consecutive_low_signal_navigations, 0);
assert_eq!(tracker.total_low_signal_navigations, 0);
assert_eq!(tracker.navigation_loop_recoveries, 3);
}
#[test]
fn balancer_recovery_cannot_postpone_the_mutation_threshold() {
let mut tracker = LoopTracker::new();
for _ in 0..(BLIND_EDITING_THRESHOLD - 1) {
tracker.record_successful_mutation();
}
assert!(!tracker.verification_is_pending());
tracker.reset_after_balancer_recovery();
tracker.record_successful_mutation();
assert_eq!(tracker.consecutive_mutations, BLIND_EDITING_THRESHOLD);
assert!(tracker.verification_is_pending());
}
#[test]
fn shell_activity_distinguishes_inspection_verification_and_mutation() {
for command in [
"rg -n 'LoopTracker' src",
"find src -name '*.rs'",
"cat Cargo.toml",
"sed -n '1,80p' src/main.rs",
] {
assert_eq!(
classify_shell_activity(tools::EXEC_COMMAND, &json!({"cmd":command})),
ShellActivity::Inspection,
"{command}"
);
}
for command in [
"cargo check --locked",
"cargo nextest run -p vtcode",
"cargo clippy --all-targets",
"cargo build --release",
"./scripts/check-dev.sh --changed",
"cargo check --locked > build.log",
"cargo check &> build.log",
] {
assert_eq!(
classify_shell_activity(tools::EXEC_COMMAND, &json!({"cmd":command})),
ShellActivity::Verification,
"{command}"
);
}
for command in [
"cargo nextest run -p vtcode 2>&1 | head -c 4000",
"cargo check | head -40",
] {
assert_eq!(
classify_shell_activity(tools::EXEC_COMMAND, &json!({"cmd":command})),
ShellActivity::Mutation,
"verification pipelines require reliable aggregate status: {command}"
);
}
assert_eq!(
classify_shell_activity(tools::EXEC_COMMAND, &json!({"cmd":"sed -i '' 's/a/b/' src/lib.rs"})),
ShellActivity::Mutation
);
assert_eq!(
classify_shell_activity(tools::EXEC_COMMAND, &json!({"cmd":"rm output && cargo check"})),
ShellActivity::Mutation
);
}
#[test]
fn inspection_commands_increment_navigation_instead_of_resetting_it() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for command in [
"rg LoopTracker src",
"find src -name '*.rs'",
"cat Cargo.toml",
"sed -n '1,20p' src/main.rs",
] {
update_repetition_tracker(&mut tracker, &success, tools::EXEC_COMMAND, &json!({"cmd":command}));
}
assert_eq!(tracker.consecutive_navigations, 4);
}
#[test]
fn navigation_tracking_ignores_nonsemantic_preview_controls() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: json!({"stdout":"useful source"}),
stdout: None,
modified_files: vec![],
command_success: true,
});
let command = "sed -n '278,420p' src/startup/mod.rs";
update_repetition_tracker(
&mut tracker,
&success,
tools::EXEC_COMMAND,
&json!({"cmd": command, "command": command, "action": "run"}),
);
update_repetition_tracker(
&mut tracker,
&success,
tools::EXEC_COMMAND,
&json!({
"cmd": command,
"command": command,
"max_output_tokens": 8000,
"action": "run"
}),
);
assert_eq!(tracker.consecutive_navigations, 2);
assert_eq!(tracker.repeated_navigation_count(), 1);
assert_eq!(tracker.consecutive_low_signal_navigations, 0);
}
#[test]
fn productive_navigation_resets_only_consecutive_low_signal_count() {
let mut tracker = LoopTracker::new();
let miss = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: json!({"results":[]}),
stdout: None,
modified_files: vec![],
command_success: true,
});
let hit = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: json!({"results":[{"path":"src/lib.rs"}]}),
stdout: None,
modified_files: vec![],
command_success: true,
});
for query in ["missing-a", "missing-b"] {
update_repetition_tracker(&mut tracker, &miss, tools::CODE_SEARCH, &json!({"query":query, "path":"src"}));
}
update_repetition_tracker(
&mut tracker,
&hit,
tools::CODE_SEARCH,
&json!({"query":"LoopTracker", "path":"src"}),
);
assert_eq!(tracker.consecutive_low_signal_navigations, 0);
assert_eq!(tracker.total_low_signal_navigations, 2);
}
#[test]
fn verification_resets_all_low_signal_navigation_counts() {
let mut tracker = LoopTracker::new();
tracker.consecutive_low_signal_navigations = 6;
tracker.total_low_signal_navigations = 10;
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&success,
tools::UNIFIED_EXEC,
&json!({"action":"run", "command":"cargo check --locked"}),
);
assert_eq!(tracker.consecutive_low_signal_navigations, 0);
assert_eq!(tracker.total_low_signal_navigations, 0);
}
#[test]
fn consecutive_mutations_increments_on_edit() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
// edit_file is classified as mutating
update_repetition_tracker(
&mut tracker,
&success,
"edit_file",
&json!({"path":"src/lib.rs","old_str":"a","new_str":"b"}),
);
assert_eq!(tracker.consecutive_mutations, 1);
assert_eq!(tracker.consecutive_navigations, 0);
update_repetition_tracker(&mut tracker, &success, "write_to_file", &json!({"path":"src/lib.rs","content":"x"}));
assert_eq!(tracker.consecutive_mutations, 2);
}
#[test]
fn execution_tool_resets_mutation_counter() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
// Two mutations
update_repetition_tracker(
&mut tracker,
&success,
"edit_file",
&json!({"path":"a","old_str":"x","new_str":"y"}),
);
update_repetition_tracker(
&mut tracker,
&success,
"edit_file",
&json!({"path":"b","old_str":"x","new_str":"y"}),
);
assert_eq!(tracker.consecutive_mutations, 2);
// Execution tool resets
update_repetition_tracker(
&mut tracker,
&success,
tools::UNIFIED_EXEC,
&json!({"action":"run","command":"cargo check"}),
);
assert_eq!(tracker.consecutive_mutations, 0);
assert_eq!(tracker.consecutive_navigations, 0);
}
#[test]
fn reads_increment_navigation_counter() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(&mut tracker, &success, tools::READ_FILE, &json!({"path":"src/main.rs"}));
assert_eq!(tracker.consecutive_navigations, 1);
assert_eq!(tracker.consecutive_mutations, 0);
update_repetition_tracker(&mut tracker, &success, tools::GREP_FILE, &json!({"pattern":"foo","path":"src/"}));
assert_eq!(tracker.consecutive_navigations, 2);
}
#[test]
fn mutation_resets_navigation_counter() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
// Several reads
for _ in 0..5 {
update_repetition_tracker(&mut tracker, &success, tools::READ_FILE, &json!({"path":"src/main.rs"}));
}
assert_eq!(tracker.consecutive_navigations, 5);
// A mutation resets navigation counter
update_repetition_tracker(
&mut tracker,
&success,
"edit_file",
&json!({"path":"src/lib.rs","old_str":"a","new_str":"b"}),
);
assert_eq!(tracker.consecutive_navigations, 0);
assert_eq!(tracker.consecutive_mutations, 1);
}
#[test]
fn task_tracker_does_not_increment_mutations_in_planning() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&success,
tools::TASK_TRACKER,
&json!({"action":"create","items":["step"]}),
);
assert_eq!(tracker.consecutive_mutations, 0);
assert_eq!(tracker.consecutive_navigations, 0);
}
#[test]
fn task_tracker_does_not_increment_mutations() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&success,
tools::TASK_TRACKER,
&json!({"action":"create","items":["step"]}),
);
assert_eq!(tracker.consecutive_mutations, 0);
assert_eq!(tracker.consecutive_navigations, 0);
}
#[test]
fn plan_file_write_does_not_increment_mutations() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&success,
tools::UNIFIED_FILE,
&json!({"action":"write","path":".vtcode/plans/my-plan.md","content":"text"}),
);
assert_eq!(tracker.consecutive_mutations, 0);
assert_eq!(tracker.consecutive_navigations, 0);
}
#[test]
fn non_plan_file_write_still_increments_mutations() {
let mut tracker = LoopTracker::new();
let success = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&success,
tools::UNIFIED_FILE,
&json!({"action":"write","path":"src/lib.rs","content":"text"}),
);
assert_eq!(tracker.consecutive_mutations, 1);
assert_eq!(tracker.consecutive_navigations, 0);
}
#[test]
fn argument_error_detection_includes_required_update_fields() {
assert!(check_is_argument_error("Tool execution failed: 'index' is required for 'update' (1-indexed)"));
}
#[test]
fn low_signal_tracker_groups_empty_search_results_by_family() {
let mut tracker = LoopTracker::new();
let miss = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"results":[]}),
stdout: None,
modified_files: vec![],
command_success: true,
});
// Different queries produce separate family keys, so each counts as its
// own family while the agent explores one path.
update_repetition_tracker(
&mut tracker,
&miss,
tools::CODE_SEARCH,
&json!({"query":"Widget", "path":"src", "result_types":["definition"]}),
);
update_repetition_tracker(
&mut tracker,
&miss,
tools::CODE_SEARCH,
&json!({"query":"Result", "path":"src", "result_types":["usage"]}),
);
update_repetition_tracker(
&mut tracker,
&miss,
tools::CODE_SEARCH,
&json!({"query":"Result<", "path":"src", "result_types":["text"]}),
);
assert_eq!(tracker.max_low_signal_count(), 1);
}
#[test]
fn low_signal_tracker_groups_identical_searches_in_same_family() {
let mut tracker = LoopTracker::new();
let miss = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({"results":[]}),
stdout: None,
modified_files: vec![],
command_success: true,
});
let args = json!({"query":"TODO","path":"src","file_types":["rust"]});
update_repetition_tracker(&mut tracker, &miss, tools::CODE_SEARCH, &args);
update_repetition_tracker(&mut tracker, &miss, tools::CODE_SEARCH, &args);
update_repetition_tracker(&mut tracker, &miss, tools::CODE_SEARCH, &args);
assert_eq!(tracker.max_low_signal_count(), 3);
}
#[test]
fn low_signal_tracker_ignores_empty_search_results_with_recovery_guidance() {
let mut tracker = LoopTracker::new();
let guided = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({
"results": [],
"hint": "Try narrowing the path.",
"is_recoverable": true,
"next_action": "Retry with narrower filters."
}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&guided,
tools::CODE_SEARCH,
&json!({"query":"run", "path":"src/agent", "result_types":["definition"]}),
);
assert_eq!(tracker.max_low_signal_count(), 0);
}
#[test]
fn low_signal_tracker_does_not_hide_structured_search_errors_as_empty_results() {
let mut tracker = LoopTracker::new();
let failure_like = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({
"results": [],
"error": "permission denied while searching the workspace"
}),
stdout: None,
modified_files: vec![],
command_success: true,
});
update_repetition_tracker(
&mut tracker,
&failure_like,
tools::CODE_SEARCH,
&json!({"query":"secret", "path":"src"}),
);
assert_eq!(tracker.max_low_signal_count(), 0);
assert_eq!(tracker.consecutive_low_signal_navigations, 0);
}
#[test]
fn low_signal_tracker_counts_missing_read_failures() {
let mut tracker = LoopTracker::new();
let miss = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::UNIFIED_FILE.to_string(),
vtcode_core::tools::registry::ToolErrorType::ResourceNotFound,
"Resource not found: vtcode-tui/src/main.rs".to_string(),
),
});
// Two reads of the same path with different offsets are *different*
// slices (paginated exploration), not a retry loop. The slice-aware
// family key keeps them as distinct families, each with count 1.
// Regression: previously both collapsed into one family with count 2,
// which falsely tripped the family cap when the model paginated a
// missing file (checkpoint turn_613 pattern).
update_repetition_tracker(
&mut tracker,
&miss,
tools::UNIFIED_FILE,
&json!({"action":"read","path":"vtcode-tui/src/main.rs"}),
);
update_repetition_tracker(
&mut tracker,
&miss,
tools::UNIFIED_FILE,
&json!({"action":"read","path":"vtcode-tui/src/main.rs","offset":40}),
);
assert_eq!(
tracker.max_low_signal_count(),
1,
"paginated reads (different offset) must be distinct families, not one family with count 2"
);
}
#[test]
fn low_signal_tracker_counts_identical_missing_read_failures() {
// True retry loop: same path + same slice, repeated. The low-signal
// count must accumulate so the turn balancer can stop the churn.
let mut tracker = LoopTracker::new();
let miss = ToolPipelineOutcome::from_status(ToolExecutionStatus::Failure {
error: vtcode_core::tools::registry::ToolExecutionError::new(
tools::UNIFIED_FILE.to_string(),
vtcode_core::tools::registry::ToolErrorType::ResourceNotFound,
"Resource not found: vtcode-tui/src/main.rs".to_string(),
),
});
let identical_args = json!({"action":"read","path":"vtcode-tui/src/main.rs"});
update_repetition_tracker(&mut tracker, &miss, tools::UNIFIED_FILE, &identical_args);
update_repetition_tracker(&mut tracker, &miss, tools::UNIFIED_FILE, &identical_args);
assert_eq!(
tracker.max_low_signal_count(),
2,
"identical retry reads must accumulate into one family with count 2"
);
}
#[test]
fn low_signal_tracker_counts_grep_style_shell_misses() {
let mut tracker = LoopTracker::new();
let miss = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({
"command": "grep -n 'missing' vtcode-tui/src/main.rs",
"exit_code": 1,
"output": ""
}),
stdout: None,
modified_files: vec![],
command_success: false,
});
update_repetition_tracker(
&mut tracker,
&miss,
tools::EXEC_COMMAND,
&json!({"cmd":"grep -n 'missing' vtcode-tui/src/main.rs"}),
);
update_repetition_tracker(
&mut tracker,
&miss,
tools::EXEC_COMMAND,
&json!({"cmd":"grep -n \"missing\" vtcode-tui/src/main.rs"}),
);
assert_eq!(tracker.max_low_signal_count(), 2);
assert_eq!(tracker.consecutive_low_signal_navigations, 2);
assert_eq!(tracker.total_low_signal_navigations, 2);
}
#[test]
fn low_signal_tracker_does_not_count_grep_style_errors_as_no_match() {
let mut tracker = LoopTracker::new();
let error = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({
"command": "rg missing restricted",
"exit_code": 2,
"output": ""
}),
stdout: None,
modified_files: vec![],
command_success: false,
});
update_repetition_tracker(&mut tracker, &error, tools::EXEC_COMMAND, &json!({"cmd":"rg missing restricted"}));
assert_eq!(tracker.max_low_signal_count(), 0);
assert_eq!(tracker.consecutive_low_signal_navigations, 0);
assert_eq!(tracker.total_low_signal_navigations, 0);
}
#[test]
fn low_signal_tracker_does_not_hide_grep_errors_as_no_match() {
let mut tracker = LoopTracker::new();
let failure = ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({
"command": "rg missing restricted",
"exit_code": 1,
"stdout": "",
"stderr": "permission denied",
}),
stdout: None,
modified_files: vec![],
command_success: false,
});
update_repetition_tracker(&mut tracker, &failure, tools::EXEC_COMMAND, &json!({"cmd":"rg missing restricted"}));
assert_eq!(tracker.max_low_signal_count(), 0);
assert_eq!(tracker.consecutive_low_signal_navigations, 0);
}
// --- read_normalized_signature_key tests ---
#[test]
fn read_normalized_signature_key_normalizes_file_operation_read_offset() {
let args_a = json!({"action": "read", "path": "src/lib.rs", "offset": 0, "limit": 100});
let args_b = json!({"action": "read", "path": "src/lib.rs", "offset": 50, "limit": 200});
let key_a = read_normalized_signature_key("file_operation", &args_a);
let key_b = read_normalized_signature_key("file_operation", &args_b);
assert_eq!(key_a, key_b, "same file read with different offset/limit should produce the same normalized key");
}
#[test]
fn read_normalized_signature_key_preserves_encoding() {
let utf8 = json!({"action": "read", "path": "src/lib.rs", "encoding": "utf8"});
let base64 = json!({"action": "read", "path": "src/lib.rs", "encoding": "base64"});
assert_ne!(
read_normalized_signature_key("file_operation", &utf8),
read_normalized_signature_key("file_operation", &base64),
"different encodings produce different tool output and must not reuse one another"
);
}
#[test]
fn read_normalized_signature_key_differentiates_different_paths() {
let args_a = json!({"action": "read", "path": "src/lib.rs"});
let args_b = json!({"action": "read", "path": "src/main.rs"});
let key_a = read_normalized_signature_key("file_operation", &args_a);
let key_b = read_normalized_signature_key("file_operation", &args_b);
assert_ne!(key_a, key_b, "different paths must produce different keys");
}
#[test]
fn read_normalized_signature_key_includes_code_search_limit_and_normalises_filter_order() {
let args_a = json!({
"query": "Widget",
"path": "src",
"file_types": ["rust", "typescript"],
"result_types": ["text", "definition"],
"max_results": 10
});
let args_b = json!({
"query": "Widget",
"path": "src",
"file_types": ["typescript", "rs"],
"result_types": ["definition", "text"],
"max_results": 100
});
let key_a = read_normalized_signature_key(tools::CODE_SEARCH, &args_a);
let key_b = read_normalized_signature_key(tools::CODE_SEARCH, &args_b);
assert_ne!(key_a, key_b, "different effective limits must not share one code-search replay identity");
let args_default = json!({
"query": " Widget ",
"path": "src",
"file_types": ["rs", "typescript"],
"result_types": ["definition", "text"]
});
let args_explicit_default = json!({
"query": "Widget",
"path": "src",
"file_types": ["typescript", "rust"],
"result_types": ["text", "definition"],
"max_results": 20
});
assert_eq!(
read_normalized_signature_key(tools::CODE_SEARCH, &args_default),
read_normalized_signature_key(tools::CODE_SEARCH, &args_explicit_default),
"omitted and explicit default limits must share replay identity"
);
}
#[test]
fn read_normalized_signature_key_preserves_mutation_for_write() {
let args_a = json!({"path": "src/lib.rs", "content": "old"});
let args_b = json!({"path": "src/lib.rs", "content": "new"});
let key_a = read_normalized_signature_key("file_operation", &args_a);
let key_b = read_normalized_signature_key("file_operation", &args_b);
assert_ne!(key_a, key_b, "mutating writes must NOT be normalized away");
}
#[test]
fn find_duplicate_in_history_matches_normalized_read() {
use vtcode_core::llm::provider as uni;
// find_duplicate_in_history uses read_normalized_signature_key, which
// strips offset/limit for file reads. A later unrelated Assistant batch
// must not obscure the earlier matching call and result pair.
// Verify normalization: same file + different offset/limit → same key
let key_a = read_normalized_signature_key(
tools::UNIFIED_FILE,
&json!({"action":"read","path":"src/lib.rs","offset":0,"limit":100}),
);
let key_b = read_normalized_signature_key(
tools::UNIFIED_FILE,
&json!({"action":"read","path":"src/lib.rs","offset":50,"limit":500}),
);
assert_eq!(key_a, key_b, "same file read with different offset/limit should normalize to the same key");
// Verify: different file → different key
let key_c = read_normalized_signature_key(
tools::UNIFIED_FILE,
&json!({"action":"read","path":"src/main.rs","offset":0,"limit":100}),
);
assert_ne!(key_a, key_c, "different files must produce different normalized keys");
// Verify: code-search result limits remain distinct while filter ordering normalises away.
let s_key_a = read_normalized_signature_key(
tools::CODE_SEARCH,
&json!({"query":"Widget","path":"src","file_types":["rust","typescript"],"result_types":["text","definition"],"max_results":10}),
);
let s_key_b = read_normalized_signature_key(
tools::CODE_SEARCH,
&json!({"query":"Widget","path":"src","file_types":["typescript","rs"],"result_types":["definition","text"],"max_results":100}),
);
assert_ne!(s_key_a, s_key_b, "different effective limits must not share one code-search replay identity");
// Verify: write NOT normalized
let w_key_a = read_normalized_signature_key(
tools::UNIFIED_FILE,
&json!({"action":"write","path":"src/lib.rs","content":"old"}),
);
let w_key_b = read_normalized_signature_key(
tools::UNIFIED_FILE,
&json!({"action":"write","path":"src/lib.rs","content":"new"}),
);
assert_ne!(w_key_a, w_key_b, "writes must not be normalized away");
// Verify: find_duplicate_in_history still works for EXACT match
let mut history: Vec<uni::Message> = Vec::new();
history.push(uni::Message::assistant_with_tools(
"read".into(),
vec![uni::ToolCall::function(
"tc_exact".into(),
tools::UNIFIED_FILE.into(),
serde_json::to_string(&json!({"action":"read","path":"src/lib.rs","offset":0,"limit":100})).unwrap(),
)],
));
history.push(uni::Message {
role: uni::MessageRole::Tool,
content: uni::MessageContent::text("exact content".into()),
tool_call_id: Some("tc_exact".into()),
..Default::default()
});
// Second pair (different file) so the scan finds A₀'s Tool after A₁:
history.push(uni::Message::assistant_with_tools(
"read other".into(),
vec![uni::ToolCall::function(
"tc_other".into(),
tools::UNIFIED_FILE.into(),
serde_json::to_string(&json!({"action":"read","path":"src/main.rs"})).unwrap(),
)],
));
history.push(uni::Message {
role: uni::MessageRole::Tool,
content: uni::MessageContent::text("other content".into()),
tool_call_id: Some("tc_other".into()),
..Default::default()
});
let result = find_duplicate_in_history(
&history,
tools::UNIFIED_FILE,
&json!({"action":"read","path":"src/lib.rs","offset":0,"limit":50}),
Path::new("."),
);
assert_eq!(result.as_deref(), Some("exact content"));
}
#[test]
fn find_duplicate_in_history_respects_normalised_code_search_limit() {
let original_args = json!({
"query": "Widget",
"path": "src",
"file_types": ["rust", "typescript"],
"result_types": ["text", "definition"],
"max_results": 10
});
let history = vec![
uni::Message::assistant_with_tools(
"search".into(),
vec![uni::ToolCall::function(
"tc_search".into(),
tools::CODE_SEARCH.into(),
serde_json::to_string(&original_args).unwrap(),
)],
),
uni::Message {
role: uni::MessageRole::Tool,
content: uni::MessageContent::text("{\"results\":[]}".into()),
tool_call_id: Some("tc_search".into()),
..Default::default()
},
];
let different_limit = find_duplicate_in_history(
&history,
tools::CODE_SEARCH,
&json!({
"query": "Widget",
"path": "src",
"file_types": ["typescript", "rs"],
"result_types": ["definition", "text"],
"max_results": 100
}),
Path::new("."),
);
assert_eq!(different_limit, None);
let equivalent_default_history = vec![
uni::Message::assistant_with_tools(
"search".into(),
vec![uni::ToolCall::function(
"tc_default".into(),
tools::CODE_SEARCH.into(),
serde_json::to_string(&json!({
"query": "Widget",
"path": "src",
"max_results": 20
}))
.unwrap(),
)],
),
uni::Message {
role: uni::MessageRole::Tool,
content: uni::MessageContent::text("{\"results\":[1]}".into()),
tool_call_id: Some("tc_default".into()),
..Default::default()
},
];
let reused = find_duplicate_in_history(
&equivalent_default_history,
tools::CODE_SEARCH,
&json!({"query": " Widget ", "path": "src"}),
Path::new("."),
);
assert_eq!(reused.as_deref(), Some("{\"results\":[1]}"));
}
#[test]
fn working_history_code_search_replay_stops_at_in_scope_mutation() {
let search_args = json!({"query": "Widget", "path": "src"});
let search_call = uni::Message::assistant_with_tools(
"search".into(),
vec![uni::ToolCall::function(
"search_call".into(),
tools::CODE_SEARCH.into(),
serde_json::to_string(&search_args).unwrap(),
)],
);
let search_result = uni::Message {
role: uni::MessageRole::Tool,
content: uni::MessageContent::text("{\"results\":[\"cached\"]}".into()),
tool_call_id: Some("search_call".into()),
..Default::default()
};
let mutation = |path: &str, result: serde_json::Value| {
let patch = format!("*** Begin Patch\n*** Update File: {path}\n@@\n-Widget\n+Gadget\n*** End Patch\n");
vec![
uni::Message::assistant_with_tools(
"edit".into(),
vec![uni::ToolCall::function(
"edit_call".into(),
tools::APPLY_PATCH.into(),
serde_json::to_string(&json!({"patch": patch})).unwrap(),
)],
),
uni::Message::tool_response("edit_call".into(), result.to_string()),
]
};
let mut in_scope_history = vec![search_call.clone(), search_result.clone()];
in_scope_history.extend(mutation("src/widget.rs", json!({"success": true})));
assert!(
find_duplicate_in_history(&in_scope_history, tools::CODE_SEARCH, &search_args, Path::new("."),).is_none(),
"editing src/widget.rs after searching src must force a fresh search"
);
let mut status_success_history = vec![search_call.clone(), search_result.clone()];
status_success_history
.extend(mutation("src/widget.rs", json!({"status": "success", "output": "patch applied"})));
assert!(
find_duplicate_in_history(&status_success_history, tools::CODE_SEARCH, &search_args, Path::new("."),)
.is_none(),
"the established successful status shape must invalidate replay"
);
let mut unrelated_history = vec![search_call.clone(), search_result.clone()];
unrelated_history.extend(mutation("tests/widget.rs", json!({"success": true})));
assert_eq!(
find_duplicate_in_history(&unrelated_history, tools::CODE_SEARCH, &search_args, Path::new("."),).as_deref(),
Some("{\"results\":[\"cached\"]}"),
"an unrelated edit may reuse the prior scoped search"
);
for failure in [
json!({"success": false, "error": "patch rejected"}),
json!({"error": {"message": "execution denied by policy"}}),
json!({"failure_kind": "timeout"}),
json!({"status": "failed"}),
json!({"status": "denied"}),
json!({"success": null}),
json!({"output": "patch output without an outcome"}),
json!(["non-object mutation output"]),
] {
let mut failed_history = vec![search_call.clone(), search_result.clone()];
failed_history.extend(mutation("src/widget.rs", failure));
assert_eq!(
find_duplicate_in_history(&failed_history, tools::CODE_SEARCH, &search_args, Path::new("."),)
.as_deref(),
Some("{\"results\":[\"cached\"]}"),
"a mutation without explicit positive success evidence must preserve reuse"
);
}
let mut unexecuted_history = vec![search_call, search_result];
let unexecuted_mutation = mutation("src/widget.rs", json!({"success": true}));
unexecuted_history.push(unexecuted_mutation[0].clone());
assert_eq!(
find_duplicate_in_history(&unexecuted_history, tools::CODE_SEARCH, &search_args, Path::new("."),)
.as_deref(),
Some("{\"results\":[\"cached\"]}"),
"an unexecuted mutation call must preserve reuse"
);
}
#[test]
fn mutation_tool_response_success_rejects_malformed_and_conflicting_shapes() {
let response = |content: &str| uni::Message::tool_response("edit_call".into(), content.into());
assert!(tool_response_is_success(&response(r#"{"success":true}"#)));
assert!(tool_response_is_success(&response(r#"{"status":"success","output":"patch applied"}"#,)));
for content in [
"not json",
"null",
r#"{"success":null,"status":"success"}"#,
r#"{"success":true,"status":"failed"}"#,
r#"{"success":true,"failure_kind":"timeout"}"#,
r#"{"success":true,"error":"execution denied"}"#,
] {
assert!(
!tool_response_is_success(&response(content)),
"mutation outcome must not count as successful: {content}"
);
}
}
#[test]
fn duplicate_history_reuse_rejects_failed_results() {
let args = json!({"query": "needle", "path": "src"});
let call = || {
uni::Message::assistant_with_tools(
"search".into(),
vec![uni::ToolCall::function(
"search_call".into(),
tools::CODE_SEARCH.into(),
serde_json::to_string(&args).unwrap(),
)],
)
};
for failure in [
r#"{"success":false,"output":"partial"}"#,
r#"{"status":"timeout","output":"partial"}"#,
r#"{"error":"permission denied"}"#,
"Error: command failed",
"timed out while reading",
"failed to execute command",
"denied by policy",
"blocked until verification",
"not executed",
] {
let history = vec![
call(),
uni::Message::tool_response("search_call".into(), failure.into()),
];
assert!(
find_duplicate_in_history(&history, tools::CODE_SEARCH, &args, Path::new(".")).is_none(),
"failed result must not be replayed: {failure}"
);
}
for success in [r#"{"results":[]}"#, "[]", "plain successful output"] {
let history = vec![
call(),
uni::Message::tool_response("search_call".into(), success.into()),
];
assert_eq!(
find_duplicate_in_history(&history, tools::CODE_SEARCH, &args, Path::new(".")).as_deref(),
Some(success)
);
}
}
#[test]
fn working_history_code_search_replay_rejects_reused_patch_call_id() {
let search_args = json!({"query": "Widget", "path": "src"});
let shared_call_id = "call_0";
let search_call = uni::Message::assistant_with_tools(
"search".into(),
vec![uni::ToolCall::function(
shared_call_id.into(),
tools::CODE_SEARCH.into(),
serde_json::to_string(&search_args).unwrap(),
)],
);
let search_result =
uni::Message::tool_response(shared_call_id.into(), "{\"results\":[\"genuine search output\"]}".into());
let patch = "*** Begin Patch\n*** Update File: src/widget.rs\n@@\n-Widget\n+Gadget\n*** End Patch\n";
let patch_call = uni::Message::assistant_with_tools(
"edit".into(),
vec![uni::ToolCall::function(
shared_call_id.into(),
tools::APPLY_PATCH.into(),
serde_json::to_string(&json!({"patch": patch})).unwrap(),
)],
);
let mut successful_history = vec![
search_call.clone(),
search_result.clone(),
patch_call.clone(),
uni::Message::tool_response(
shared_call_id.into(),
json!({"success": true, "output": "patch output"}).to_string(),
),
];
assert!(
find_duplicate_in_history(&successful_history, tools::CODE_SEARCH, &search_args, Path::new("."),).is_none(),
"a successful in-scope patch must invalidate the genuine earlier search result"
);
successful_history.pop();
successful_history.push(uni::Message::tool_response(
shared_call_id.into(),
json!({"success": false, "error": "patch rejected", "output": "patch output"}).to_string(),
));
assert_eq!(
find_duplicate_in_history(&successful_history, tools::CODE_SEARCH, &search_args, Path::new("."),)
.as_deref(),
Some("{\"results\":[\"genuine search output\"]}"),
"a failed patch must preserve the earlier search without returning patch output"
);
}
#[test]
fn read_extent_covers_query_rejects_larger_limit() {
// Cached limit=200 must NOT cover query limit=220
assert!(!read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200}),
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":220}),
));
// Cached limit=200 covers query limit=200 (same)
assert!(read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200}),
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200}),
));
// Cached limit=200 covers query limit=100 (subset)
assert!(read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200}),
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":100}),
));
// Different offset must not match
assert!(!read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200}),
&json!({"action":"read","path":"AGENTS.md","offset":50,"limit":200}),
));
}
#[test]
fn read_extent_covers_query_rejects_different_raw_mode() {
// Non-raw cached must NOT cover raw=true query
assert!(!read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200}),
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200,"raw":true}),
));
// Raw cached covers raw query
assert!(read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200,"raw":true}),
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200,"raw":true}),
));
// Raw cached must NOT cover non-raw query
assert!(!read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200,"raw":true}),
&json!({"action":"read","path":"AGENTS.md","offset":0,"limit":200}),
));
}
#[test]
fn read_extent_covers_query_handles_missing_limit() {
// Both missing limit → matches (same default read)
assert!(read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md"}),
&json!({"action":"read","path":"AGENTS.md"}),
));
// Cached has limit, query doesn't → mismatch
assert!(!read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","limit":200}),
&json!({"action":"read","path":"AGENTS.md"}),
));
// Cached has no limit, query does → mismatch
assert!(!read_extent::extent_covers(
&json!({"action":"read","path":"AGENTS.md","limit":200}),
&json!({"action":"read","path":"AGENTS.md"}),
));
}
fn successful_exec_output() -> ToolPipelineOutcome {
ToolPipelineOutcome::from_status(ToolExecutionStatus::Success {
output: serde_json::json!({}),
stdout: None,
modified_files: vec![],
command_success: true,
})
}
#[test]
fn coarse_listing_count_groups_same_root_rescans() {
let mut tracker = LoopTracker::new();
assert_eq!(tracker.max_coarse_listing_count(), 0);
// Same root across flag and quote variations: one coarse family.
for command in ["ls src", "ls \"src\"", "ls -1 src/"] {
update_repetition_tracker(
&mut tracker,
&successful_exec_output(),
tools::EXEC_COMMAND,
&json!({"cmd":command}),
);
}
assert_eq!(tracker.max_coarse_listing_count(), 3);
assert_eq!(tracker.dominant_churn(), Some(("exec::inspection::ls::src".to_string(), 3)));
}
#[test]
fn coarse_listing_count_separates_distinct_roots() {
let mut tracker = LoopTracker::new();
// Distinct trees are legitimate exploration, not churn.
for command in ["ls src", "ls crates", "ls tests"] {
update_repetition_tracker(
&mut tracker,
&successful_exec_output(),
tools::EXEC_COMMAND,
&json!({"cmd":command}),
);
}
assert_eq!(tracker.max_coarse_listing_count(), 1);
}
#[test]
fn coarse_listing_count_keeps_binaries_in_separate_families() {
let mut tracker = LoopTracker::new();
for command in ["ls src", "find crates -name lib.rs", "fd main src"] {
update_repetition_tracker(
&mut tracker,
&successful_exec_output(),
tools::EXEC_COMMAND,
&json!({"cmd":command}),
);
}
assert_eq!(tracker.max_coarse_listing_count(), 1);
}
#[test]
fn coarse_listing_count_ignores_grep_style_searches() {
let mut tracker = LoopTracker::new();
for command in ["rg foo src", "rg bar crates", "grep -r baz src"] {
update_repetition_tracker(
&mut tracker,
&successful_exec_output(),
tools::EXEC_COMMAND,
&json!({"cmd":command}),
);
}
assert_eq!(tracker.max_coarse_listing_count(), 0);
// `rg`/`grep` must not enter the coarse ledger at all, so they can
// never be promoted to low-signal or named as dominant churn.
assert_eq!(tracker.max_low_signal_count(), 0);
assert_eq!(tracker.dominant_churn(), None);
assert_eq!(tracker.low_signal_tool_calls, 0);
}
#[test]
fn same_pattern_grep_searches_do_not_promote_to_low_signal() {
// Regression for turn_1303/turn_1304 (`exec::inspection::grep::enum ×5`)
// and turn_1291 (`exec::inspection::rg::pub ×5`): five distinct
// successful searches sharing one pattern (`enum` / `pub`) across
// different files/flags are legitimate research, not churn. They must
// not be promoted into the low-signal ledger and must not trip early
// recovery on their own.
let mut tracker = LoopTracker::new();
for command in [
"grep -n \"enum Commands\" -A 80 src/cli/mod.rs",
"grep -n \"enum Command\\|pub enum\" src/cli/mod.rs",
"grep -rn \"enum Commands\" crates/codegen/vtcode-core/src",
"grep -rn \"enum ExecSubcommand\" -A 30 crates/codegen/vtcode-core/src/cli/args/",
"grep -rn \"enum ScheduleSubcommand\" crates/codegen/vtcode-core/src/cli/args/",
] {
update_repetition_tracker(
&mut tracker,
&successful_exec_output(),
tools::EXEC_COMMAND,
&json!({"cmd":command}),
);
}
assert_eq!(tracker.max_coarse_listing_count(), 0);
assert_eq!(tracker.max_low_signal_count(), 0);
assert_eq!(tracker.dominant_churn(), None);
let mut tracker = LoopTracker::new();
for command in [
"rg -n 'pub enum Commands' src/ -A 40",
"rg -n 'pub enum Commands' crates/codegen/vtcode-core/src/cli/args/mod.rs -A 50",
"rg -n 'pub enum Commands' crates/codegen/vtcode-core/src/cli/args/mod.rs -A 600",
"rg -n 'pub enum Provider|Gemini|OpenAI' crates/codegen/vtcode-llm/src",
"rg -n 'pub enum SecretCommand|Add|List' crates/codegen/vtcode-core/src/cli/args/secret.rs",
] {
update_repetition_tracker(
&mut tracker,
&successful_exec_output(),
tools::EXEC_COMMAND,
&json!({"cmd":command}),
);
}
assert_eq!(tracker.max_coarse_listing_count(), 0);
assert_eq!(tracker.max_low_signal_count(), 0);
assert_eq!(tracker.dominant_churn(), None);
}
#[test]
fn coarse_inspection_root_extracts_first_positional_token() {
assert_eq!(coarse_inspection_root("ls src/"), "src");
assert_eq!(coarse_inspection_root("ls \"src/\""), "src");
assert_eq!(coarse_inspection_root("find src-tauri/ -maxdepth 1"), "src-tauri");
assert_eq!(coarse_inspection_root("ls -la"), ".");
assert_eq!(coarse_inspection_root("ls"), ".");
// Heuristic: an option value can be picked up as the root, which only
// fragments families and keeps detection conservative.
assert_eq!(coarse_inspection_root("ls --width 80 src"), "80");
}
#[test]
fn promoted_listing_repeat_counts_toward_low_signal_telemetry() {
// Three same-root successful listings: the third is promoted into the
// low-signal ledger, so telemetry records exactly one low-signal call
// even though every listing succeeded (the old
// `low_signal_tool_calls:0 on 3×find` diagnostics gap).
let mut tracker = LoopTracker::new();
for command in ["ls src", "ls -1 src", "ls src/"] {
update_repetition_tracker(
&mut tracker,
&successful_exec_output(),
tools::EXEC_COMMAND,
&json!({"cmd":command}),
);
}
assert_eq!(tracker.low_signal_tool_calls, 1);
assert_eq!(tracker.total_low_signal_navigations, 1);
assert_eq!(tracker.consecutive_low_signal_navigations, 1);
assert_eq!(tracker.max_coarse_listing_count(), 3);
}
}