travelagent 1.11.1

Agent-first TUI code review tool
//! Agent-action confirmation state machine, extracted from `App`
//! in v1.6 (Phase H continuation) and encapsulated in v1.8.1.
//!
//! The pre-v1.6 `App` had four fields for this state machine scattered
//! among ~60 unrelated `pub` fields: `pending_agent_action`,
//! `agent_action_history`, `pending_forge_completion`,
//! `last_agent_decision`. All four move together across the bridge ↔
//! App boundary on every confirmation lifecycle event, so grouping
//! them into one struct makes the MCP bridge ↔ App dataflow legible
//! without surfing past 60 unrelated fields.
//!
//! **Encapsulation pass (v1.8.1):** the four fields are now private
//! (`pub(super)` for the single remaining tests-only probe on
//! `history`). Every state transition runs through an invariant-
//! preserving method on this struct so `pending → last_decision`
//! archival is atomic (`archive_with_decision`): call sites can no
//! longer forget to set `last_decision` after archiving, or archive
//! without clearing `pending`. Forge completion channel lifecycle
//! (`set_completion` / `take_completion` / `put_back_completion`) is
//! likewise single-sourced on the struct.
//!
//! App-level methods (`approve_pending_agent_action`,
//! `reject_pending_agent_action_with_reason`, `poll_forge_completion`,
//! `tick_agent_action_timeout`) still exist on `App` because they also
//! mutate `App.engine`, `App.dirty`, the status bar, and the MCP
//! notification queue — but their bodies now only call struct methods
//! for the `agent_action.*` bits, and the MCP bridge handlers
//! (`handle_propose_*`, `handle_cancel_confirmation`) also route
//! through those methods instead of writing fields directly.

use std::collections::VecDeque;

use super::{ConfirmationStatus, ForgeSubmitResult, LastAgentDecision, PendingAgentAction};

/// Cap on the recently-decided confirmations ring (duplicated here so
/// the struct can enforce the invariant without a cross-module
/// constant dependency — `CONFIRMATION_HISTORY_CAP` in `app/mod.rs`
/// is kept as the public-facing constant for tests and the status
/// handler that names the cap in wire messages).
const HISTORY_CAP: usize = super::CONFIRMATION_HISTORY_CAP;

/// The confirmation state machine for agent-proposed writes, grouped
/// out of `App` in v1.6 and encapsulated in v1.8.1. All state
/// transitions live on this struct so `pending → last_decision`
/// archival is a single atomic call — see module docs.
#[derive(Debug, Default)]
pub struct AgentActionState {
    /// Agent-proposed forge / mental-model / accept-test action
    /// awaiting human confirmation, if any. Only one pending action
    /// at a time — a second propose while this is `Some(..)` comes
    /// back as `"busy"`. Ephemeral (not persisted to the session
    /// file); if the TUI crashes mid-decision the agent can
    /// re-propose.
    pending: Option<PendingAgentAction>,
    /// Ring of recently-decided confirmations, capped at
    /// `CONFIRMATION_HISTORY_CAP`. Populated when `pending`
    /// transitions to a terminal status; lets
    /// `trv_get_confirmation_status(id)` answer after the modal
    /// closes. Drop-oldest on overflow.
    ///
    /// `pub(super)` only so the existing ring-cap test in
    /// `mcp_bridge::tests` can assert `history.len() == CAP` without
    /// introducing a public accessor just for test coverage.
    pub(super) history: VecDeque<PendingAgentAction>,
    /// Oneshot receiver for the forge call spawned by
    /// `approve_pending_agent_action`. `Some` while a forge submit
    /// is in flight; the main loop polls non-blocking each tick via
    /// `poll_forge_completion`. Cleared once the result lands.
    forge_completion: Option<tokio::sync::oneshot::Receiver<ForgeSubmitResult>>,
    /// Summary of the most recent terminal decision on the
    /// confirmation channel, populated by every approve / reject /
    /// timeout / cancel / forge-completion path. Surfaced in
    /// `review://status.agent_action.last_decision`.
    last_decision: Option<LastAgentDecision>,
}

impl AgentActionState {
    // --- read accessors ---

    /// Borrow the currently-pending action, if any.
    pub fn pending(&self) -> Option<&PendingAgentAction> {
        self.pending.as_ref()
    }

    /// Mutably borrow the currently-pending action, if any. Only used
    /// by tests to rewind `proposed_at_monotonic` past the timeout cut
    /// without waiting out five real minutes.
    pub fn pending_mut(&mut self) -> Option<&mut PendingAgentAction> {
        self.pending.as_mut()
    }

