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