Skip to main content

cookie_agent_tui/state/
runtime.rs

1//! Coherent protocol-10 runtime snapshot state machine.
2
3use cookie_agent_protocol::{
4    CatalogSource, RuntimeChangedNotification, RuntimeRevision, RuntimeSnapshotV1,
5};
6
7pub const EMPTY_RUNTIME_GUIDANCE: &str = "type /connect to continue";
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub enum RuntimePhase {
11    Loading,
12    Empty,
13    Ready,
14    Stale,
15    Bootstrap,
16    ErrorRetry,
17}
18
19#[derive(Clone, Debug)]
20pub struct RuntimeState {
21    snapshot: Option<RuntimeSnapshotV1>,
22    phase: RuntimePhase,
23    durable_explanation: Option<String>,
24}
25
26impl Default for RuntimeState {
27    fn default() -> Self {
28        Self {
29            snapshot: None,
30            phase: RuntimePhase::Loading,
31            durable_explanation: None,
32        }
33    }
34}
35
36impl RuntimeState {
37    pub fn snapshot(&self) -> Option<&RuntimeSnapshotV1> {
38        self.snapshot.as_ref()
39    }
40
41    pub const fn phase(&self) -> RuntimePhase {
42        self.phase
43    }
44
45    pub fn durable_explanation(&self) -> Option<&str> {
46        self.durable_explanation.as_deref()
47    }
48
49    pub fn revision(&self) -> Option<&RuntimeRevision> {
50        self.snapshot
51            .as_ref()
52            .map(|snapshot| &snapshot.runtime_revision)
53    }
54
55    pub fn is_empty(&self) -> bool {
56        self.phase == RuntimePhase::Empty
57    }
58
59    pub fn install_initial(&mut self, snapshot: RuntimeSnapshotV1) {
60        if self.snapshot.is_none() {
61            self.install(snapshot);
62        }
63    }
64
65    /// Install an operation response only if no newer runtime was installed
66    /// while that operation was in flight.
67    pub fn install_response(
68        &mut self,
69        baseline: Option<&RuntimeRevision>,
70        snapshot: RuntimeSnapshotV1,
71    ) -> bool {
72        if self.revision() == Some(&snapshot.runtime_revision) {
73            return false;
74        }
75        if self.revision() != baseline {
76            return false;
77        }
78        self.install(snapshot);
79        true
80    }
81
82    /// Runtime notifications form a predecessor-linked monotonic stream.
83    /// Duplicate and stale/out-of-order notifications never replace the
84    /// currently installed coherent snapshot.
85    pub fn apply_notification(&mut self, changed: RuntimeChangedNotification) -> bool {
86        if self.revision() == Some(&changed.snapshot.runtime_revision) {
87            return false;
88        }
89        if let Some(current) = self.revision()
90            && changed.previous_revision.as_ref() != Some(current)
91        {
92            return false;
93        }
94        self.install(changed.snapshot);
95        true
96    }
97
98    pub fn set_error(&mut self, error: impl Into<String>) {
99        let error = error.into();
100        self.durable_explanation = Some(error);
101        if self.snapshot.is_none() {
102            self.phase = RuntimePhase::ErrorRetry;
103        }
104    }
105
106    fn install(&mut self, snapshot: RuntimeSnapshotV1) {
107        let empty = snapshot.models.is_empty();
108        let (phase, explanation) = if empty {
109            (RuntimePhase::Empty, catalog_explanation(&snapshot))
110        } else if snapshot.catalog_source == CatalogSource::Bootstrap {
111            (
112                RuntimePhase::Bootstrap,
113                Some("Using bundled bootstrap catalog; provider and model availability may be limited.".into()),
114            )
115        } else if snapshot.catalog_state.stale {
116            (RuntimePhase::Stale, catalog_explanation(&snapshot))
117        } else {
118            (RuntimePhase::Ready, None)
119        };
120        self.snapshot = Some(snapshot);
121        self.phase = phase;
122        self.durable_explanation = explanation;
123    }
124}
125
126fn catalog_explanation(snapshot: &RuntimeSnapshotV1) -> Option<String> {
127    if snapshot.catalog_source == CatalogSource::Bootstrap {
128        return Some(
129            "Using bundled bootstrap catalog; provider and model availability may be limited."
130                .into(),
131        );
132    }
133    snapshot.catalog_state.last_error.as_ref().map(|error| {
134        format!(
135            "Using stale catalog cache after {} at {}: {}",
136            error.code, error.time, error.message
137        )
138    })
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn revision<T>() -> T
146    where
147        T: serde::de::DeserializeOwned,
148    {
149        serde_json::from_value(serde_json::json!(format!("sha256:{}", "a".repeat(64))))
150            .expect("revision")
151    }
152
153    fn snapshot(with_model: bool) -> RuntimeSnapshotV1 {
154        let model = cookie_agent_protocol::AvailableModelDescriptor {
155            key: "custom.test/model".parse().expect("model key"),
156            display_name: "Model".to_owned(),
157            capabilities: cookie_agent_protocol::ModelCapabilities {
158                input: [cookie_agent_protocol::Modality::Text]
159                    .into_iter()
160                    .collect(),
161                output: [cookie_agent_protocol::Modality::Text]
162                    .into_iter()
163                    .collect(),
164                context_tokens: 4096,
165                output_tokens: 1024,
166                tool_calling: true,
167                parallel_tool_calls: true,
168                structured_output: false,
169                reasoning: false,
170                temperature: true,
171                top_p: true,
172                seed: true,
173                native_replay: cookie_agent_protocol::ReplayCapability::Unsupported,
174                cancellation: cookie_agent_protocol::CancellationCapability::LocalOnly,
175                media: Default::default(),
176            },
177            variants: Vec::new(),
178            variant_order: Vec::new(),
179            default_variant: None,
180            behavior_fingerprint: cookie_agent_protocol::Sha256Digest::of_bytes(b"model"),
181        };
182        let selection = cookie_agent_protocol::ModelSelection {
183            model: model.key.clone(),
184            variant: None,
185        };
186        RuntimeSnapshotV1 {
187            snapshot_schema_version: cookie_agent_protocol::RuntimeSnapshotSchemaVersion::current(),
188            recipe_registry_revision: revision(),
189            catalog_revision: revision(),
190            catalog_source: CatalogSource::Network,
191            catalog_state: cookie_agent_protocol::CatalogRuntimeState {
192                stale: false,
193                provider_quarantine_count: 0,
194                model_quarantine_count: 0,
195                quarantine_digest: cookie_agent_protocol::Sha256Digest::of_bytes(b"quarantine"),
196                last_error: None,
197            },
198            provider_state_revision: revision(),
199            provider_store_generation: cookie_agent_protocol::ProviderStoreGeneration::new(1)
200                .expect("generation"),
201            model_revision: revision(),
202            agent_revision: revision(),
203            runtime_revision: revision(),
204            providers: Vec::new(),
205            models: if with_model { vec![model] } else { Vec::new() },
206            agents: if with_model {
207                vec![cookie_agent_protocol::AgentDescriptor {
208                    id: cookie_agent_protocol::AgentId::new("default").expect("agent ID"),
209                    preset: None,
210                    description: "Built-in default coding agent".to_owned(),
211                    mode: cookie_agent_protocol::AgentMode::Primary,
212                    enabled: true,
213                    runnable_as_root: true,
214                    resolved_fallback: vec![selection],
215                    delegation_targets: Vec::new(),
216                }]
217            } else {
218                Vec::new()
219            },
220        }
221    }
222
223    #[test]
224    fn empty_guidance_is_exact() {
225        assert_eq!(
226            EMPTY_RUNTIME_GUIDANCE.as_bytes(),
227            b"type /connect to continue"
228        );
229    }
230
231    #[test]
232    fn models_with_synthetic_agent_are_not_empty() {
233        let mut state = RuntimeState::default();
234        state.install_initial(snapshot(true));
235        assert_eq!(state.phase(), RuntimePhase::Ready);
236    }
237
238    #[test]
239    fn runtime_without_available_models_is_empty() {
240        let mut state = RuntimeState::default();
241        state.install_initial(snapshot(false));
242        assert_eq!(state.phase(), RuntimePhase::Empty);
243    }
244}