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    /// Class of the previously recorded recovery response; `None` until two
58    /// responses have been classified. Compared against `response_class` to
59    /// detect a repeated identical failure. Cleared by clarification, which
60    /// carries new information; preserved across explicit retries and model
61    /// switches so consecutive identical shapes stay visible.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub previous_response_class: Option<PlanningResponseClass>,
64    /// Runtime-owned guard cause retained independently of concise human summaries.
65    pub guard_reason: Option<String>,
66    pub provider: String,
67    pub model: String,
68    /// Prevents repeated automatic presentation of the same exhausted incident.
69    pub input_prompt_shown: bool,
70    /// Opens the existing picker at the next idle interaction boundary.
71    pub model_selection_pending: bool,
72    /// One explicitly requested research batch for newly missing clarification facts.
73    pub targeted_research: Option<String>,
74}
75
76impl PlanningRecoverySnapshot {
77    /// One synthesis and at most two corrections, shared across continuations.
78    pub const MAX_REQUESTS: u8 = 3;
79
80    /// Admit one tool-free request without reopening research or replenishing allowance.
81    pub fn admit_request(&mut self) -> bool {
82        if matches!(self.outcome, PlanningOutcome::AwaitingInput | PlanningOutcome::AwaitingApproval)
83            || self.requests_used >= Self::MAX_REQUESTS
84        {
85            return false;
86        }
87        self.outcome = if self.requests_used == 0 {
88            PlanningOutcome::Synthesizing
89        } else {
90            PlanningOutcome::Repairing
91        };
92        self.requests_used += 1;
93        true
94    }
95
96    /// Shift the latest classified response shape into the previous slot
97    /// before recording the new one, so the handoff can tell a repeated
98    /// identical failure from a new one. All writers must use this instead
99    /// of assigning `response_class` directly.
100    pub fn record_response_class(&mut self, class: PlanningResponseClass) {
101        self.previous_response_class = self.response_class;
102        self.response_class = Some(class);
103    }
104
105    /// Called only after an explicit human retry, clarification, or model selection.
106    pub fn begin_explicit_cycle(&mut self) {
107        self.requests_used = 0;
108        self.cycle = self.cycle.saturating_add(1);
109        self.input_prompt_shown = false;
110        self.targeted_research = None;
111        self.outcome = PlanningOutcome::Synthesizing;
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn request_admission_remains_bounded_after_serialized_resume_and_explicit_retry() {
121        let mut snapshot = PlanningRecoverySnapshot {
122            research_closed: true,
123            best_draft: Some("public draft".into()),
124            ..Default::default()
125        };
126        assert!(snapshot.admit_request());
127        assert_eq!(snapshot.outcome, PlanningOutcome::Synthesizing);
128        let mut resumed: PlanningRecoverySnapshot =
129            serde_json::from_str(&serde_json::to_string(&snapshot).unwrap()).unwrap();
130        assert!(resumed.admit_request());
131        assert!(resumed.admit_request());
132        assert_eq!(resumed.requests_used, 3);
133        assert!(!resumed.admit_request());
134        resumed.outcome = PlanningOutcome::AwaitingInput;
135        assert!(!resumed.admit_request());
136        resumed.begin_explicit_cycle();
137        assert_eq!(resumed.cycle, 1);
138        assert!(resumed.research_closed);
139        assert_eq!(resumed.best_draft.as_deref(), Some("public draft"));
140        assert!(resumed.admit_request());
141    }
142}