    /// Borrow the most-recent terminal decision, if any.
    pub fn last_decision(&self) -> Option<&LastAgentDecision> {
        self.last_decision.as_ref()
    }

    /// True when there is a pending action *and* it is still in the
    /// `Pending` state (i.e. not Executing/Succeeded/Failed/Rejected).
    /// Used by the confirmation-modal capture gate and busy-branch
    /// checks in the MCP bridge.
    pub fn has_waiting_pending(&self) -> bool {
        matches!(
            self.pending.as_ref().map(|a| &a.status),
            Some(ConfirmationStatus::Pending)
        )
    }

    /// Look up an action by id — pending slot first, then the history
    /// ring. `None` when the id is unknown.
    pub fn find(&self, id: &str) -> Option<&PendingAgentAction> {
        if let Some(p) = &self.pending
            && p.id == id
        {
            return Some(p);
        }
        self.history.iter().find(|p| p.id == id)
    }

    // --- propose / lifecycle ---

    /// Arm a fresh proposal as the currently-pending action. Caller
    /// must have verified `has_waiting_pending()` is false (the MCP
    /// propose handlers do this and route the busy branch through
    /// `record_transient_rejection`).
    pub fn arm_pending(&mut self, action: PendingAgentAction) {
        self.pending = Some(action);
    }

    /// Take the currently-pending action out for a state transition.
    /// Returns `None` if nothing is pending. Paired with either
    /// `put_back_pending` (when the caller decides not to transition
    /// after all) or `archive_with_decision` (terminal transition).
    pub fn take_pending(&mut self) -> Option<PendingAgentAction> {
        self.pending.take()
    }

    /// Put a previously-taken action back in the pending slot. Used
    /// by the `approve` / `reject` paths when the action turned out
    /// not to be in the right status for the transition (e.g. approve
    /// called while already Executing).
    pub fn put_back_pending(&mut self, action: PendingAgentAction) {
        self.pending = Some(action);
    }

    /// Archive a terminal action to the history ring *and* record the
    /// corresponding `LastAgentDecision` atomically. This is the
    /// central invariant of the state machine: every
    /// `pending → terminal` transition must update both fields in
    /// lockstep so status subscribers see a consistent view.
    ///
    /// Drop-oldest on history overflow.
    pub fn archive_with_decision(
        &mut self,
        action: PendingAgentAction,
        decision: LastAgentDecision,
    ) {
        if self.history.len() >= HISTORY_CAP {
            self.history.pop_front();
        }
        self.history.push_back(action);
        self.last_decision = Some(decision);
    }

    /// Record a transient rejection (e.g. a second propose landing on
    /// top of a still-pending one) without disturbing the real
    /// `pending` slot. Archives the transient action and updates
    /// `last_decision`; the currently-pending action stays put.
    ///
    /// Semantically identical to `archive_with_decision` — callers
    /// use this name to signal intent ("this is a busy-branch
    /// rejection, not a terminal transition of the pending action").
    pub fn record_transient_rejection(
        &mut self,
        rejected: PendingAgentAction,
        decision: LastAgentDecision,
    ) {
        self.archive_with_decision(rejected, decision);
    }

    // --- forge-completion channel ---

    /// Install the oneshot receiver for a spawned forge call. Called
    /// by `approve_pending_agent_action` once the forge task is
    /// spawned (and also by the synchronous error shortcuts that pre-
    /// seal the channel with an `Err` before returning).
    pub fn set_completion(&mut self, rx: tokio::sync::oneshot::Receiver<ForgeSubmitResult>) {
        self.forge_completion = Some(rx);
    }

    /// Take the oneshot receiver for polling. Returns `None` if no
    /// forge call is in flight.
    pub fn take_completion(&mut self) -> Option<tokio::sync::oneshot::Receiver<ForgeSubmitResult>> {
        self.forge_completion.take()
    }

    /// Put the receiver back when polling found no result yet (the
    /// forge task is still running). Paired with `take_completion`.
    pub fn put_back_completion(&mut self, rx: tokio::sync::oneshot::Receiver<ForgeSubmitResult>) {
        self.forge_completion = Some(rx);
    }

    // --- test-visibility helpers ---

    /// History ring length. Exposed for tests that want to verify
    /// the `CONFIRMATION_HISTORY_CAP` drop-oldest behaviour without
    /// reaching into the private field.
    pub fn history_len(&self) -> usize {
        self.history.len()
    }
}