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; 16] = [
58 "SubAgent",
59 "Plan",
60 "Task",
61 "session_history_current",
62 "session_message",
63 "Read",
64 "Grep",
65 "Glob",
66 "GetFileInfo",
67 "ViewImage",
68 "work_overview",
69 "work_search",
70 "work_inspect",
71 "work_changes",
72 "work_update",
73 "work_dispatch",
74];
75
76pub fn orchestration_only_allows_execution_name(execution_name: &str) -> bool {
77 ROOT_ORCHESTRATION_TOOLS.contains(&execution_name)
78}
79
80pub const SUPERVISOR_ORCHESTRATION_TOOLS: [&str; 2] = ["session_control", "session_history"];
83
84pub fn orchestration_only_allows_execution_name_for_supervisor(
85 execution_name: &str,
86 executing_supervisor: bool,
87) -> bool {
88 orchestration_only_allows_execution_name(execution_name)
89 || (executing_supervisor && SUPERVISOR_ORCHESTRATION_TOOLS.contains(&execution_name))
90}
91
92#[derive(Debug, thiserror::Error, PartialEq, Eq)]
93pub enum RootToolAuthorityError {
94 #[error("only a Root Session may select orchestration-only tool authority")]
95 NotRoot,
96 #[error("Root tool authority revision cannot advance")]
97 RevisionOverflow,
98 #[error("Root tool authority snapshot is stale or inconsistent")]
99 StaleSnapshot,
100 #[error("orchestration-only Root cannot enter while legacy Plan mode is active")]
101 LegacyPlanActive,
102 #[error("orchestration-only Root cannot use a selected Skill or Workflow")]
103 WorkflowSelected,
104}
105
106impl Session {
107 pub fn root_thinking_mode(&self) -> RootThinkingMode {
108 RootThinkingMode::from_enabled(self.root_orchestration_only_enabled())
109 }
110
111 pub fn root_orchestration_only_enabled(&self) -> bool {
112 self.kind == SessionKind::Root
113 && self.parent_session_id.is_none()
114 && self.root_orchestration_only
115 }
116
117 pub fn set_root_orchestration_only(
119 &mut self,
120 enabled: bool,
121 ) -> Result<(), RootToolAuthorityError> {
122 if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
123 return Err(RootToolAuthorityError::NotRoot);
124 }
125 if enabled {
126 self.validate_root_orchestration_compatibility()?;
127 }
128 if self.root_orchestration_only == enabled {
129 return Ok(());
130 }
131 let next = self
132 .root_tool_authority_revision
133 .checked_add(1)
134 .ok_or(RootToolAuthorityError::RevisionOverflow)?;
135 self.root_orchestration_only = enabled;
136 self.root_tool_authority_revision = next;
137 if self.model_context_state.is_some() {
138 self.reset_model_context_epoch(ModelContextResetReason::CacheScopeChanged);
139 }
140 Ok(())
141 }
142
143 pub fn validate_root_orchestration_compatibility(&self) -> Result<(), RootToolAuthorityError> {
146 if self
147 .agent_runtime_state
148 .as_ref()
149 .is_some_and(|state| state.plan_mode.is_some())
150 {
151 return Err(RootToolAuthorityError::LegacyPlanActive);
152 }
153 if self.selected_skill_ids().is_some_and(|ids| !ids.is_empty()) {
154 return Err(RootToolAuthorityError::WorkflowSelected);
155 }
156 Ok(())
157 }
158
159 pub fn allows_model_tool_execution(&self, execution_name: &str) -> bool {
161 !self.root_orchestration_only_enabled()
162 || orchestration_only_allows_execution_name_for_supervisor(
163 execution_name,
164 self.supervisor_incarnation_id().is_some(),
165 )
166 }
167
168 pub fn adopt_root_tool_authority_from(
171 &mut self,
172 latest: &Session,
173 ) -> Result<(), RootToolAuthorityError> {
174 fn root_id(session: &Session) -> &str {
178 if session.kind == SessionKind::Root && session.root_session_id.is_empty() {
179 session.id.as_str()
180 } else {
181 session.root_session_id.as_str()
182 }
183 }
184 if self.id != latest.id
185 || self.kind != latest.kind
186 || self.created_at != latest.created_at
187 || self.authority_identity != latest.authority_identity
188 || self.parent_session_id != latest.parent_session_id
189 || root_id(self) != root_id(latest)
190 || self.spawn_depth != latest.spawn_depth
191 || (self.root_orchestration_only && self.root_tool_authority_revision == 0)
192 || (latest.root_orchestration_only && latest.root_tool_authority_revision == 0)
193 || latest.root_tool_authority_revision < self.root_tool_authority_revision
194 || (latest.root_tool_authority_revision == self.root_tool_authority_revision
195 && latest.root_orchestration_only != self.root_orchestration_only)
196 || latest.root_mode_transition_epoch < self.root_mode_transition_epoch
197 || (latest.root_mode_transition_epoch == self.root_mode_transition_epoch
198 && latest.root_mode_operations != self.root_mode_operations)
199 {
200 return Err(RootToolAuthorityError::StaleSnapshot);
201 }
202 let selections = latest.root_tool_authority_revision - self.root_tool_authority_revision;
203 self.root_orchestration_only = latest.root_orchestration_only;
204 self.root_tool_authority_revision = latest.root_tool_authority_revision;
205 self.root_mode_transition_epoch = latest.root_mode_transition_epoch;
206 self.root_mode_operations = latest.root_mode_operations.clone();
207 if selections > 0 {
208 self.model_context_state = latest.model_context_state.clone();
212 self.provider_transcript.invalidate_repeated(
213 super::provider_transcript::ProviderTranscriptResetReason::CacheScopeChanged,
214 selections,
215 );
216 }
217 Ok(())
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use crate::session::{AgentRuntimeState, ModelContextState, PlanModeState, PlanModeStatus};
225
226 #[test]
227 fn trusted_supervisor_session_management_survives_thinking_mode_selection() {
228 use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
229 let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
230 supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
231 incarnation_id: uuid::Uuid::new_v4(),
232 };
233 for ultra in [false, true] {
234 supervisor.set_root_orchestration_only(ultra).unwrap();
235 let restored: Session =
236 serde_json::from_value(serde_json::to_value(&supervisor).unwrap()).unwrap();
237 for name in ["session_control", "session_history"] {
238 assert!(
239 restored.allows_model_tool_execution(name),
240 "trusted Supervisor lost {name} in {:?}",
241 restored.root_thinking_mode()
242 );
243 }
244 }
245 }
246
247 #[test]
248 fn ultra_management_exception_requires_canonical_supervisor_and_exact_tool() {
249 use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
250 let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
251 supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
252 incarnation_id: uuid::Uuid::new_v4(),
253 };
254 supervisor.set_root_orchestration_only(true).unwrap();
255 for mutation in [
256 |s: &mut Session| s.authority_identity = SessionAuthorityIdentity::Ordinary,
257 |s: &mut Session| s.id = "other".into(),
258 |s: &mut Session| s.root_session_id = "other".into(),
259 |s: &mut Session| s.spawn_depth = 1,
260 |s: &mut Session| {
261 s.authority_identity = SessionAuthorityIdentity::Supervisor {
262 incarnation_id: uuid::Uuid::nil(),
263 }
264 },
265 ] {
266 let mut invalid = supervisor.clone();
267 mutation(&mut invalid);
268 invalid.metadata.insert("role".into(), "supervisor".into());
269 for name in ["session_control", "session_history"] {
270 assert!(!invalid.allows_model_tool_execution(name));
271 }
272 }
273 for name in [
274 "default::session_control",
275 "vendor::session_control",
276 "exec_command",
277 "Write",
278 ] {
279 assert!(!supervisor.allows_model_tool_execution(name), "{name}");
280 }
281 let child = Session::new_child_of("child", &supervisor, "model", "child");
282 assert_eq!(child.supervisor_incarnation_id(), None);
283 let mut ordinary = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
284 ordinary.set_root_orchestration_only(true).unwrap();
285 ordinary.metadata.insert("role".into(), "supervisor".into());
286 for name in ["session_control", "session_history"] {
287 assert!(!ordinary.allows_model_tool_execution(name));
288 }
289 }
290
291 #[test]
292 fn thinking_mode_is_a_derived_root_policy_independent_of_effort() {
293 use crate::ReasoningEffort;
294 let mut root = Session::new("ultra-root", "model");
295 root.reasoning_effort = Some(ReasoningEffort::Max);
296 root.set_root_orchestration_prompt_enabled(true);
297 assert_eq!(root.root_thinking_mode(), RootThinkingMode::Standard);
298 root.set_root_orchestration_only(true).unwrap();
299 let persisted = serde_json::to_value(&root).unwrap();
300 assert!(
301 persisted.get("thinking_mode").is_none(),
302 "no second persisted authority"
303 );
304 let reloaded: Session = serde_json::from_value(persisted.clone()).unwrap();
305 assert_eq!(reloaded.root_thinking_mode(), RootThinkingMode::Ultra);
306 assert_eq!(reloaded.reasoning_effort, Some(ReasoningEffort::Max));
307 assert_eq!(
308 serde_json::to_value(&reloaded).unwrap(),
309 persisted,
310 "projection never writes"
311 );
312 let mut child = Session::new_child_of("ultra-child", &reloaded, "model", "child");
313 assert_eq!(child.root_thinking_mode(), RootThinkingMode::Standard);
314 assert_eq!(child.reasoning_effort, None);
315 child.root_orchestration_only = true;
316 assert_eq!(
317 child.root_thinking_mode(),
318 RootThinkingMode::Standard,
319 "raw child bool has no authority"
320 );
321 assert_eq!(
322 child.set_root_orchestration_only(true),
323 Err(RootToolAuthorityError::NotRoot)
324 );
325 assert_eq!(
326 serde_json::to_string(&RootThinkingMode::Ultra).unwrap(),
327 "\"ultra\""
328 );
329 assert_eq!(
330 serde_json::from_str::<RootThinkingMode>("\"standard\"").unwrap(),
331 RootThinkingMode::Standard
332 );
333 assert!(serde_json::from_str::<RootThinkingMode>("\"max\"").is_err());
334 assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
335 }
336
337 #[test]
338 fn canonical_and_legacy_thinking_selectors_share_one_boolean_request() {
339 assert_eq!(RootThinkingMode::resolve_selection(None, None), Ok(None));
340 for enabled in [false, true] {
341 let mode = RootThinkingMode::from_enabled(enabled);
342 assert_eq!(
343 RootThinkingMode::resolve_selection(Some(mode), None),
344 Ok(Some(enabled))
345 );
346 assert_eq!(
347 RootThinkingMode::resolve_selection(None, Some(enabled)),
348 Ok(Some(enabled))
349 );
350 assert_eq!(
351 RootThinkingMode::resolve_selection(Some(mode), Some(enabled)),
352 Ok(Some(enabled))
353 );
354 assert_eq!(
355 RootThinkingMode::resolve_selection(Some(mode), Some(!enabled)),
356 Err(RootThinkingModeConflict)
357 );
358 }
359 }
360
361 #[test]
362 fn root_selection_is_persistent_and_child_does_not_inherit_it() {
363 let mut root = Session::new("root", "model");
364 root.set_root_orchestration_only(true).unwrap();
365 assert_eq!(root.root_tool_authority_revision, 1);
366 let reloaded: Session =
367 serde_json::from_str(&serde_json::to_string(&root).unwrap()).unwrap();
368 assert!(reloaded.root_orchestration_only_enabled());
369 let mut child = Session::new_child_of("child", &reloaded, "model", "child");
370 assert!(!child.root_orchestration_only_enabled());
371 assert_eq!(child.root_tool_authority_revision, 0);
372 assert_eq!(
373 child.set_root_orchestration_only(false),
374 Err(RootToolAuthorityError::NotRoot)
375 );
376 assert!(child.allows_model_tool_execution("Bash"));
377 }
378
379 #[test]
380 fn root_allowlist_is_exact_and_denies_unknown_tools() {
381 let mut root = Session::new("root", "model");
382 root.set_root_orchestration_only(true).unwrap();
383 for tool in ROOT_ORCHESTRATION_TOOLS {
384 assert!(root.allows_model_tool_execution(tool), "{tool}");
385 }
386 for tool in [
387 "exec_command",
388 "write_stdin",
389 "Bash",
390 "Edit",
391 "Write",
392 "WebFetch",
393 "workflow_run",
394 "mcp__x__read",
395 "read_file",
396 "default::Read",
397 ] {
398 assert!(!root.allows_model_tool_execution(tool), "{tool}");
399 }
400 assert_eq!(root.root_tool_authority_revision, 1);
401 root.set_root_orchestration_only(true).unwrap();
402 assert_eq!(root.root_tool_authority_revision, 1);
403 root.set_root_orchestration_only(false).unwrap();
404 assert_eq!(root.root_tool_authority_revision, 2);
405 assert!(root.allows_model_tool_execution("Bash"));
406 }
407
408 #[test]
409 fn durable_refresh_adopts_tightening_and_rejects_stale_loosen() {
410 let mut running = Session::new("root", "model");
411 let mut durable = running.clone();
412 durable.set_root_orchestration_only(true).unwrap();
413 running.adopt_root_tool_authority_from(&durable).unwrap();
414 assert!(!running.allows_model_tool_execution("Bash"));
415 assert_eq!(
416 durable.adopt_root_tool_authority_from(&Session::new("root", "model")),
417 Err(RootToolAuthorityError::StaleSnapshot)
418 );
419 durable.set_root_orchestration_only(false).unwrap();
420 running.adopt_root_tool_authority_from(&durable).unwrap();
421 assert!(running.allows_model_tool_execution("Bash"));
422 }
423
424 #[test]
425 fn durable_refresh_rejects_a_different_birth_identity_or_lineage() {
426 let running = Session::new("root", "model");
427 let mut other_birth = running.clone();
428 other_birth.created_at += chrono::Duration::microseconds(1);
429 assert_eq!(
430 running.clone().adopt_root_tool_authority_from(&other_birth),
431 Err(RootToolAuthorityError::StaleSnapshot)
432 );
433
434 let mut other_identity = running.clone();
435 other_identity.authority_identity = super::super::SessionAuthorityIdentity::Supervisor {
436 incarnation_id: uuid::Uuid::new_v4(),
437 };
438 assert_eq!(
439 running
440 .clone()
441 .adopt_root_tool_authority_from(&other_identity),
442 Err(RootToolAuthorityError::StaleSnapshot)
443 );
444
445 let mut other_lineage = running.clone();
446 other_lineage.root_session_id = "another-root".into();
447 assert_eq!(
448 running
449 .clone()
450 .adopt_root_tool_authority_from(&other_lineage),
451 Err(RootToolAuthorityError::StaleSnapshot)
452 );
453 other_lineage = running.clone();
454 other_lineage.spawn_depth = 1;
455 assert_eq!(
456 running
457 .clone()
458 .adopt_root_tool_authority_from(&other_lineage),
459 Err(RootToolAuthorityError::StaleSnapshot)
460 );
461
462 let mut legacy = running.clone();
464 legacy.root_session_id.clear();
465 legacy.adopt_root_tool_authority_from(&running).unwrap();
466 }
467
468 #[test]
469 fn root_selection_rejects_legacy_plan_or_workflow_that_needs_other_tools() {
470 let mut root = Session::new("root", "model");
471 let runtime = AgentRuntimeState {
472 plan_mode: Some(PlanModeState {
473 entered_at: chrono::Utc::now(),
474 pre_permission_mode: "default".to_string(),
475 plan_file_path: None,
476 status: PlanModeStatus::Exploring,
477 }),
478 ..AgentRuntimeState::default()
479 };
480 root.agent_runtime_state = Some(runtime);
481 assert_eq!(
482 root.set_root_orchestration_only(true),
483 Err(RootToolAuthorityError::LegacyPlanActive)
484 );
485 root.agent_runtime_state = None;
486 root.set_selected_skill_ids(vec!["workflow".to_string()]);
487 assert_eq!(
488 root.set_root_orchestration_only(true),
489 Err(RootToolAuthorityError::WorkflowSelected)
490 );
491 root.clear_selected_skill_ids();
492 root.set_root_orchestration_only(true).unwrap();
493 root.set_selected_skill_ids(vec!["workflow".to_string()]);
494 assert_eq!(
495 root.validate_root_orchestration_compatibility(),
496 Err(RootToolAuthorityError::WorkflowSelected)
497 );
498 }
499
500 #[test]
501 fn live_authority_adoption_resets_prior_model_context_epoch() {
502 for enabled in [false, true] {
503 let mut running = Session::new("root", "model");
504 if !enabled {
505 running.set_root_orchestration_only(true).unwrap();
506 }
507 running.model_context_state = Some(ModelContextState {
508 prefix_epoch: 4,
509 cache_scope_sha256: Some("prior-provider-scope".to_string()),
510 ..ModelContextState::default()
511 });
512 let mut durable = running.clone();
513 durable.set_root_orchestration_only(enabled).unwrap();
514 running.adopt_root_tool_authority_from(&durable).unwrap();
515 let context = running.model_context_state.as_ref().unwrap();
516 assert_eq!(context.prefix_epoch, 5);
517 assert!(context.cache_scope_sha256.is_none());
518 assert_eq!(
519 context.last_reset_reason,
520 Some(ModelContextResetReason::CacheScopeChanged)
521 );
522 }
523 }
524
525 #[test]
526 fn live_authority_adoption_preserves_all_missed_mode_resets() {
527 let mut running = Session::new("root", "model");
528 running.model_context_state = Some(ModelContextState {
529 prefix_epoch: 4,
530 cache_scope_sha256: Some("old scope".into()),
531 ..Default::default()
532 });
533 running
534 .activate_provider_transcript_route(
535 super::super::provider_transcript::ProviderFamily::OpenAi,
536 super::super::provider_transcript::ProviderProtocol::OpenAiResponsesV1,
537 &"a".repeat(64),
538 )
539 .unwrap();
540 let mut durable = running.clone();
541 durable.set_root_orchestration_only(true).unwrap();
542 durable
545 .model_context_state
546 .as_mut()
547 .unwrap()
548 .cache_scope_sha256 = Some("new scope".into());
549 durable.set_root_orchestration_only(false).unwrap();
550 assert_eq!(running.root_thinking_mode(), durable.root_thinking_mode());
551 running.adopt_root_tool_authority_from(&durable).unwrap();
552 assert_eq!(running.model_context_state, durable.model_context_state);
553 assert_eq!(
554 running.model_context_state.as_ref().unwrap().prefix_epoch,
555 6
556 );
557 assert_eq!(running.provider_transcript, durable.provider_transcript);
558 running
560 .model_context_state
561 .as_mut()
562 .unwrap()
563 .cache_scope_sha256 = Some("owner scope".into());
564 let owner_context = running.model_context_state.clone();
565 durable.root_mode_transition_epoch += 1;
566 running.adopt_root_tool_authority_from(&durable).unwrap();
567 assert_eq!(running.model_context_state, owner_context);
568 }
569}