1use super::{model_context::ModelContextResetReason, Session, SessionKind};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum RootThinkingMode {
11 Standard,
12 Ultra,
13}
14
15#[derive(Debug, thiserror::Error, PartialEq, Eq)]
16#[error("thinking_mode and root orchestration enabled selection conflict")]
17pub struct RootThinkingModeConflict;
18
19impl RootThinkingMode {
20 pub fn deserialize_selection<'de, D>(deserializer: D) -> Result<Option<Self>, D::Error>
22 where
23 D: serde::Deserializer<'de>,
24 {
25 <Self as serde::Deserialize>::deserialize(deserializer).map(Some)
26 }
27
28 pub fn from_enabled(enabled: bool) -> Self {
29 if enabled {
30 Self::Ultra
31 } else {
32 Self::Standard
33 }
34 }
35
36 pub fn enabled(self) -> bool {
37 self == Self::Ultra
38 }
39
40 pub fn resolve_selection(
42 mode: Option<Self>,
43 enabled: Option<bool>,
44 ) -> Result<Option<bool>, RootThinkingModeConflict> {
45 if let (Some(mode), Some(enabled)) = (mode, enabled) {
46 if mode.enabled() != enabled {
47 return Err(RootThinkingModeConflict);
48 }
49 }
50 Ok(mode.map(Self::enabled).or(enabled))
51 }
52}
53
54pub const ROOT_ORCHESTRATION_TOOLS: [&str; 15] = [
58 "SubAgent",
59 "Plan",
60 "Task",
61 "session_history_current",
62 "Read",
63 "Grep",
64 "Glob",
65 "GetFileInfo",
66 "ViewImage",
67 "work_overview",
68 "work_search",
69 "work_inspect",
70 "work_changes",
71 "work_update",
72 "work_dispatch",
73];
74
75pub fn orchestration_only_allows_execution_name(execution_name: &str) -> bool {
76 ROOT_ORCHESTRATION_TOOLS.contains(&execution_name)
77}
78
79#[derive(Debug, thiserror::Error, PartialEq, Eq)]
80pub enum RootToolAuthorityError {
81 #[error("only a Root Session may select orchestration-only tool authority")]
82 NotRoot,
83 #[error("Root tool authority revision cannot advance")]
84 RevisionOverflow,
85 #[error("Root tool authority snapshot is stale or inconsistent")]
86 StaleSnapshot,
87 #[error("orchestration-only Root cannot enter while legacy Plan mode is active")]
88 LegacyPlanActive,
89 #[error("orchestration-only Root cannot use a selected Skill or Workflow")]
90 WorkflowSelected,
91}
92
93impl Session {
94 pub fn root_thinking_mode(&self) -> RootThinkingMode {
95 RootThinkingMode::from_enabled(self.root_orchestration_only_enabled())
96 }
97
98 pub fn root_orchestration_only_enabled(&self) -> bool {
99 self.kind == SessionKind::Root
100 && self.parent_session_id.is_none()
101 && self.root_orchestration_only
102 }
103
104 pub fn set_root_orchestration_only(
106 &mut self,
107 enabled: bool,
108 ) -> Result<(), RootToolAuthorityError> {
109 if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
110 return Err(RootToolAuthorityError::NotRoot);
111 }
112 if enabled {
113 self.validate_root_orchestration_compatibility()?;
114 }
115 if self.root_orchestration_only == enabled {
116 return Ok(());
117 }
118 let next = self
119 .root_tool_authority_revision
120 .checked_add(1)
121 .ok_or(RootToolAuthorityError::RevisionOverflow)?;
122 self.root_orchestration_only = enabled;
123 self.root_tool_authority_revision = next;
124 if self.model_context_state.is_some() {
125 self.reset_model_context_epoch(ModelContextResetReason::CacheScopeChanged);
126 }
127 Ok(())
128 }
129
130 pub fn validate_root_orchestration_compatibility(&self) -> Result<(), RootToolAuthorityError> {
133 if self
134 .agent_runtime_state
135 .as_ref()
136 .is_some_and(|state| state.plan_mode.is_some())
137 {
138 return Err(RootToolAuthorityError::LegacyPlanActive);
139 }
140 if self.selected_skill_ids().is_some_and(|ids| !ids.is_empty()) {
141 return Err(RootToolAuthorityError::WorkflowSelected);
142 }
143 Ok(())
144 }
145
146 pub fn allows_model_tool_execution(&self, execution_name: &str) -> bool {
148 !self.root_orchestration_only_enabled()
149 || orchestration_only_allows_execution_name(execution_name)
150 }
151
152 pub fn adopt_root_tool_authority_from(
155 &mut self,
156 latest: &Session,
157 ) -> Result<(), RootToolAuthorityError> {
158 fn root_id(session: &Session) -> &str {
162 if session.kind == SessionKind::Root && session.root_session_id.is_empty() {
163 session.id.as_str()
164 } else {
165 session.root_session_id.as_str()
166 }
167 }
168 if self.id != latest.id
169 || self.kind != latest.kind
170 || self.created_at != latest.created_at
171 || self.authority_identity != latest.authority_identity
172 || self.parent_session_id != latest.parent_session_id
173 || root_id(self) != root_id(latest)
174 || self.spawn_depth != latest.spawn_depth
175 || (self.root_orchestration_only && self.root_tool_authority_revision == 0)
176 || (latest.root_orchestration_only && latest.root_tool_authority_revision == 0)
177 || latest.root_tool_authority_revision < self.root_tool_authority_revision
178 || (latest.root_tool_authority_revision == self.root_tool_authority_revision
179 && latest.root_orchestration_only != self.root_orchestration_only)
180 || latest.root_mode_transition_epoch < self.root_mode_transition_epoch
181 || (latest.root_mode_transition_epoch == self.root_mode_transition_epoch
182 && latest.root_mode_operations != self.root_mode_operations)
183 {
184 return Err(RootToolAuthorityError::StaleSnapshot);
185 }
186 let selections = latest.root_tool_authority_revision - self.root_tool_authority_revision;
187 self.root_orchestration_only = latest.root_orchestration_only;
188 self.root_tool_authority_revision = latest.root_tool_authority_revision;
189 self.root_mode_transition_epoch = latest.root_mode_transition_epoch;
190 self.root_mode_operations = latest.root_mode_operations.clone();
191 if selections > 0 {
192 self.model_context_state = latest.model_context_state.clone();
196 self.provider_transcript.invalidate_repeated(
197 super::provider_transcript::ProviderTranscriptResetReason::CacheScopeChanged,
198 selections,
199 );
200 }
201 Ok(())
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::session::{AgentRuntimeState, ModelContextState, PlanModeState, PlanModeStatus};
209
210 #[test]
211 fn thinking_mode_is_a_derived_root_policy_independent_of_effort() {
212 use crate::ReasoningEffort;
213 let mut root = Session::new("ultra-root", "model");
214 root.reasoning_effort = Some(ReasoningEffort::Max);
215 root.set_root_orchestration_prompt_enabled(true);
216 assert_eq!(root.root_thinking_mode(), RootThinkingMode::Standard);
217 root.set_root_orchestration_only(true).unwrap();
218 let persisted = serde_json::to_value(&root).unwrap();
219 assert!(
220 persisted.get("thinking_mode").is_none(),
221 "no second persisted authority"
222 );
223 let reloaded: Session = serde_json::from_value(persisted.clone()).unwrap();
224 assert_eq!(reloaded.root_thinking_mode(), RootThinkingMode::Ultra);
225 assert_eq!(reloaded.reasoning_effort, Some(ReasoningEffort::Max));
226 assert_eq!(
227 serde_json::to_value(&reloaded).unwrap(),
228 persisted,
229 "projection never writes"
230 );
231 let mut child = Session::new_child_of("ultra-child", &reloaded, "model", "child");
232 assert_eq!(child.root_thinking_mode(), RootThinkingMode::Standard);
233 assert_eq!(child.reasoning_effort, None);
234 child.root_orchestration_only = true;
235 assert_eq!(
236 child.root_thinking_mode(),
237 RootThinkingMode::Standard,
238 "raw child bool has no authority"
239 );
240 assert_eq!(
241 child.set_root_orchestration_only(true),
242 Err(RootToolAuthorityError::NotRoot)
243 );
244 assert_eq!(
245 serde_json::to_string(&RootThinkingMode::Ultra).unwrap(),
246 "\"ultra\""
247 );
248 assert_eq!(
249 serde_json::from_str::<RootThinkingMode>("\"standard\"").unwrap(),
250 RootThinkingMode::Standard
251 );
252 assert!(serde_json::from_str::<RootThinkingMode>("\"max\"").is_err());
253 assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
254 }
255
256 #[test]
257 fn canonical_and_legacy_thinking_selectors_share_one_boolean_request() {
258 assert_eq!(RootThinkingMode::resolve_selection(None, None), Ok(None));
259 for enabled in [false, true] {
260 let mode = RootThinkingMode::from_enabled(enabled);
261 assert_eq!(
262 RootThinkingMode::resolve_selection(Some(mode), None),
263 Ok(Some(enabled))
264 );
265 assert_eq!(
266 RootThinkingMode::resolve_selection(None, Some(enabled)),
267 Ok(Some(enabled))
268 );
269 assert_eq!(
270 RootThinkingMode::resolve_selection(Some(mode), Some(enabled)),
271 Ok(Some(enabled))
272 );
273 assert_eq!(
274 RootThinkingMode::resolve_selection(Some(mode), Some(!enabled)),
275 Err(RootThinkingModeConflict)
276 );
277 }
278 }
279
280 #[test]
281 fn root_selection_is_persistent_and_child_does_not_inherit_it() {
282 let mut root = Session::new("root", "model");
283 root.set_root_orchestration_only(true).unwrap();
284 assert_eq!(root.root_tool_authority_revision, 1);
285 let reloaded: Session =
286 serde_json::from_str(&serde_json::to_string(&root).unwrap()).unwrap();
287 assert!(reloaded.root_orchestration_only_enabled());
288 let mut child = Session::new_child_of("child", &reloaded, "model", "child");
289 assert!(!child.root_orchestration_only_enabled());
290 assert_eq!(child.root_tool_authority_revision, 0);
291 assert_eq!(
292 child.set_root_orchestration_only(false),
293 Err(RootToolAuthorityError::NotRoot)
294 );
295 assert!(child.allows_model_tool_execution("Bash"));
296 }
297
298 #[test]
299 fn root_allowlist_is_exact_and_denies_unknown_tools() {
300 let mut root = Session::new("root", "model");
301 root.set_root_orchestration_only(true).unwrap();
302 for tool in ROOT_ORCHESTRATION_TOOLS {
303 assert!(root.allows_model_tool_execution(tool), "{tool}");
304 }
305 for tool in [
306 "Bash",
307 "Edit",
308 "Write",
309 "WebFetch",
310 "workflow_run",
311 "mcp__x__read",
312 "read_file",
313 "default::Read",
314 ] {
315 assert!(!root.allows_model_tool_execution(tool), "{tool}");
316 }
317 assert_eq!(root.root_tool_authority_revision, 1);
318 root.set_root_orchestration_only(true).unwrap();
319 assert_eq!(root.root_tool_authority_revision, 1);
320 root.set_root_orchestration_only(false).unwrap();
321 assert_eq!(root.root_tool_authority_revision, 2);
322 assert!(root.allows_model_tool_execution("Bash"));
323 }
324
325 #[test]
326 fn durable_refresh_adopts_tightening_and_rejects_stale_loosen() {
327 let mut running = Session::new("root", "model");
328 let mut durable = running.clone();
329 durable.set_root_orchestration_only(true).unwrap();
330 running.adopt_root_tool_authority_from(&durable).unwrap();
331 assert!(!running.allows_model_tool_execution("Bash"));
332 assert_eq!(
333 durable.adopt_root_tool_authority_from(&Session::new("root", "model")),
334 Err(RootToolAuthorityError::StaleSnapshot)
335 );
336 durable.set_root_orchestration_only(false).unwrap();
337 running.adopt_root_tool_authority_from(&durable).unwrap();
338 assert!(running.allows_model_tool_execution("Bash"));
339 }
340
341 #[test]
342 fn durable_refresh_rejects_a_different_birth_identity_or_lineage() {
343 let running = Session::new("root", "model");
344 let mut other_birth = running.clone();
345 other_birth.created_at += chrono::Duration::microseconds(1);
346 assert_eq!(
347 running.clone().adopt_root_tool_authority_from(&other_birth),
348 Err(RootToolAuthorityError::StaleSnapshot)
349 );
350
351 let mut other_identity = running.clone();
352 other_identity.authority_identity = super::super::SessionAuthorityIdentity::Supervisor {
353 incarnation_id: uuid::Uuid::new_v4(),
354 };
355 assert_eq!(
356 running
357 .clone()
358 .adopt_root_tool_authority_from(&other_identity),
359 Err(RootToolAuthorityError::StaleSnapshot)
360 );
361
362 let mut other_lineage = running.clone();
363 other_lineage.root_session_id = "another-root".into();
364 assert_eq!(
365 running
366 .clone()
367 .adopt_root_tool_authority_from(&other_lineage),
368 Err(RootToolAuthorityError::StaleSnapshot)
369 );
370 other_lineage = running.clone();
371 other_lineage.spawn_depth = 1;
372 assert_eq!(
373 running
374 .clone()
375 .adopt_root_tool_authority_from(&other_lineage),
376 Err(RootToolAuthorityError::StaleSnapshot)
377 );
378
379 let mut legacy = running.clone();
381 legacy.root_session_id.clear();
382 legacy.adopt_root_tool_authority_from(&running).unwrap();
383 }
384
385 #[test]
386 fn root_selection_rejects_legacy_plan_or_workflow_that_needs_other_tools() {
387 let mut root = Session::new("root", "model");
388 let runtime = AgentRuntimeState {
389 plan_mode: Some(PlanModeState {
390 entered_at: chrono::Utc::now(),
391 pre_permission_mode: "default".to_string(),
392 plan_file_path: None,
393 status: PlanModeStatus::Exploring,
394 }),
395 ..AgentRuntimeState::default()
396 };
397 root.agent_runtime_state = Some(runtime);
398 assert_eq!(
399 root.set_root_orchestration_only(true),
400 Err(RootToolAuthorityError::LegacyPlanActive)
401 );
402 root.agent_runtime_state = None;
403 root.set_selected_skill_ids(vec!["workflow".to_string()]);
404 assert_eq!(
405 root.set_root_orchestration_only(true),
406 Err(RootToolAuthorityError::WorkflowSelected)
407 );
408 root.clear_selected_skill_ids();
409 root.set_root_orchestration_only(true).unwrap();
410 root.set_selected_skill_ids(vec!["workflow".to_string()]);
411 assert_eq!(
412 root.validate_root_orchestration_compatibility(),
413 Err(RootToolAuthorityError::WorkflowSelected)
414 );
415 }
416
417 #[test]
418 fn live_authority_adoption_resets_prior_model_context_epoch() {
419 for enabled in [false, true] {
420 let mut running = Session::new("root", "model");
421 if !enabled {
422 running.set_root_orchestration_only(true).unwrap();
423 }
424 running.model_context_state = Some(ModelContextState {
425 prefix_epoch: 4,
426 cache_scope_sha256: Some("prior-provider-scope".to_string()),
427 ..ModelContextState::default()
428 });
429 let mut durable = running.clone();
430 durable.set_root_orchestration_only(enabled).unwrap();
431 running.adopt_root_tool_authority_from(&durable).unwrap();
432 let context = running.model_context_state.as_ref().unwrap();
433 assert_eq!(context.prefix_epoch, 5);
434 assert!(context.cache_scope_sha256.is_none());
435 assert_eq!(
436 context.last_reset_reason,
437 Some(ModelContextResetReason::CacheScopeChanged)
438 );
439 }
440 }
441
442 #[test]
443 fn live_authority_adoption_preserves_all_missed_mode_resets() {
444 let mut running = Session::new("root", "model");
445 running.model_context_state = Some(ModelContextState {
446 prefix_epoch: 4,
447 cache_scope_sha256: Some("old scope".into()),
448 ..Default::default()
449 });
450 running
451 .activate_provider_transcript_route(
452 super::super::provider_transcript::ProviderFamily::OpenAi,
453 super::super::provider_transcript::ProviderProtocol::OpenAiResponsesV1,
454 &"a".repeat(64),
455 )
456 .unwrap();
457 let mut durable = running.clone();
458 durable.set_root_orchestration_only(true).unwrap();
459 durable
462 .model_context_state
463 .as_mut()
464 .unwrap()
465 .cache_scope_sha256 = Some("new scope".into());
466 durable.set_root_orchestration_only(false).unwrap();
467 assert_eq!(running.root_thinking_mode(), durable.root_thinking_mode());
468 running.adopt_root_tool_authority_from(&durable).unwrap();
469 assert_eq!(running.model_context_state, durable.model_context_state);
470 assert_eq!(
471 running.model_context_state.as_ref().unwrap().prefix_epoch,
472 6
473 );
474 assert_eq!(running.provider_transcript, durable.provider_transcript);
475 running
477 .model_context_state
478 .as_mut()
479 .unwrap()
480 .cache_scope_sha256 = Some("owner scope".into());
481 let owner_context = running.model_context_state.clone();
482 durable.root_mode_transition_epoch += 1;
483 running.adopt_root_tool_authority_from(&durable).unwrap();
484 assert_eq!(running.model_context_state, owner_context);
485 }
486}