vtcode 0.137.0

A Rust-based terminal coding agent with modular architecture supporting multiple LLM providers
//! Planning workflow intent detection.
//!
//! This module isolates the logic for detecting user intent related to
//! planning workflow transitions (enter/exit/stay). It provides a clean
//! interface that decouples intent detection from the turn loop mechanics.
//!
//! ## Interface Guard Rails
//!
//! The `PlanningIntent` enum ensures that all possible intent states are
//! explicitly handled, preventing implicit fallthrough bugs. The detection
//! functions are pure (no side effects) and independently testable.

use vtcode_core::llm::provider as uni;
use vtcode_core::planning;

/// Represents the user's intent regarding planning workflow transitions.
///
/// This enum provides an exhaustive set of possible intents, ensuring
/// that all cases are handled explicitly rather than through boolean flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PlanningIntent {
    /// User wants to exit planning and start implementing.
    ExitAndImplement,
    /// User explicitly wants to stay in planning workflow.
    StayInPlanning,
    /// No planning-related intent detected.
    None,
}

/// Detect planning-related intent from user text.
///
/// This is the single entry point for intent detection. It checks for
/// stay-phrases first (higher priority), then exit-phrases, then
/// confirmation phrases. The phrase literals and matchers live in
/// `vtcode_core::planning` (the Codex bridge consumes the same source so the
/// two paths cannot drift).
///
/// # Arguments
/// * `text` - The user's input text
/// * `assistant_prompted_implementation` - Whether the assistant recently
///   asked the user if they want to implement
///
/// # Returns
/// A `PlanningIntent` indicating the user's intent.
pub(crate) fn detect_planning_intent(text: &str, assistant_prompted_implementation: bool) -> PlanningIntent {
    let normalized = planning::normalize_plan_intent(text);
    let trimmed = normalized.trim();

    // Priority 1: Explicit stay-in-planning phrases override everything.
    // Includes the bare token `edit`/`revise` — short-form replies to the
    // yes/no/edit HITL prompt surfaced when `request_user_input` is
    // unavailable (checkpoint turn_725). These route the user back into
    // planning revision without requiring the longer "keep planning" phrase.
    if planning::matches_stay_intent(&normalized) || is_stay_token(trimmed) {
        return PlanningIntent::StayInPlanning;
    }

    // Priority 2-3: Direct exit commands, approval words/phrases, and exit
    // trigger phrases (combined by `matches_exit_intent`).
    if planning::matches_exit_intent(&normalized) {
        return PlanningIntent::ExitAndImplement;
    }

    // Priority 4: Short confirmation when assistant recently prompted.
    if assistant_prompted_implementation && is_short_confirmation(trimmed) {
        return PlanningIntent::ExitAndImplement;
    }

    PlanningIntent::None
}

/// Detect intent to enter planning workflow from user text.
pub(crate) fn detect_enter_planning_intent(text: &str) -> bool {
    // Check for /plan command before normalization (slash is stripped by normalization).
    let trimmed_raw = text.trim();
    if trimmed_raw == "/plan" || trimmed_raw.to_ascii_lowercase().starts_with("/plan ") {
        return true;
    }

    let normalized = planning::normalize_plan_intent(text);
    planning::matches_enter_intent(&normalized)
}

/// Check if the assistant recently prompted for implementation.
///
/// Scans backward through working history to find the last assistant
/// message before the last user message, then checks if it contains
/// implementation-related cues.
pub(crate) fn assistant_recently_prompted_implementation(working_history: &[uni::Message]) -> bool {
    let Some(last_user_index) = working_history.iter().rposition(|msg| msg.role == uni::MessageRole::User) else {
        return false;
    };

    let Some(last_assistant_msg) = working_history[..last_user_index]
        .iter()
        .rev()
        .find(|msg| msg.role == uni::MessageRole::Assistant)
    else {
        return false;
    };

    let assistant_text = planning::normalize_plan_intent(&last_assistant_msg.content.as_text());
    planning::contains_implementation_cue(&assistant_text)
}

// ============================================================================
// Internal helpers (pure functions, no side effects)
// ============================================================================

/// Check if text is a short confirmation word.
fn is_short_confirmation(trimmed: &str) -> bool {
    let confirmation_tokens = [
        "yes",
        "y",
        "ok",
        "okay",
        "continue",
        "go",
        "go ahead",
        "proceed",
        "start",
        "start now",
        "begin",
        "begin now",
        "let s start",
        "lets start",
        "sounds good",
        "do it",
    ];
    confirmation_tokens.contains(&trimmed)
}

