Skip to main content

meerkat_core/
ops.rs

1//! Async operation types for Meerkat
2//!
3//! Unified abstraction for tool calls, shell commands, and delegated branches.
4
5use crate::budget::BudgetLimits;
6use crate::error::ToolError;
7use crate::session::DeferredToolLoadAuthority;
8use crate::types::{Message, ToolNameSet};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12/// Unique identifier for an operation
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
15pub struct OperationId(#[cfg_attr(feature = "schema", schemars(with = "String"))] pub Uuid);
16
17/// Wait policy for async operations.
18///
19/// Determines whether an operation blocks the turn boundary (`Barrier`) or runs
20/// independently (`Detached`).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum WaitPolicy {
24    /// Operation must complete before `ToolCallsResolved` can fire.
25    Barrier,
26    /// Operation runs independently and does not block the turn.
27    Detached,
28}
29
30/// Typed async operation reference carrying an operation ID and its wait policy.
31///
32/// Replaces raw `OperationId` sequences in the TurnExecution machine state to
33/// enable barrier-aware scheduling. Only `Barrier` ops block the turn boundary;
34/// `Detached` ops are recorded but do not gate `ToolCallsResolved`.
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36pub struct AsyncOpRef {
37    pub operation_id: OperationId,
38    pub wait_policy: WaitPolicy,
39}
40
41impl WaitPolicy {
42    /// Normal tool-call operations that must complete before the turn boundary.
43    pub fn barrier() -> Self {
44        Self::Barrier
45    }
46
47    /// Background or mob-child operations that run independently of the turn.
48    pub fn detached() -> Self {
49        Self::Detached
50    }
51}
52
53impl AsyncOpRef {
54    /// Create a barrier op ref — blocks the turn boundary until resolved.
55    pub fn barrier(operation_id: OperationId) -> Self {
56        Self {
57            operation_id,
58            wait_policy: WaitPolicy::barrier(),
59        }
60    }
61
62    /// Create a detached op ref — runs independently, does not block the turn.
63    pub fn detached(operation_id: OperationId) -> Self {
64        Self {
65            operation_id,
66            wait_policy: WaitPolicy::detached(),
67        }
68    }
69}
70
71/// Outcome of a tool dispatch, separating transcript data from execution metadata.
72///
73/// `result` is what the model sees (conversation/transcript). `async_ops` is
74/// what the runtime scheduler sees (barrier/detached classification). This
75/// prevents hooks, persistence, and message serialization from accidentally
76/// owning barrier semantics.
77/// Typed session-level effect produced by tool dispatch.
78///
79/// Tools that need to mutate session-owned durable state (e.g., mob authority)
80/// must NOT call `SessionService` methods from inside dispatch. Instead they
81/// return typed effects here, and the turn owner (agent loop) merges and commits
82/// them after the parallel tool batch completes.
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(tag = "effect_type", rename_all = "snake_case")]
85pub enum SessionEffect {
86    /// Replace the session's canonical mob operator authority projection.
87    ///
88    /// The replacement context is constructible only by generated machine
89    /// authority. The turn owner rejects deserialized/unsealed effects before
90    /// installing the durable projection and does not widen scope from local
91    /// effect fields.
92    ReplaceMobToolAuthorityContext {
93        authority_context: crate::service::MobToolAuthorityContext,
94    },
95    /// Record durable deferred-tool requests for subsequent boundaries.
96    RequestDeferredTools {
97        authorities: Vec<DeferredToolLoadAuthority>,
98    },
99    /// Append durable assistant blocks produced by a tool after the tool has
100    /// committed any external payloads (for example generated image blobs).
101    AppendAssistantBlocks {
102        blocks: Vec<crate::types::AssistantBlock>,
103    },
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum ToolDispatchTerminalErrorKind {
108    NotFound,
109    Unavailable,
110    InvalidArguments,
111    ExecutionFailed,
112    Timeout,
113    AccessDenied,
114    Other,
115    CallbackPending,
116}
117
118impl From<&ToolError> for ToolDispatchTerminalErrorKind {
119    fn from(error: &ToolError) -> Self {
120        match error {
121            ToolError::NotFound { .. } => Self::NotFound,
122            ToolError::Unavailable { .. } => Self::Unavailable,
123            ToolError::InvalidArguments { .. } => Self::InvalidArguments,
124            ToolError::ExecutionFailed { .. } | ToolError::ExecutionFailedWithData { .. } => {
125                Self::ExecutionFailed
126            }
127            ToolError::Timeout { .. } | ToolError::InactivityTimeout { .. } => Self::Timeout,
128            ToolError::AccessDenied { .. } => Self::AccessDenied,
129            ToolError::Other(_) => Self::Other,
130            ToolError::CallbackPending { .. } => Self::CallbackPending,
131        }
132    }
133}
134
135/// The canonical, typed terminal cause for a runtime/tool-dispatch failure.
136///
137/// This is the single owner of the terminal failure truth: it carries the
138/// typed [`ToolError`] itself, and both the coarse classification
139/// ([`Self::kind`]) and the model-facing transcript text
140/// ([`Self::to_transcript_content`]) are derived purely from that one value.
141/// Callers must not re-derive the failure class by parsing transcript text.
142#[derive(Debug, Clone, PartialEq)]
143pub enum ToolDispatchTerminalCause {
144    RuntimeToolError { error: ToolError },
145}
146
147impl ToolDispatchTerminalCause {
148    #[must_use]
149    pub fn runtime_tool_error(error: &ToolError) -> Self {
150        Self::RuntimeToolError {
151            error: error.clone(),
152        }
153    }
154
155    /// The coarse terminal error classification, derived from the typed error.
156    #[must_use]
157    pub fn kind(&self) -> ToolDispatchTerminalErrorKind {
158        match self {
159            Self::RuntimeToolError { error } => ToolDispatchTerminalErrorKind::from(error),
160        }
161    }
162
163    /// The canonical model-facing transcript text for this terminal cause.
164    ///
165    /// This is the sole render boundary: the transcript JSON is derived purely
166    /// from the typed error, so the transcript is never a second source of
167    /// truth that can drift from the typed cause.
168    #[must_use]
169    pub fn to_transcript_content(&self) -> String {
170        match self {
171            Self::RuntimeToolError { error } => error.to_transcript_content(),
172        }
173    }
174
175    #[must_use]
176    pub fn is_runtime_tool_timeout(&self) -> bool {
177        self.kind() == ToolDispatchTerminalErrorKind::Timeout
178    }
179}
180
181#[derive(Debug, Clone)]
182pub struct ToolDispatchOutcome {
183    /// The tool result for the conversation transcript.
184    pub result: crate::types::ToolResult,
185    /// Async operations started by this dispatch, with typed wait policies.
186    ///
187    /// Empty for synchronous tools. Barrier ops block the turn boundary;
188    /// detached ops run independently.
189    pub async_ops: Vec<AsyncOpRef>,
190    /// Session-level effects to be merged by the turn owner after the batch.
191    ///
192    /// Most tools return an empty vec. Tools that need durable session state
193    /// changes (e.g., mob authority grants) emit typed effects here instead
194    /// of calling `SessionService` from inside dispatch.
195    pub session_effects: Vec<SessionEffect>,
196    /// Runtime/tool-dispatch-authored terminal cause.
197    ///
198    /// Tool-authored `is_error` results intentionally leave this empty; callers
199    /// must not infer canonical timeout/failure classes by parsing result text.
200    terminal_cause: Option<ToolDispatchTerminalCause>,
201}
202
203/// Optional timeout policy supplied by an external tool-dispatch caller.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum ToolDispatchTimeoutPolicy {
206    /// Use the caller's default timeout value.
207    Default { timeout: std::time::Duration },
208    /// Do not apply a caller-specific timeout. The dispatcher may still apply
209    /// its own normal execution policy.
210    Disabled,
211    /// Apply this finite caller-specific timeout.
212    Finite { timeout: std::time::Duration },
213}
214
215impl ToolDispatchTimeoutPolicy {
216    #[must_use]
217    pub fn timeout(self) -> Option<std::time::Duration> {
218        match self {
219            Self::Default { timeout } | Self::Finite { timeout } => Some(timeout),
220            Self::Disabled => None,
221        }
222    }
223
224    #[must_use]
225    pub fn timeout_ms(self) -> Option<u64> {
226        self.timeout()
227            .map(|timeout| u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX))
228    }
229}
230
231impl ToolDispatchOutcome {
232    /// Create an outcome with explicit async operations and session effects.
233    pub fn new(
234        result: crate::types::ToolResult,
235        async_ops: Vec<AsyncOpRef>,
236        session_effects: Vec<SessionEffect>,
237    ) -> Self {
238        Self {
239            result,
240            async_ops,
241            session_effects,
242            terminal_cause: None,
243        }
244    }
245
246    /// Create an outcome with no async operations or session effects (synchronous tool).
247    pub fn sync_result(result: crate::types::ToolResult) -> Self {
248        Self::new(result, Vec::new(), Vec::new())
249    }
250
251    #[must_use]
252    pub fn terminal_cause(&self) -> Option<&ToolDispatchTerminalCause> {
253        self.terminal_cause.as_ref()
254    }
255
256    #[must_use]
257    pub fn is_runtime_tool_timeout(&self) -> bool {
258        self.terminal_cause
259            .as_ref()
260            .is_some_and(ToolDispatchTerminalCause::is_runtime_tool_timeout)
261    }
262
263    pub(crate) fn clear_terminal_cause(&mut self) {
264        self.terminal_cause = None;
265    }
266}
267
268impl From<crate::types::ToolResult> for ToolDispatchOutcome {
269    fn from(result: crate::types::ToolResult) -> Self {
270        Self::sync_result(result)
271    }
272}
273
274/// Convert a denied/failed tool dispatch into the canonical terminal tool
275/// outcome shape used by both model-driven and external tool calls.
276pub fn terminal_tool_outcome_for_error(
277    tool_use_id: impl Into<String>,
278    error: ToolError,
279) -> ToolDispatchOutcome {
280    let terminal_cause = ToolDispatchTerminalCause::RuntimeToolError { error };
281    // The transcript text is derived purely from the typed terminal cause, so
282    // the cause is the sole source of truth and the transcript can never drift
283    // from it. Rendering is infallible (no fallback string launder).
284    let content = terminal_cause.to_transcript_content();
285    let mut outcome = ToolDispatchOutcome::sync_result(crate::types::ToolResult::new(
286        tool_use_id.into(),
287        content,
288        true,
289    ));
290    outcome.terminal_cause = Some(terminal_cause);
291    outcome
292}
293
294impl OperationId {
295    /// Create a new operation ID
296    pub fn new() -> Self {
297        Self(crate::time_compat::new_uuid_v7())
298    }
299
300    /// Derive the stable MeerkatMachine operation that owns one session's
301    /// explicit wait binding to a realm-qualified detached job.
302    ///
303    /// Determinism lets a reconstructed runtime re-register the same binding
304    /// after volatile non-terminal operation state is discarded. It does not
305    /// claim, retry, or otherwise mutate detached-job execution authority.
306    pub fn for_detached_job_wait(
307        session_id: &crate::types::SessionId,
308        realm_id: &str,
309        job_id: &str,
310    ) -> Self {
311        let name = format!(
312            "meerkat.detached_job_wait.v1:{}:{}:{}:{}:{}",
313            session_id,
314            realm_id.len(),
315            realm_id,
316            job_id.len(),
317            job_id
318        );
319        Self(Uuid::new_v5(&Uuid::NAMESPACE_URL, name.as_bytes()))
320    }
321}
322
323impl Default for OperationId {
324    fn default() -> Self {
325        Self::new()
326    }
327}
328
329impl std::fmt::Display for OperationId {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        write!(f, "{}", self.0)
332    }
333}
334
335/// What kind of work the operation performs
336#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
337#[serde(rename_all = "snake_case")]
338pub enum WorkKind {
339    /// MCP or internal tool call
340    ToolCall,
341    /// Shell command execution
342    ShellCommand,
343}
344
345/// Shape of the operation's result
346#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
347#[serde(rename_all = "snake_case")]
348pub enum ResultShape {
349    /// Single result value
350    Single,
351    /// Streaming output (progress events)
352    Stream,
353    /// Multiple results (e.g., fork branches)
354    Batch,
355}
356
357/// How much context a delegated branch receives
358#[derive(Debug, Clone, Default, Serialize, Deserialize)]
359#[serde(tag = "type", content = "value", rename_all = "snake_case")]
360pub enum ContextStrategy {
361    /// Complete conversation history (Fork default)
362    #[default]
363    FullHistory,
364    /// Last N turns from parent
365    LastTurns(u32),
366    /// Compressed summary of conversation
367    Summary { max_tokens: u32 },
368    /// Explicit message list
369    Custom { messages: Vec<Message> },
370}
371
372/// How to allocate budget when forking
373#[derive(Debug, Clone, Default, Serialize, Deserialize)]
374#[serde(tag = "type", content = "value", rename_all = "snake_case")]
375pub enum ForkBudgetPolicy {
376    /// Split remaining budget equally among branches
377    #[default]
378    Equal,
379    /// Split proportionally based on weights
380    Proportional,
381    /// Fixed budget per branch
382    Fixed(u64),
383    /// Give all remaining budget to each branch
384    Remaining,
385}
386
387/// Tool access control for delegated branches
388#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
389#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(tag = "type", content = "value", rename_all = "snake_case")]
391pub enum ToolAccessPolicy {
392    /// Inherit all tools from parent
393    #[default]
394    Inherit,
395    /// Only allow specific tools
396    AllowList(ToolNameSet),
397    /// Block specific tools
398    DenyList(ToolNameSet),
399}
400
401/// Policy for operation execution
402#[derive(Debug, Clone, Serialize, Deserialize, Default)]
403pub struct OperationPolicy {
404    /// Timeout for this operation
405    pub timeout_ms: Option<u64>,
406    /// Whether to cancel on parent cancellation
407    pub cancel_on_parent_cancel: bool,
408    /// Whether to include in checkpoints
409    pub checkpoint_results: bool,
410}
411
412/// Complete operation specification
413#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct OperationSpec {
415    pub id: OperationId,
416    pub kind: WorkKind,
417    pub result_shape: ResultShape,
418    pub policy: OperationPolicy,
419    pub budget_reservation: BudgetLimits,
420    pub depth: u32,
421    pub depends_on: Vec<OperationId>,
422    pub context: Option<ContextStrategy>,
423    pub tool_access: Option<ToolAccessPolicy>,
424}
425
426/// Result of a completed operation
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428pub struct OperationResult {
429    pub id: OperationId,
430    pub content: String,
431    pub is_error: bool,
432    pub duration_ms: u64,
433    pub tokens_used: u64,
434}
435
436/// Events from operations
437#[derive(Debug, Clone, Serialize, Deserialize)]
438#[serde(tag = "type", rename_all = "snake_case")]
439pub enum OpEvent {
440    /// Operation started executing
441    Started { id: OperationId, kind: WorkKind },
442
443    /// Progress update (for streaming operations)
444    Progress {
445        id: OperationId,
446        message: String,
447        percent: Option<f32>,
448    },
449
450    /// Operation completed successfully
451    Completed {
452        id: OperationId,
453        result: OperationResult,
454    },
455
456    /// Operation failed
457    Failed { id: OperationId, error: String },
458
459    /// Operation was cancelled
460    Cancelled { id: OperationId },
461}
462
463/// Concurrency limits for operations
464#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct ConcurrencyLimits {
466    /// Maximum delegated-branch nesting depth
467    pub max_depth: u32,
468    /// Maximum concurrent operations (all types)
469    pub max_concurrent_ops: usize,
470    /// Maximum concurrent delegated branches specifically
471    pub max_concurrent_agents: usize,
472    /// Maximum children per parent agent
473    pub max_children_per_agent: usize,
474}
475
476impl Default for ConcurrencyLimits {
477    fn default() -> Self {
478        Self {
479            max_depth: 3,
480            max_concurrent_ops: 32,
481            max_concurrent_agents: 8,
482            max_children_per_agent: 5,
483        }
484    }
485}
486
487/// Specification for spawning a new delegated branch
488#[derive(Debug, Clone, Serialize, Deserialize, Default)]
489pub struct SpawnSpec {
490    /// The prompt/task for the delegated branch
491    pub prompt: String,
492    /// How much context the delegated branch receives
493    pub context: ContextStrategy,
494    /// Which tools the delegated branch can access
495    pub tool_access: ToolAccessPolicy,
496    /// Budget allocation for the delegated branch
497    pub budget: BudgetLimits,
498    /// If false, the delegated branch cannot spawn/fork further
499    pub allow_spawn: bool,
500    /// System prompt override (None = inherit from parent)
501    pub system_prompt: Option<String>,
502}
503
504/// A branch in a fork operation
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct ForkBranch {
507    /// Identifier for this branch
508    pub name: String,
509    /// The prompt/task for this branch
510    pub prompt: String,
511    /// Tool access override (None = inherit)
512    pub tool_access: Option<ToolAccessPolicy>,
513}
514
515#[cfg(test)]
516#[allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
517mod tests {
518    use super::*;
519
520    fn generated_mob_authority_for_test() -> crate::service::MobToolAuthorityContext {
521        crate::service::MobToolAuthorityContext::generated_for_test(
522            crate::service::OpaquePrincipalToken::new("generated-effect-test"),
523            true,
524            true,
525            false,
526            std::collections::BTreeSet::from(["test-mob".to_string()]),
527            std::collections::BTreeMap::new(),
528            None,
529            None,
530        )
531    }
532
533    #[test]
534    fn barrier_constructor_produces_barrier_policy() {
535        assert_eq!(WaitPolicy::barrier(), WaitPolicy::Barrier);
536        let op_ref = AsyncOpRef::barrier(OperationId::new());
537        assert_eq!(op_ref.wait_policy, WaitPolicy::Barrier);
538    }
539
540    #[test]
541    fn detached_constructor_produces_detached_policy() {
542        assert_eq!(WaitPolicy::detached(), WaitPolicy::Detached);
543        let op_ref = AsyncOpRef::detached(OperationId::new());
544        assert_eq!(op_ref.wait_policy, WaitPolicy::Detached);
545    }
546
547    #[test]
548    fn test_operation_id_encoding() {
549        let id = OperationId::new();
550        let json = serde_json::to_string(&id).unwrap();
551
552        let parsed: OperationId = serde_json::from_str(&json).unwrap();
553        assert_eq!(id, parsed);
554    }
555
556    #[test]
557    fn test_work_kind_serialization() {
558        assert_eq!(
559            serde_json::to_value(WorkKind::ToolCall).unwrap(),
560            "tool_call"
561        );
562        assert_eq!(
563            serde_json::to_value(WorkKind::ShellCommand).unwrap(),
564            "shell_command"
565        );
566    }
567
568    #[test]
569    fn test_context_strategy_serialization() {
570        let full = ContextStrategy::FullHistory;
571        let json = serde_json::to_value(&full).unwrap();
572        assert_eq!(json["type"], "full_history");
573
574        let last = ContextStrategy::LastTurns(5);
575        let json = serde_json::to_value(&last).unwrap();
576        assert_eq!(json["type"], "last_turns");
577        // Adjacently-tagged: {"type": "last_turns", "value": 5}
578        assert_eq!(json["value"], 5);
579
580        let summary = ContextStrategy::Summary { max_tokens: 1000 };
581        let json = serde_json::to_value(&summary).unwrap();
582        assert_eq!(json["type"], "summary");
583        // Adjacently-tagged struct variant: {"type": "summary", "value": {"max_tokens": 1000}}
584        assert_eq!(json["value"]["max_tokens"], 1000);
585
586        // Roundtrip
587        let parsed: ContextStrategy = serde_json::from_value(json).unwrap();
588        match parsed {
589            ContextStrategy::Summary { max_tokens } => assert_eq!(max_tokens, 1000),
590            _ => unreachable!("Wrong variant"),
591        }
592    }
593
594    #[test]
595    fn test_fork_budget_policy_serialization() {
596        let policies = vec![
597            (ForkBudgetPolicy::Equal, "equal"),
598            (ForkBudgetPolicy::Proportional, "proportional"),
599            (ForkBudgetPolicy::Remaining, "remaining"),
600        ];
601
602        for (policy, expected_type) in policies {
603            let json = serde_json::to_value(&policy).unwrap();
604            assert_eq!(json["type"], expected_type);
605        }
606
607        let fixed = ForkBudgetPolicy::Fixed(5000);
608        let json = serde_json::to_value(&fixed).unwrap();
609        assert_eq!(json["type"], "fixed");
610        // Adjacently-tagged: {"type": "fixed", "value": 5000}
611        assert_eq!(json["value"], 5000);
612
613        // Roundtrip
614        let parsed: ForkBudgetPolicy = serde_json::from_value(json).unwrap();
615        match parsed {
616            ForkBudgetPolicy::Fixed(tokens) => assert_eq!(tokens, 5000),
617            _ => unreachable!("Wrong variant"),
618        }
619    }
620
621    #[test]
622    fn test_tool_access_policy_serialization() {
623        let inherit = ToolAccessPolicy::Inherit;
624        let json = serde_json::to_value(&inherit).unwrap();
625        assert_eq!(json["type"], "inherit");
626
627        let allow = ToolAccessPolicy::AllowList(["read_file", "write_file"].into_iter().collect());
628        let json = serde_json::to_value(&allow).unwrap();
629        assert_eq!(json["type"], "allow_list");
630        // Adjacently-tagged: {"type": "allow_list", "value": [...]}
631        assert!(json["value"].is_array());
632
633        let deny = ToolAccessPolicy::DenyList(["dangerous_tool"].into_iter().collect());
634        let json = serde_json::to_value(&deny).unwrap();
635        assert_eq!(json["type"], "deny_list");
636        assert!(json["value"].is_array());
637
638        // Roundtrip
639        let parsed: ToolAccessPolicy = serde_json::from_value(json).unwrap();
640        match parsed {
641            ToolAccessPolicy::DenyList(tools) => {
642                assert_eq!(tools.len(), 1);
643                assert!(tools.contains("dangerous_tool"));
644            }
645            _ => unreachable!("Wrong variant"),
646        }
647    }
648
649    #[test]
650    fn test_op_event_serialization() {
651        let events = vec![
652            OpEvent::Started {
653                id: OperationId::new(),
654                kind: WorkKind::ToolCall,
655            },
656            OpEvent::Progress {
657                id: OperationId::new(),
658                message: "50% complete".to_string(),
659                percent: Some(0.5),
660            },
661            OpEvent::Completed {
662                id: OperationId::new(),
663                result: OperationResult {
664                    id: OperationId::new(),
665                    content: "result".to_string(),
666                    is_error: false,
667                    duration_ms: 100,
668                    tokens_used: 50,
669                },
670            },
671            OpEvent::Failed {
672                id: OperationId::new(),
673                error: "timeout".to_string(),
674            },
675            OpEvent::Cancelled {
676                id: OperationId::new(),
677            },
678        ];
679
680        for event in events {
681            let json = serde_json::to_value(&event).unwrap();
682            assert!(json.get("type").is_some());
683
684            // Roundtrip
685            let _: OpEvent = serde_json::from_value(json).unwrap();
686        }
687    }
688
689    #[test]
690    fn test_concurrency_limits_default() {
691        let limits = ConcurrencyLimits::default();
692        assert_eq!(limits.max_depth, 3);
693        assert_eq!(limits.max_concurrent_ops, 32);
694        assert_eq!(limits.max_concurrent_agents, 8);
695        assert_eq!(limits.max_children_per_agent, 5);
696    }
697
698    #[test]
699    fn session_effect_replace_mob_authority_context_deserializes_without_authority_seal() {
700        let effect = SessionEffect::ReplaceMobToolAuthorityContext {
701            authority_context: generated_mob_authority_for_test(),
702        };
703        let json = serde_json::to_value(&effect).unwrap();
704        let parsed: SessionEffect = serde_json::from_value(json).unwrap();
705        match parsed {
706            SessionEffect::ReplaceMobToolAuthorityContext { authority_context } => {
707                assert!(!authority_context.is_generated_authority_context());
708                assert!(!authority_context.can_create_mobs());
709                assert!(!authority_context.can_mutate_profiles());
710                assert!(!authority_context.can_manage_mob("test-mob"));
711            }
712            other => panic!("unexpected session effect: {other:?}"),
713        }
714    }
715
716    #[test]
717    fn tool_dispatch_outcome_with_session_effects() {
718        let result = crate::types::ToolResult::new("t1".into(), "ok".into(), false);
719        let outcome = ToolDispatchOutcome::new(
720            result,
721            vec![],
722            vec![SessionEffect::ReplaceMobToolAuthorityContext {
723                authority_context: generated_mob_authority_for_test(),
724            }],
725        );
726        assert_eq!(outcome.session_effects.len(), 1);
727        assert_eq!(outcome.terminal_cause(), None);
728    }
729
730    #[test]
731    fn tool_dispatch_outcome_sync_result_has_empty_effects() {
732        let result = crate::types::ToolResult::new("t1".into(), "ok".into(), false);
733        let outcome = ToolDispatchOutcome::sync_result(result);
734        assert!(outcome.session_effects.is_empty());
735        assert_eq!(outcome.terminal_cause(), None);
736    }
737
738    #[test]
739    fn terminal_tool_outcome_carries_runtime_timeout_cause() {
740        let outcome = terminal_tool_outcome_for_error("t1", ToolError::timeout("slow_tool", 50));
741
742        assert!(outcome.result.is_error);
743        assert!(outcome.is_runtime_tool_timeout());
744        let cause = outcome.terminal_cause().expect("terminal cause present");
745        assert_eq!(cause.kind(), ToolDispatchTerminalErrorKind::Timeout);
746        assert_eq!(
747            cause,
748            &ToolDispatchTerminalCause::RuntimeToolError {
749                error: ToolError::timeout("slow_tool", 50),
750            }
751        );
752    }
753
754    #[test]
755    fn terminal_tool_outcome_transcript_text_is_derived_purely_from_terminal_cause() {
756        // Doctrine (Rule 8): the terminal cause is the sole source of truth.
757        // The transcript content must be byte-for-byte derived from it, with
758        // no second derivation and no fallback-string launder path.
759        let error = ToolError::execution_failed_with_data(
760            "boom",
761            serde_json::json!({ "detail": "structured", "n": 7 }),
762        );
763        let outcome = terminal_tool_outcome_for_error("t1", error);
764
765        let cause = outcome.terminal_cause().expect("terminal cause present");
766        assert_eq!(outcome.result.text_content(), cause.to_transcript_content());
767    }
768
769    #[test]
770    fn tool_authored_error_result_has_no_runtime_terminal_cause() {
771        let result =
772            crate::types::ToolResult::new("t1".into(), "{\"error\":\"timeout\"}".into(), true);
773        let outcome = ToolDispatchOutcome::sync_result(result);
774
775        assert!(outcome.result.is_error);
776        assert!(!outcome.is_runtime_tool_timeout());
777        assert_eq!(outcome.terminal_cause(), None);
778    }
779}