1use serde::{Deserialize, Serialize};
9use std::fmt;
10
11use super::{
12 AgentRuntimeState, Session, AGENT_RUNTIME_STATE_VERSION, MODEL_CONTEXT_SCHEMA_VERSION,
13};
14
15pub const CURRENT_SESSION_VERSION: u32 = 2;
16
17pub fn same_session_tool_contract(left: Option<u32>, right: Option<u32>) -> bool {
21 matches!(
22 (left, right),
23 (None | Some(1), None | Some(1)) | (Some(2), Some(2))
24 )
25}
26
27pub fn independent_root_spawn_reference(reference: &str) -> bool {
28 let name = reference
29 .trim()
30 .rsplit("::")
31 .next()
32 .unwrap_or(reference)
33 .trim();
34 name.eq_ignore_ascii_case("spawn_session") || name.eq_ignore_ascii_case("spawnSession")
35}
36
37pub fn resolve_session_tool_reference(
42 version: Option<u32>,
43 reference: &str,
44 mut contains: impl FnMut(&str) -> bool,
45) -> Option<String> {
46 if !supported(version) {
47 return None;
48 }
49 if independent_root_spawn_reference(reference) {
50 let exact = reference.trim();
51 if contains(exact) {
52 return Some(exact.to_string());
53 }
54 let unqualified = exact.rsplit("::").next().unwrap_or(exact).trim();
55 if unqualified != exact && contains(unqualified) {
56 return Some(unqualified.to_string());
57 }
58 return contains("spawn_session").then(|| "spawn_session".to_string());
59 }
60 crate::resolve_tool_reference_name(reference, contains)
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum SessionCompatibilityStatus {
66 Compatible,
67 LegacyCompatible,
68 Conflict,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct SessionCompatibilityConflict {
73 pub code: String,
74 pub message: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct SessionCompatibilityReport {
79 pub session_version: Option<u32>,
80 pub current_session_version: u32,
81 pub status: SessionCompatibilityStatus,
82 pub history_readable: bool,
83 pub execution_allowed: bool,
84 pub conflicts: Vec<SessionCompatibilityConflict>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct SessionCompatibilityError {
89 pub compatibility: SessionCompatibilityReport,
90}
91
92impl fmt::Display for SessionCompatibilityError {
93 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94 f.write_str("session_execution_incompatible: ")?;
95 for (index, conflict) in self.compatibility.conflicts.iter().enumerate() {
96 if index != 0 {
97 f.write_str("; ")?;
98 }
99 write!(f, "{}: {}", conflict.code, conflict.message)?;
100 }
101 f.write_str(" Read the existing history with a compatible runtime, or start a new session with selected context and an explicit workspace. No state was migrated.")
102 }
103}
104
105impl std::error::Error for SessionCompatibilityError {}
106
107fn supported(version: Option<u32>) -> bool {
108 matches!(version, None | Some(1) | Some(2))
109}
110
111impl Session {
112 pub fn independent_root_spawn_contract(&self) -> bool {
115 self.session_version == Some(2)
116 }
117
118 pub fn observe_main_session_version(&mut self, main_version: Option<u32>) {
122 self.observed_runtime_session_version = Some(self.session_version);
123 self.session_version = main_version;
124 }
125
126 pub fn execution_compatibility(&self) -> SessionCompatibilityReport {
127 let mut conflicts = Vec::new();
128 if let Some(version) = self
129 .session_version
130 .filter(|_| !supported(self.session_version))
131 {
132 conflicts.push(SessionCompatibilityConflict {
133 code: "unsupported_session_version".into(),
134 message: format!("Session behavior version {version} is not supported; this runtime supports versions 1 and {CURRENT_SESSION_VERSION} and compatible legacy records."),
135 });
136 }
137 if let Some(runtime_version) = self.observed_runtime_session_version {
138 if runtime_version != self.session_version
139 && !same_session_tool_contract(runtime_version, self.session_version)
140 {
141 conflicts.push(SessionCompatibilityConflict {
142 code: "session_version_mismatch".into(),
143 message: format!("Canonical Session behavior {:?} and retained runtime behavior {:?} conflict.", self.session_version, runtime_version),
144 });
145 }
146 }
147 if let Some(AgentRuntimeState { version, .. }) = &self.agent_runtime_state {
148 if *version != AGENT_RUNTIME_STATE_VERSION {
149 conflicts.push(SessionCompatibilityConflict {
150 code: "unsupported_runtime_state_version".into(),
151 message: format!("Retained execution-state schema {version} is not supported; expected {AGENT_RUNTIME_STATE_VERSION}."),
152 });
153 }
154 }
155 if let Some(context) = &self.model_context_state {
156 if context.schema_version != MODEL_CONTEXT_SCHEMA_VERSION {
157 conflicts.push(SessionCompatibilityConflict {
158 code: "unsupported_model_context_version".into(),
159 message: format!("Retained model-context schema {} is not supported; expected {MODEL_CONTEXT_SCHEMA_VERSION}.", context.schema_version),
160 });
161 }
162 }
163 let execution_allowed = conflicts.is_empty();
164 SessionCompatibilityReport {
165 session_version: self.session_version,
166 current_session_version: CURRENT_SESSION_VERSION,
167 status: if !execution_allowed {
168 SessionCompatibilityStatus::Conflict
169 } else if self.session_version.is_none() {
170 SessionCompatibilityStatus::LegacyCompatible
171 } else {
172 SessionCompatibilityStatus::Compatible
173 },
174 history_readable: true,
175 execution_allowed,
176 conflicts,
177 }
178 }
179
180 pub fn check_execution_compatibility(&self) -> Result<(), SessionCompatibilityError> {
181 let compatibility = self.execution_compatibility();
182 if compatibility.execution_allowed {
183 Ok(())
184 } else {
185 Err(SessionCompatibilityError { compatibility })
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::{Message, SessionAuthorityIdentity};
194
195 #[test]
196 fn missing_stamp_is_assessed_and_compatible_without_mutation() {
197 let mut session = Session::new("legacy", "model");
198 session.session_version = None;
199 session.add_message(Message::user("keep history"));
200 let before = serde_json::to_value(&session).unwrap();
201 let report = session.execution_compatibility();
202 assert_eq!(report.status, SessionCompatibilityStatus::LegacyCompatible);
203 assert!(report.execution_allowed && report.history_readable);
204 session.check_execution_compatibility().unwrap();
205 assert_eq!(serde_json::to_value(&session).unwrap(), before);
206 }
207
208 #[test]
209 fn current_and_legacy_contracts_preserve_mode_and_identity_boundaries() {
210 for version in [None, Some(CURRENT_SESSION_VERSION)] {
211 for ultra in [false, true] {
212 for supervisor in [false, true] {
213 let mut session = Session::new(
214 if supervisor {
215 crate::DEFAULT_SUPERVISOR_SESSION_ID
216 } else {
217 "root"
218 },
219 "model",
220 );
221 session.session_version = version;
222 session.set_root_orchestration_only(ultra).unwrap();
223 if supervisor {
224 session.authority_identity = SessionAuthorityIdentity::Supervisor {
225 incarnation_id: uuid::Uuid::new_v4(),
226 };
227 }
228 let before = serde_json::to_value(&session).unwrap();
229 assert!(session.execution_compatibility().execution_allowed);
230 session.check_execution_compatibility().unwrap();
231 assert_eq!(serde_json::to_value(&session).unwrap(), before);
232 assert_eq!(
233 session.allows_model_tool_execution("Bash"),
234 !ultra && !supervisor
235 );
236 let mut child = Session::new_child_of("child", &session, "model", "child");
237 child.session_version = version;
238 assert!(child.execution_compatibility().execution_allowed);
239 assert!(child.allows_model_tool_execution("Bash"));
240 }
241 }
242 }
243 }
244
245 #[test]
246 fn unsupported_versions_are_diagnostics_not_deserialization_failures() {
247 for version in [0, CURRENT_SESSION_VERSION + 1, u32::MAX] {
248 let mut session = Session::new("future", "model");
249 session.session_version = Some(version);
250 let before = serde_json::to_value(&session).unwrap();
251 let reloaded: Session = serde_json::from_value(before.clone()).unwrap();
252 let report = reloaded.execution_compatibility();
253 assert!(report.history_readable);
254 assert!(!report.execution_allowed);
255 assert_eq!(report.conflicts[0].code, "unsupported_session_version");
256 assert!(reloaded
257 .check_execution_compatibility()
258 .unwrap_err()
259 .to_string()
260 .contains("session_execution_incompatible"));
261 assert_eq!(serde_json::to_value(reloaded).unwrap(), before);
262 }
263 }
264
265 #[test]
266 fn runtime_overlay_cannot_hide_an_unsupported_main_or_side_stamp() {
267 for (main, runtime) in [(Some(99), None), (Some(1), Some(99)), (None, Some(99))] {
268 let mut side = Session::new("pair", "model");
269 side.session_version = runtime;
270 side.observe_main_session_version(main);
271 assert_eq!(side.session_version, main);
272 assert!(!side.execution_compatibility().execution_allowed);
273 }
274 for (main, runtime) in [(None, Some(1)), (Some(1), None), (None, None)] {
275 let mut side = Session::new("pair", "model");
276 side.session_version = runtime;
277 side.observe_main_session_version(main);
278 assert_eq!(side.session_version, main);
279 assert!(side.execution_compatibility().execution_allowed);
280 }
281 }
282
283 #[test]
284 fn unsupported_retained_model_context_is_refused_without_reset() {
285 let mut session = Session::new("future-context", "model");
286 session.session_version = None;
287 let context = super::super::ModelContextState {
288 schema_version: 99,
289 state_revision: 7,
290 prefix_epoch: 3,
291 ..Default::default()
292 };
293 session.model_context_state = Some(context);
294 let before = serde_json::to_value(&session).unwrap();
295 let report = session.execution_compatibility();
296 assert!(!report.execution_allowed);
297 assert_eq!(
298 report.conflicts[0].code,
299 "unsupported_model_context_version"
300 );
301 assert_eq!(serde_json::to_value(session).unwrap(), before);
302 }
303
304 #[test]
305 fn unsupported_retained_runtime_state_is_refused_without_reset() {
306 let mut session = Session::new("future-runtime", "model");
307 session.session_version = None;
308 let mut state = AgentRuntimeState::new("retained-execution");
309 state.version = 99;
310 session.agent_runtime_state = Some(state);
311 let before = serde_json::to_value(&session).unwrap();
312 let report = session.execution_compatibility();
313 assert!(!report.execution_allowed);
314 assert_eq!(
315 report.conflicts[0].code,
316 "unsupported_runtime_state_version"
317 );
318 assert_eq!(serde_json::to_value(session).unwrap(), before);
319 }
320}
321
322#[cfg(test)]
323#[path = "versioned_spawn_tests.rs"]
324mod versioned_spawn_tests;