/// Check if text is a bare stay-in-planning token. These are the short-form
/// replies to the yes/no/edit HITL prompt (`edit`, `revise`) — the longer
/// phrases are caught by `matches_stay_intent`, but the bare tokens need
/// exact matching here so `edit` alone routes to revision (checkpoint
/// turn_725). `no` is intentionally NOT a stay token — it abandons the plan
/// and falls through to `PlanningIntent::None`, letting the model respond
/// naturally to the user's "no" rather than forcing them back into planning.
fn is_stay_token(trimmed: &str) -> bool {
    matches!(trimmed, "edit" | "revise")
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_implement_as_exit_intent() {
        assert_eq!(detect_planning_intent("implement", false), PlanningIntent::ExitAndImplement);
        assert_eq!(detect_planning_intent("Implement the plan.", false), PlanningIntent::ExitAndImplement);
    }

    #[test]
    fn detects_stay_in_planning_as_higher_priority() {
        assert_eq!(
            detect_planning_intent("Do not implement yet; keep planning.", false),
            PlanningIntent::StayInPlanning
        );
        assert_eq!(
            detect_planning_intent("Stay in planning workflow and don't implement.", false),
            PlanningIntent::StayInPlanning
        );
    }

    #[test]
    fn detects_short_confirmation_with_context() {
        assert_eq!(detect_planning_intent("yes", true), PlanningIntent::ExitAndImplement);
        assert_eq!(detect_planning_intent("continue", true), PlanningIntent::ExitAndImplement);
    }

    #[test]
    fn short_confirmation_without_context_is_none() {
        assert_eq!(
            detect_planning_intent("yes", false),
            PlanningIntent::ExitAndImplement // "yes" is a direct command
        );
        assert_eq!(detect_planning_intent("continue", false), PlanningIntent::None);
    }

    #[test]
    fn detects_approve_as_exit_intent() {
        assert_eq!(detect_planning_intent("approve", false), PlanningIntent::ExitAndImplement);
        assert_eq!(detect_planning_intent("approved", false), PlanningIntent::ExitAndImplement);
        assert_eq!(detect_planning_intent("approve the plan", false), PlanningIntent::ExitAndImplement);
        assert_eq!(detect_planning_intent("lgtm", false), PlanningIntent::ExitAndImplement);
        assert_eq!(detect_planning_intent("looks good, let's go", false), PlanningIntent::ExitAndImplement);
    }

    #[test]
    fn disapprove_is_not_exit_intent() {
        assert_eq!(detect_planning_intent("I disapprove, keep planning", false), PlanningIntent::StayInPlanning);
    }

    #[test]
    fn bare_edit_token_routes_to_stay_in_planning() {
        // The yes/no/edit HITL prompt (surfaced when request_user_input is
        // unavailable) must route the bare `edit` reply back to planning
        // revision without requiring the longer "keep planning" phrase.
        assert_eq!(detect_planning_intent("edit", false), PlanningIntent::StayInPlanning);
        assert_eq!(detect_planning_intent("revise", false), PlanningIntent::StayInPlanning);
        assert_eq!(detect_planning_intent("Edit the plan please", false), PlanningIntent::StayInPlanning);
    }

    #[test]
    fn bare_no_token_does_not_force_stay_in_planning() {
        // `no` abandons the plan — it falls through to None so the model can
        // respond naturally to the user's "no" rather than forcing them back.
        assert_eq!(detect_planning_intent("no", false), PlanningIntent::None);
    }

    #[test]
    fn detects_enter_planning_intent() {
        assert!(detect_enter_planning_intent("make a plan for this"));
        assert!(detect_enter_planning_intent("/plan"));
        assert!(detect_enter_planning_intent("before implementing, create a plan"));
    }

    #[test]
    fn non_intent_text_returns_none() {
        assert_eq!(detect_planning_intent("The implementation details are unclear.", false), PlanningIntent::None);
    }

    #[test]
    fn assistant_prompted_implementation_detects_cues() {
        let history = vec![
            uni::Message::assistant("Implement this plan?".to_string()),
            uni::Message::user("yes".to_string()),
        ];
        assert!(assistant_recently_prompted_implementation(&history));
    }

    #[test]
    fn assistant_prompted_implementation_requires_cue() {
        let history = vec![
            uni::Message::assistant("Continue planning and expand the risks section.".to_string()),
            uni::Message::user("yes".to_string()),
        ];
        assert!(!assistant_recently_prompted_implementation(&history));
    }
}