Skip to main content

vtcode_exec_events/
planning.rs

1//! Persisted planning recovery state. Answers to recovery prompts never grant approval.
2use serde::{Deserialize, Serialize};
3
4/// Runtime-owned planning outcome, independent of model prose and diagnostics.
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
6#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7#[serde(rename_all = "snake_case")]
8pub enum PlanningOutcome {
9    #[default]
10    Researching,
11    Synthesizing,
12    Repairing,
13    AwaitingInput,
14    AwaitingApproval,
15}
16
17/// Observable response shape, never the contents of private reasoning.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
20#[serde(rename_all = "snake_case")]
21pub enum PlanningResponseClass {
22    EmptyContent,
23    ReasoningOnly,
24    NativeToolCalls,
25    MalformedPlan,
26    Truncation,
27    ProviderFailure,
28    ValidPlan,
29    ClarifyingQuestion,
30}
31
32/// Complete bounded checkpoint for one planning request and its explicit retry cycles.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
35#[serde(default)]
36pub struct PlanningRecoverySnapshot {
37    pub outcome: PlanningOutcome,
38    /// Headless task identity prevents replaying another request's draft or allowance.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub request_id: Option<String>,
41    /// Full headless prompt/instructions identity; bounded display text is not sufficient.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub request_fingerprint: Option<u64>,
44    /// Once closed, automatic continuations cannot reopen research.
45    pub research_closed: bool,
46    /// Model requests admitted in this cycle: one synthesis and two corrections.
47    pub requests_used: u8,
48    /// Increased only by an explicit human retry, clarification, or model selection.
49    pub cycle: u32,
50    pub latest_request: String,
51    /// Bounded public tool evidence; private reasoning is never stored here.
52    pub evidence: Vec<String>,
53    pub best_draft: Option<String>,
54    pub best_draft_defects: Option<usize>,
55    pub validation_report: Option<String>,
56    pub response_class: Option<PlanningResponseClass>,
57    /// Runtime-owned guard cause retained independently of concise human summaries.
58    pub guard_reason: Option<String>,
59    pub provider: String,
60    pub model: String,
61    /// Prevents repeated automatic presentation of the same exhausted incident.
62    pub input_prompt_shown: bool,
63    /// Opens the existing picker at the next idle interaction boundary.
64    pub model_selection_pending: bool,
65    /// One explicitly requested research batch for newly missing clarification facts.
66    pub targeted_research: Option<String>,
67}
68
69impl PlanningRecoverySnapshot {
70    /// One synthesis and at most two corrections, shared across continuations.
71    pub const MAX_REQUESTS: u8 = 3;
72
73    /// Admit one tool-free request without reopening research or replenishing allowance.
74    pub fn admit_request(&mut self) -> bool {
75        if matches!(self.outcome, PlanningOutcome::AwaitingInput | PlanningOutcome::AwaitingApproval)
76            || self.requests_used >= Self::MAX_REQUESTS
77        {
78            return false;
79        }
80        self.outcome = if self.requests_used == 0 {
81            PlanningOutcome::Synthesizing
82        } else {
83            PlanningOutcome::Repairing
84        };
85        self.requests_used += 1;
86        true
87    }
88
89    /// Called only after an explicit human retry, clarification, or model selection.
90    pub fn begin_explicit_cycle(&mut self) {
91        self.requests_used = 0;
92        self.cycle = self.cycle.saturating_add(1);
93        self.input_prompt_shown = false;
94        self.targeted_research = None;
95        self.outcome = PlanningOutcome::Synthesizing;
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn request_admission_remains_bounded_after_serialized_resume_and_explicit_retry() {
105        let mut snapshot = PlanningRecoverySnapshot {
106            research_closed: true,
107            best_draft: Some("public draft".into()),
108            ..Default::default()
109        };
110        assert!(snapshot.admit_request());
111        assert_eq!(snapshot.outcome, PlanningOutcome::Synthesizing);
112        let mut resumed: PlanningRecoverySnapshot =
113            serde_json::from_str(&serde_json::to_string(&snapshot).unwrap()).unwrap();
114        assert!(resumed.admit_request());
115        assert!(resumed.admit_request());
116        assert_eq!(resumed.requests_used, 3);
117        assert!(!resumed.admit_request());
118        resumed.outcome = PlanningOutcome::AwaitingInput;
119        assert!(!resumed.admit_request());
120        resumed.begin_explicit_cycle();
121        assert_eq!(resumed.cycle, 1);
122        assert!(resumed.research_closed);
123        assert_eq!(resumed.best_draft.as_deref(), Some("public draft"));
124        assert!(resumed.admit_request());
125    }
126}