1use 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 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 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}