Skip to main content

bamboo_domain/session/
tool_authority.rs

1//! Durable Root tool authority, separate from prompt or permission suggestions.
2
3use super::{model_context::ModelContextResetReason, Session, SessionKind};
4
5/// Root product policy, independent of a model call's `ReasoningEffort`.
6/// Ultra delegates planning/execution and keeps verification at the Root.
7/// It is projected from existing durable Root authority, never persisted twice.
8#[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    /// Optional DTO field: omission is allowed, explicit null is not a mode.
21    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    /// Normalize canonical and legacy selectors before existing admission.
41    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
54/// Exact execution identities admitted for an orchestration-only Root.
55/// Unknown tools, external providers, and legacy aliases do not enter this
56/// list; callers first resolve a registered execution identity.
57pub const ROOT_ORCHESTRATION_TOOLS: [&str; 18] = [
58    "SubAgent",
59    "spawn_session",
60    "Plan",
61    "Task",
62    "session_history_current",
63    "session_history",
64    "session_message",
65    "Read",
66    "Grep",
67    "Glob",
68    "GetFileInfo",
69    "ViewImage",
70    "work_overview",
71    "work_search",
72    "work_inspect",
73    "work_changes",
74    "work_update",
75    "work_dispatch",
76];
77
78pub fn orchestration_only_allows_execution_name(execution_name: &str) -> bool {
79    ROOT_ORCHESTRATION_TOOLS.contains(&execution_name)
80}
81
82/// Exact Supervisor surface, independent of an ordinary Root's thinking mode.
83/// These are delegation or bounded business views, never raw workspace/file,
84/// process, Skill, Workflow or external-provider execution. Concrete Session
85/// ports still require their live Host instance proof; names grant nothing.
86pub const SUPERVISOR_ORCHESTRATION_TOOLS: [&str; 14] = [
87    "SubAgent",
88    "spawn_session",
89    "Plan",
90    "Task",
91    "session_history_current",
92    "session_history",
93    "session_message",
94    "session_control",
95    "work_overview",
96    "work_search",
97    "work_inspect",
98    "work_changes",
99    "work_update",
100    "work_dispatch",
101];
102
103pub fn orchestration_only_allows_execution_name_for_supervisor(
104    execution_name: &str,
105    executing_supervisor: bool,
106) -> bool {
107    if executing_supervisor {
108        SUPERVISOR_ORCHESTRATION_TOOLS.contains(&execution_name)
109    } else {
110        orchestration_only_allows_execution_name(execution_name)
111    }
112}
113
114#[derive(Debug, thiserror::Error, PartialEq, Eq)]
115pub enum RootToolAuthorityError {
116    #[error("only a Root Session may select orchestration-only tool authority")]
117    NotRoot,
118    #[error("Root tool authority revision cannot advance")]
119    RevisionOverflow,
120    #[error("Root tool authority snapshot is stale or inconsistent")]
121    StaleSnapshot,
122    #[error("orchestration-only Root cannot enter while legacy Plan mode is active")]
123    LegacyPlanActive,
124    #[error("orchestration-only Root cannot use a selected Skill or Workflow")]
125    WorkflowSelected,
126}
127
128impl Session {
129    /// A trusted Supervisor's delegation-only business authority is independent
130    /// of thinking mode. Callers still need Storage's canonical identity proof.
131    /// Thinking-mode counters are not business permission epochs. Existing
132    /// positive Work binding revisions remain in their canonical Ticket store;
133    /// the Host verifies their unchanged Supervisor namespace separately.
134    pub fn supervisor_orchestration_revision(&self) -> Option<u64> {
135        self.supervisor_incarnation_id().map(|_| 1)
136    }
137
138    pub fn root_thinking_mode(&self) -> RootThinkingMode {
139        RootThinkingMode::from_enabled(self.root_orchestration_only_enabled())
140    }
141
142    pub fn root_orchestration_only_enabled(&self) -> bool {
143        self.kind == SessionKind::Root
144            && self.parent_session_id.is_none()
145            && self.root_orchestration_only
146    }
147
148    /// Called only by a host-owned selection, never by a model tool.
149    pub fn set_root_orchestration_only(
150        &mut self,
151        enabled: bool,
152    ) -> Result<(), RootToolAuthorityError> {
153        if self.kind != SessionKind::Root || self.parent_session_id.is_some() {
154            return Err(RootToolAuthorityError::NotRoot);
155        }
156        if enabled {
157            self.validate_root_orchestration_compatibility()?;
158        }
159        if self.root_orchestration_only == enabled {
160            return Ok(());
161        }
162        let next = self
163            .root_tool_authority_revision
164            .checked_add(1)
165            .ok_or(RootToolAuthorityError::RevisionOverflow)?;
166        self.root_orchestration_only = enabled;
167        self.root_tool_authority_revision = next;
168        if self.model_context_state.is_some() {
169            self.reset_model_context_epoch(ModelContextResetReason::CacheScopeChanged);
170        }
171        Ok(())
172    }
173
174    /// Keep the narrow tool catalog usable when an old turn or a new request
175    /// carries a mode that requires tools outside the Root allowlist.
176    pub fn validate_root_orchestration_compatibility(&self) -> Result<(), RootToolAuthorityError> {
177        if self
178            .agent_runtime_state
179            .as_ref()
180            .is_some_and(|state| state.plan_mode.is_some())
181        {
182            return Err(RootToolAuthorityError::LegacyPlanActive);
183        }
184        if self.selected_skill_ids().is_some_and(|ids| !ids.is_empty()) {
185            return Err(RootToolAuthorityError::WorkflowSelected);
186        }
187        Ok(())
188    }
189
190    /// Derived model posture, not a persisted selection or permission grant.
191    /// A trusted Supervisor orchestrates in both thinking modes. Children and
192    /// ordinary Roots retain their own independent mode and permissions.
193    pub fn model_orchestration_only_enabled(&self) -> bool {
194        self.root_orchestration_only_enabled() || self.supervisor_incarnation_id().is_some()
195    }
196
197    /// Defense in depth at both catalog projection and final dispatch.
198    pub fn allows_model_tool_execution(&self, execution_name: &str) -> bool {
199        !self.model_orchestration_only_enabled()
200            || orchestration_only_allows_execution_name_for_supervisor(
201                execution_name,
202                self.supervisor_incarnation_id().is_some(),
203            )
204    }
205
206    /// Refresh a running snapshot from the durable control plane before tool
207    /// dispatch. An older snapshot can never loosen a selected Root policy.
208    pub fn adopt_root_tool_authority_from(
209        &mut self,
210        latest: &Session,
211    ) -> Result<(), RootToolAuthorityError> {
212        // A tool policy revision is meaningful only within the same Session
213        // lifetime. A deleted Root ID can be recreated with a fresh birth, so
214        // matching the ID and revision alone would authorize an old SDK run.
215        fn root_id(session: &Session) -> &str {
216            if session.kind == SessionKind::Root && session.root_session_id.is_empty() {
217                session.id.as_str()
218            } else {
219                session.root_session_id.as_str()
220            }
221        }
222        if !super::compatibility::same_session_tool_contract(
223            self.session_version,
224            latest.session_version,
225        ) || self.id != latest.id
226            || self.kind != latest.kind
227            || self.created_at != latest.created_at
228            || self.authority_identity != latest.authority_identity
229            || self.parent_session_id != latest.parent_session_id
230            || root_id(self) != root_id(latest)
231            || self.spawn_depth != latest.spawn_depth
232            || (self.root_orchestration_only && self.root_tool_authority_revision == 0)
233            || (latest.root_orchestration_only && latest.root_tool_authority_revision == 0)
234            || latest.root_tool_authority_revision < self.root_tool_authority_revision
235            || (latest.root_tool_authority_revision == self.root_tool_authority_revision
236                && latest.root_orchestration_only != self.root_orchestration_only)
237            || latest.root_mode_transition_epoch < self.root_mode_transition_epoch
238            || (latest.root_mode_transition_epoch == self.root_mode_transition_epoch
239                && latest.root_mode_operations != self.root_mode_operations)
240        {
241            return Err(RootToolAuthorityError::StaleSnapshot);
242        }
243        let selections = latest.root_tool_authority_revision - self.root_tool_authority_revision;
244        self.root_orchestration_only = latest.root_orchestration_only;
245        self.root_tool_authority_revision = latest.root_tool_authority_revision;
246        self.root_mode_transition_epoch = latest.root_mode_transition_epoch;
247        self.root_mode_operations = latest.root_mode_operations.clone();
248        if selections > 0 {
249            // A running snapshot may miss several selections, including a
250            // round trip to the same mode. Adopt the durable invalidation;
251            // resetting the local ledger once could retain an older epoch.
252            self.model_context_state = latest.model_context_state.clone();
253            self.provider_transcript.invalidate_repeated(
254                super::provider_transcript::ProviderTranscriptResetReason::CacheScopeChanged,
255                selections,
256            );
257        }
258        Ok(())
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::session::{AgentRuntimeState, ModelContextState, PlanModeState, PlanModeStatus};
266
267    #[test]
268    fn supervisor_direct_files_and_processes_require_delegation_in_both_modes() {
269        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
270        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
271        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
272            incarnation_id: uuid::Uuid::new_v4(),
273        };
274        for ultra in [false, true, false] {
275            supervisor.set_root_orchestration_only(ultra).unwrap();
276            let restored: Session =
277                serde_json::from_value(serde_json::to_value(&supervisor).unwrap()).unwrap();
278            for name in [
279                "Read",
280                "Grep",
281                "Glob",
282                "GetFileInfo",
283                "ViewImage",
284                "Write",
285                "Edit",
286                "NotebookEdit",
287                "apply_patch",
288                "Bash",
289                "exec_command",
290                "write_stdin",
291                "load_skill",
292                "workflow_run",
293                "browser",
294                "browser_eval",
295                "mcp__vendor__Read",
296                "default::Read",
297                "vendor::Read",
298            ] {
299                assert!(
300                    !restored.allows_model_tool_execution(name),
301                    "Supervisor mode={ultra} must delegate {name}"
302                );
303            }
304            for name in [
305                "SubAgent",
306                "Plan",
307                "Task",
308                "session_control",
309                "session_history",
310            ] {
311                assert!(restored.allows_model_tool_execution(name), "{name}");
312            }
313            let child = Session::new_child_of("worker", &restored, "model", "worker");
314            assert!(child.authority_identity.is_ordinary());
315            assert!(child.allows_model_tool_execution("Read"));
316            assert!(child.allows_model_tool_execution("Edit"));
317            assert!(child.allows_model_tool_execution("exec_command"));
318        }
319        let mut ordinary = Session::new("ordinary", "model");
320        for ultra in [false, true] {
321            ordinary.set_root_orchestration_only(ultra).unwrap();
322            assert!(ordinary.allows_model_tool_execution("Read"));
323            assert_eq!(ordinary.supervisor_incarnation_id(), None);
324            // Standard's coarse tool policy is not the concrete Host port's
325            // management authorization. Keep its existing surface unchanged.
326            if ultra {
327                assert!(!ordinary.allows_model_tool_execution("session_control"));
328            }
329        }
330    }
331
332    #[test]
333    fn supervisor_delegation_first_does_not_depend_on_ultra_or_leak_to_children() {
334        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
335        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
336        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
337            incarnation_id: uuid::Uuid::new_v4(),
338        };
339        for ultra in [false, true, false] {
340            supervisor.set_root_orchestration_only(ultra).unwrap();
341            let restored: Session =
342                serde_json::from_value(serde_json::to_value(&supervisor).unwrap()).unwrap();
343            for name in SUPERVISOR_ORCHESTRATION_TOOLS {
344                assert!(
345                    restored.allows_model_tool_execution(name),
346                    "{ultra}: {name}"
347                );
348            }
349            for name in [
350                "exec_command",
351                "write_stdin",
352                "Bash",
353                "Edit",
354                "Write",
355                "apply_patch",
356                "load_skill",
357                "workflow_run",
358                "mcp__vendor__read",
359                "default::Read",
360            ] {
361                assert!(
362                    !restored.allows_model_tool_execution(name),
363                    "Supervisor {ultra}: {name}"
364                );
365            }
366            let child = Session::new_child_of("worker", &restored, "model", "worker");
367            assert!(child.authority_identity.is_ordinary());
368            assert!(child.allows_model_tool_execution("Edit"));
369            assert!(child.allows_model_tool_execution("exec_command"));
370        }
371        let mut ordinary = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
372        ordinary.metadata.insert("role".into(), "supervisor".into());
373        assert!(ordinary.allows_model_tool_execution("Edit"));
374        ordinary.set_root_orchestration_only(true).unwrap();
375        assert!(!ordinary.allows_model_tool_execution("session_control"));
376        assert!(ordinary.allows_model_tool_execution("session_history"));
377    }
378
379    #[test]
380    fn trusted_supervisor_session_management_survives_thinking_mode_selection() {
381        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
382        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
383        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
384            incarnation_id: uuid::Uuid::new_v4(),
385        };
386        for ultra in [false, true] {
387            supervisor.set_root_orchestration_only(ultra).unwrap();
388            let restored: Session =
389                serde_json::from_value(serde_json::to_value(&supervisor).unwrap()).unwrap();
390            for name in ["session_control", "session_history"] {
391                assert!(
392                    restored.allows_model_tool_execution(name),
393                    "trusted Supervisor lost {name} in {:?}",
394                    restored.root_thinking_mode()
395                );
396            }
397        }
398    }
399
400    #[test]
401    fn ultra_management_exception_requires_canonical_supervisor_and_exact_tool() {
402        use crate::{SessionAuthorityIdentity, DEFAULT_SUPERVISOR_SESSION_ID};
403        let mut supervisor = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
404        supervisor.authority_identity = SessionAuthorityIdentity::Supervisor {
405            incarnation_id: uuid::Uuid::new_v4(),
406        };
407        supervisor.set_root_orchestration_only(true).unwrap();
408        for mutation in [
409            |s: &mut Session| s.authority_identity = SessionAuthorityIdentity::Ordinary,
410            |s: &mut Session| s.id = "other".into(),
411            |s: &mut Session| s.root_session_id = "other".into(),
412            |s: &mut Session| s.spawn_depth = 1,
413            |s: &mut Session| {
414                s.authority_identity = SessionAuthorityIdentity::Supervisor {
415                    incarnation_id: uuid::Uuid::nil(),
416                }
417            },
418        ] {
419            let mut invalid = supervisor.clone();
420            mutation(&mut invalid);
421            invalid.metadata.insert("role".into(), "supervisor".into());
422            assert!(!invalid.allows_model_tool_execution("session_control"));
423            assert!(invalid.allows_model_tool_execution("session_history"));
424        }
425        for name in [
426            "default::session_control",
427            "vendor::session_control",
428            "exec_command",
429            "Write",
430        ] {
431            assert!(!supervisor.allows_model_tool_execution(name), "{name}");
432        }
433        let child = Session::new_child_of("child", &supervisor, "model", "child");
434        assert_eq!(child.supervisor_incarnation_id(), None);
435        let mut ordinary = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
436        ordinary.set_root_orchestration_only(true).unwrap();
437        ordinary.metadata.insert("role".into(), "supervisor".into());
438        assert!(!ordinary.allows_model_tool_execution("session_control"));
439        assert!(ordinary.allows_model_tool_execution("session_history"));
440    }
441
442    #[test]
443    fn thinking_mode_is_a_derived_root_policy_independent_of_effort() {
444        use crate::ReasoningEffort;
445        let mut root = Session::new("ultra-root", "model");
446        root.reasoning_effort = Some(ReasoningEffort::Max);
447        root.set_root_orchestration_prompt_enabled(true);
448        assert_eq!(root.root_thinking_mode(), RootThinkingMode::Standard);
449        root.set_root_orchestration_only(true).unwrap();
450        let persisted = serde_json::to_value(&root).unwrap();
451        assert!(
452            persisted.get("thinking_mode").is_none(),
453            "no second persisted authority"
454        );
455        let reloaded: Session = serde_json::from_value(persisted.clone()).unwrap();
456        assert_eq!(reloaded.root_thinking_mode(), RootThinkingMode::Ultra);
457        assert_eq!(reloaded.reasoning_effort, Some(ReasoningEffort::Max));
458        assert_eq!(
459            serde_json::to_value(&reloaded).unwrap(),
460            persisted,
461            "projection never writes"
462        );
463        let mut child = Session::new_child_of("ultra-child", &reloaded, "model", "child");
464        assert_eq!(child.root_thinking_mode(), RootThinkingMode::Standard);
465        assert_eq!(child.reasoning_effort, None);
466        child.root_orchestration_only = true;
467        assert_eq!(
468            child.root_thinking_mode(),
469            RootThinkingMode::Standard,
470            "raw child bool has no authority"
471        );
472        assert_eq!(
473            child.set_root_orchestration_only(true),
474            Err(RootToolAuthorityError::NotRoot)
475        );
476        assert_eq!(
477            serde_json::to_string(&RootThinkingMode::Ultra).unwrap(),
478            "\"ultra\""
479        );
480        assert_eq!(
481            serde_json::from_str::<RootThinkingMode>("\"standard\"").unwrap(),
482            RootThinkingMode::Standard
483        );
484        assert!(serde_json::from_str::<RootThinkingMode>("\"max\"").is_err());
485        assert!(serde_json::from_str::<ReasoningEffort>("\"ultra\"").is_err());
486    }
487
488    #[test]
489    fn canonical_and_legacy_thinking_selectors_share_one_boolean_request() {
490        assert_eq!(RootThinkingMode::resolve_selection(None, None), Ok(None));
491        for enabled in [false, true] {
492            let mode = RootThinkingMode::from_enabled(enabled);
493            assert_eq!(
494                RootThinkingMode::resolve_selection(Some(mode), None),
495                Ok(Some(enabled))
496            );
497            assert_eq!(
498                RootThinkingMode::resolve_selection(None, Some(enabled)),
499                Ok(Some(enabled))
500            );
501            assert_eq!(
502                RootThinkingMode::resolve_selection(Some(mode), Some(enabled)),
503                Ok(Some(enabled))
504            );
505            assert_eq!(
506                RootThinkingMode::resolve_selection(Some(mode), Some(!enabled)),
507                Err(RootThinkingModeConflict)
508            );
509        }
510    }
511
512    #[test]
513    fn root_selection_is_persistent_and_child_does_not_inherit_it() {
514        let mut root = Session::new("root", "model");
515        root.set_root_orchestration_only(true).unwrap();
516        assert_eq!(root.root_tool_authority_revision, 1);
517        let reloaded: Session =
518            serde_json::from_str(&serde_json::to_string(&root).unwrap()).unwrap();
519        assert!(reloaded.root_orchestration_only_enabled());
520        let mut child = Session::new_child_of("child", &reloaded, "model", "child");
521        assert!(!child.root_orchestration_only_enabled());
522        assert_eq!(child.root_tool_authority_revision, 0);
523        assert_eq!(
524            child.set_root_orchestration_only(false),
525            Err(RootToolAuthorityError::NotRoot)
526        );
527        assert!(child.allows_model_tool_execution("Bash"));
528    }
529
530    #[test]
531    fn root_allowlist_is_exact_and_denies_unknown_tools() {
532        let mut root = Session::new("root", "model");
533        root.set_root_orchestration_only(true).unwrap();
534        for tool in ROOT_ORCHESTRATION_TOOLS {
535            assert!(root.allows_model_tool_execution(tool), "{tool}");
536        }
537        for tool in [
538            "exec_command",
539            "write_stdin",
540            "Bash",
541            "Edit",
542            "Write",
543            "WebFetch",
544            "workflow_run",
545            "mcp__x__read",
546            "read_file",
547            "default::Read",
548        ] {
549            assert!(!root.allows_model_tool_execution(tool), "{tool}");
550        }
551        assert_eq!(root.root_tool_authority_revision, 1);
552        root.set_root_orchestration_only(true).unwrap();
553        assert_eq!(root.root_tool_authority_revision, 1);
554        root.set_root_orchestration_only(false).unwrap();
555        assert_eq!(root.root_tool_authority_revision, 2);
556        assert!(root.allows_model_tool_execution("Bash"));
557    }
558
559    #[test]
560    fn durable_refresh_adopts_tightening_and_rejects_stale_loosen() {
561        let mut running = Session::new("root", "model");
562        let mut durable = running.clone();
563        durable.set_root_orchestration_only(true).unwrap();
564        running.adopt_root_tool_authority_from(&durable).unwrap();
565        assert!(!running.allows_model_tool_execution("Bash"));
566        assert_eq!(
567            durable.adopt_root_tool_authority_from(&Session::new("root", "model")),
568            Err(RootToolAuthorityError::StaleSnapshot)
569        );
570        durable.set_root_orchestration_only(false).unwrap();
571        running.adopt_root_tool_authority_from(&durable).unwrap();
572        assert!(running.allows_model_tool_execution("Bash"));
573    }
574
575    #[test]
576    fn durable_refresh_rejects_a_different_birth_identity_or_lineage() {
577        let running = Session::new("root", "model");
578        let mut other_birth = running.clone();
579        other_birth.created_at += chrono::Duration::microseconds(1);
580        assert_eq!(
581            running.clone().adopt_root_tool_authority_from(&other_birth),
582            Err(RootToolAuthorityError::StaleSnapshot)
583        );
584
585        let mut other_identity = running.clone();
586        other_identity.authority_identity = super::super::SessionAuthorityIdentity::Supervisor {
587            incarnation_id: uuid::Uuid::new_v4(),
588        };
589        assert_eq!(
590            running
591                .clone()
592                .adopt_root_tool_authority_from(&other_identity),
593            Err(RootToolAuthorityError::StaleSnapshot)
594        );
595
596        let mut other_lineage = running.clone();
597        other_lineage.root_session_id = "another-root".into();
598        assert_eq!(
599            running
600                .clone()
601                .adopt_root_tool_authority_from(&other_lineage),
602            Err(RootToolAuthorityError::StaleSnapshot)
603        );
604        other_lineage = running.clone();
605        other_lineage.spawn_depth = 1;
606        assert_eq!(
607            running
608                .clone()
609                .adopt_root_tool_authority_from(&other_lineage),
610            Err(RootToolAuthorityError::StaleSnapshot)
611        );
612
613        // Legacy Roots can omit this derived field without changing lineage.
614        let mut legacy = running.clone();
615        legacy.root_session_id.clear();
616        legacy.adopt_root_tool_authority_from(&running).unwrap();
617    }
618
619    #[test]
620    fn root_selection_rejects_legacy_plan_or_workflow_that_needs_other_tools() {
621        let mut root = Session::new("root", "model");
622        let runtime = AgentRuntimeState {
623            plan_mode: Some(PlanModeState {
624                entered_at: chrono::Utc::now(),
625                pre_permission_mode: "default".to_string(),
626                plan_file_path: None,
627                status: PlanModeStatus::Exploring,
628            }),
629            ..AgentRuntimeState::default()
630        };
631        root.agent_runtime_state = Some(runtime);
632        assert_eq!(
633            root.set_root_orchestration_only(true),
634            Err(RootToolAuthorityError::LegacyPlanActive)
635        );
636        root.agent_runtime_state = None;
637        root.set_selected_skill_ids(vec!["workflow".to_string()]);
638        assert_eq!(
639            root.set_root_orchestration_only(true),
640            Err(RootToolAuthorityError::WorkflowSelected)
641        );
642        root.clear_selected_skill_ids();
643        root.set_root_orchestration_only(true).unwrap();
644        root.set_selected_skill_ids(vec!["workflow".to_string()]);
645        assert_eq!(
646            root.validate_root_orchestration_compatibility(),
647            Err(RootToolAuthorityError::WorkflowSelected)
648        );
649    }
650
651    #[test]
652    fn live_authority_adoption_resets_prior_model_context_epoch() {
653        for enabled in [false, true] {
654            let mut running = Session::new("root", "model");
655            if !enabled {
656                running.set_root_orchestration_only(true).unwrap();
657            }
658            running.model_context_state = Some(ModelContextState {
659                prefix_epoch: 4,
660                cache_scope_sha256: Some("prior-provider-scope".to_string()),
661                ..ModelContextState::default()
662            });
663            let mut durable = running.clone();
664            durable.set_root_orchestration_only(enabled).unwrap();
665            running.adopt_root_tool_authority_from(&durable).unwrap();
666            let context = running.model_context_state.as_ref().unwrap();
667            assert_eq!(context.prefix_epoch, 5);
668            assert!(context.cache_scope_sha256.is_none());
669            assert_eq!(
670                context.last_reset_reason,
671                Some(ModelContextResetReason::CacheScopeChanged)
672            );
673        }
674    }
675
676    #[test]
677    fn live_authority_adoption_preserves_all_missed_mode_resets() {
678        let mut running = Session::new("root", "model");
679        running.model_context_state = Some(ModelContextState {
680            prefix_epoch: 4,
681            cache_scope_sha256: Some("old scope".into()),
682            ..Default::default()
683        });
684        running
685            .activate_provider_transcript_route(
686                super::super::provider_transcript::ProviderFamily::OpenAi,
687                super::super::provider_transcript::ProviderProtocol::OpenAiResponsesV1,
688                &"a".repeat(64),
689            )
690            .unwrap();
691        let mut durable = running.clone();
692        durable.set_root_orchestration_only(true).unwrap();
693        // A prepared round between selections makes the second reset a new
694        // ledger boundary rather than the existing pending-reset coalescing.
695        durable
696            .model_context_state
697            .as_mut()
698            .unwrap()
699            .cache_scope_sha256 = Some("new scope".into());
700        durable.set_root_orchestration_only(false).unwrap();
701        assert_eq!(running.root_thinking_mode(), durable.root_thinking_mode());
702        running.adopt_root_tool_authority_from(&durable).unwrap();
703        assert_eq!(running.model_context_state, durable.model_context_state);
704        assert_eq!(
705            running.model_context_state.as_ref().unwrap().prefix_epoch,
706            6
707        );
708        assert_eq!(running.provider_transcript, durable.provider_transcript);
709        // A recovery-only epoch advance must preserve current owner work.
710        running
711            .model_context_state
712            .as_mut()
713            .unwrap()
714            .cache_scope_sha256 = Some("owner scope".into());
715        let owner_context = running.model_context_state.clone();
716        durable.root_mode_transition_epoch += 1;
717        running.adopt_root_tool_authority_from(&durable).unwrap();
718        assert_eq!(running.model_context_state, owner_context);
719    }
720}