Skip to main content

meerkat_runtime/handles/
turn_state.rs

1//! Runtime impl of [`meerkat_core::handles::TurnStateHandle`].
2
3use std::collections::BTreeSet;
4use std::sync::Arc;
5
6use meerkat_core::handles::{DslTransitionError, TurnStateHandle, TurnStateSnapshot};
7use meerkat_core::lifecycle::RunId;
8use meerkat_core::ops::{AsyncOpRef, OperationId, WaitPolicy};
9use meerkat_core::retry::LlmRetrySchedule;
10use meerkat_core::turn_execution_authority::{
11    TurnFailureReason, TurnPhase, TurnPrimitiveKind, TurnTerminalOutcome,
12};
13
14use super::HandleDslAuthority;
15use crate::meerkat_machine::dsl as mm_dsl;
16
17/// Runtime-backed [`TurnStateHandle`] impl.
18#[derive(Debug)]
19pub struct RuntimeTurnStateHandle {
20    dsl: Arc<HandleDslAuthority>,
21}
22
23impl RuntimeTurnStateHandle {
24    /// Construct a handle backed by the session's shared DSL authority.
25    pub fn new(dsl: Arc<HandleDslAuthority>) -> Self {
26        Self { dsl }
27    }
28
29    /// Construct a handle backed by an ephemeral DSL authority.
30    pub fn ephemeral() -> Self {
31        Self::new(Arc::new(HandleDslAuthority::ephemeral()))
32    }
33}
34
35impl TurnStateHandle for RuntimeTurnStateHandle {
36    fn start_conversation_run(
37        &self,
38        run_id: RunId,
39        primitive_kind: TurnPrimitiveKind,
40        admitted_content_shape: meerkat_core::turn_execution_authority::ContentShape,
41        vision_enabled: bool,
42        image_tool_results_enabled: bool,
43        max_extraction_retries: u64,
44    ) -> Result<(), DslTransitionError> {
45        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
46        self.dsl.apply_input(
47            mm_dsl::MeerkatMachineInput::StartConversationRun {
48                run_id: mm_dsl::RunId::from_domain(&run_id),
49                primitive_kind: mm_dsl::TurnPrimitiveKind::from(primitive_kind),
50                admitted_content_shape: mm_dsl::ContentShape::from(admitted_content_shape),
51                vision_enabled,
52                image_tool_results_enabled,
53                max_extraction_retries,
54            },
55            "TurnStateHandle::start_conversation_run",
56        )
57    }
58
59    fn start_immediate_append(&self, run_id: RunId) -> Result<(), DslTransitionError> {
60        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
61        self.dsl.apply_input(
62            mm_dsl::MeerkatMachineInput::StartImmediateAppend {
63                run_id: mm_dsl::RunId::from_domain(&run_id),
64            },
65            "TurnStateHandle::start_immediate_append",
66        )
67    }
68
69    fn start_immediate_context(&self, run_id: RunId) -> Result<(), DslTransitionError> {
70        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
71        self.dsl.apply_input(
72            mm_dsl::MeerkatMachineInput::StartImmediateContext {
73                run_id: mm_dsl::RunId::from_domain(&run_id),
74            },
75            "TurnStateHandle::start_immediate_context",
76        )
77    }
78
79    fn primitive_applied(&self) -> Result<(), DslTransitionError> {
80        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
81        self.dsl.apply_input(
82            mm_dsl::MeerkatMachineInput::PrimitiveApplied,
83            "TurnStateHandle::primitive_applied",
84        )
85    }
86
87    fn llm_returned_tool_calls(&self, tool_count: u64) -> Result<(), DslTransitionError> {
88        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
89        self.dsl.apply_input(
90            mm_dsl::MeerkatMachineInput::LlmReturnedToolCalls { tool_count },
91            "TurnStateHandle::llm_returned_tool_calls",
92        )
93    }
94
95    fn llm_returned_terminal(&self) -> Result<(), DslTransitionError> {
96        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
97        self.dsl.apply_input(
98            mm_dsl::MeerkatMachineInput::LlmReturnedTerminal,
99            "TurnStateHandle::llm_returned_terminal",
100        )
101    }
102
103    fn register_pending_ops(
104        &self,
105        op_refs: BTreeSet<AsyncOpRef>,
106        barrier_operation_ids: BTreeSet<OperationId>,
107    ) -> Result<(), DslTransitionError> {
108        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
109        self.dsl.apply_input(
110            mm_dsl::MeerkatMachineInput::RegisterPendingOps {
111                op_refs: op_refs
112                    .iter()
113                    .map(|op_ref| op_ref.operation_id.to_string())
114                    .collect(),
115                barrier_operation_ids: barrier_operation_ids
116                    .iter()
117                    .map(ToString::to_string)
118                    .collect(),
119            },
120            "TurnStateHandle::register_pending_ops",
121        )
122    }
123
124    fn tool_calls_resolved(&self) -> Result<(), DslTransitionError> {
125        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
126        self.dsl.apply_input(
127            mm_dsl::MeerkatMachineInput::ToolCallsResolved,
128            "TurnStateHandle::tool_calls_resolved",
129        )
130    }
131
132    fn ops_barrier_satisfied(
133        &self,
134        operation_ids: BTreeSet<OperationId>,
135    ) -> Result<(), DslTransitionError> {
136        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
137        self.dsl.apply_input(
138            mm_dsl::MeerkatMachineInput::OpsBarrierSatisfied {
139                operation_ids: operation_ids.iter().map(ToString::to_string).collect(),
140            },
141            "TurnStateHandle::ops_barrier_satisfied",
142        )
143    }
144
145    fn boundary_continue(&self) -> Result<(), DslTransitionError> {
146        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
147        self.dsl.apply_input(
148            mm_dsl::MeerkatMachineInput::BoundaryContinue,
149            "TurnStateHandle::boundary_continue",
150        )
151    }
152
153    fn boundary_complete(&self) -> Result<(), DslTransitionError> {
154        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
155        self.dsl.apply_input(
156            mm_dsl::MeerkatMachineInput::BoundaryComplete,
157            "TurnStateHandle::boundary_complete",
158        )
159    }
160
161    fn enter_extraction(&self, max_retries: u32) -> Result<(), DslTransitionError> {
162        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
163        self.dsl.apply_input(
164            mm_dsl::MeerkatMachineInput::EnterExtraction {
165                max_extraction_retries: u64::from(max_retries),
166            },
167            "TurnStateHandle::enter_extraction",
168        )
169    }
170
171    fn extraction_start(&self) -> Result<(), DslTransitionError> {
172        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
173        self.dsl.apply_input(
174            mm_dsl::MeerkatMachineInput::ExtractionStart,
175            "TurnStateHandle::extraction_start",
176        )
177    }
178
179    fn extraction_validation_passed(&self) -> Result<(), DslTransitionError> {
180        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
181        self.dsl.apply_input(
182            mm_dsl::MeerkatMachineInput::ExtractionValidationPassed,
183            "TurnStateHandle::extraction_validation_passed",
184        )
185    }
186
187    fn extraction_validation_failed(&self, error: String) -> Result<(), DslTransitionError> {
188        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
189        self.dsl.apply_input(
190            mm_dsl::MeerkatMachineInput::ExtractionValidationFailed { error },
191            "TurnStateHandle::extraction_validation_failed",
192        )
193    }
194
195    fn extraction_failed(&self, error: String) -> Result<(), DslTransitionError> {
196        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
197        self.dsl.apply_input(
198            mm_dsl::MeerkatMachineInput::ExtractionFailed { error },
199            "TurnStateHandle::extraction_failed",
200        )
201    }
202
203    fn recoverable_failure(&self, retry: LlmRetrySchedule) -> Result<(), DslTransitionError> {
204        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
205        self.dsl.apply_input(
206            mm_dsl::MeerkatMachineInput::RecoverableFailure {
207                failure_kind: retry.failure.kind.into(),
208                retry_attempt: u64::from(retry.plan.attempt),
209                max_retries: u64::from(retry.plan.max_retries),
210                selected_delay_ms: retry.plan.selected_delay_ms,
211                error: retry.failure.message,
212            },
213            "TurnStateHandle::recoverable_failure",
214        )
215    }
216
217    fn fatal_failure(&self, reason: TurnFailureReason) -> Result<(), DslTransitionError> {
218        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
219        self.dsl.apply_input(
220            mm_dsl::MeerkatMachineInput::FatalFailure {
221                terminal_cause_kind: mm_dsl::TurnTerminalCauseKind::from(reason.cause_kind),
222                error: reason.message,
223            },
224            "TurnStateHandle::fatal_failure",
225        )
226    }
227
228    fn retry_requested(&self, retry_attempt: u32) -> Result<(), DslTransitionError> {
229        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
230        self.dsl.apply_input(
231            mm_dsl::MeerkatMachineInput::RetryRequested {
232                retry_attempt: u64::from(retry_attempt),
233            },
234            "TurnStateHandle::retry_requested",
235        )
236    }
237
238    fn cancel_now(&self) -> Result<(), DslTransitionError> {
239        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
240        self.dsl.apply_input(
241            mm_dsl::MeerkatMachineInput::CancelNow,
242            "TurnStateHandle::cancel_now",
243        )
244    }
245
246    fn request_cancel_after_boundary(&self) -> Result<(), DslTransitionError> {
247        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
248        self.dsl.apply_input(
249            mm_dsl::MeerkatMachineInput::RequestCancelAfterBoundary,
250            "TurnStateHandle::request_cancel_after_boundary",
251        )
252    }
253
254    fn cancellation_observed(&self) -> Result<(), DslTransitionError> {
255        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
256        self.dsl.apply_input(
257            mm_dsl::MeerkatMachineInput::CancellationObserved,
258            "TurnStateHandle::cancellation_observed",
259        )
260    }
261
262    fn acknowledge_terminal(&self, outcome: TurnTerminalOutcome) -> Result<(), DslTransitionError> {
263        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
264        self.dsl.apply_input(
265            mm_dsl::MeerkatMachineInput::AcknowledgeTerminal {
266                outcome: mm_dsl::TurnTerminalOutcome::from(outcome),
267            },
268            "TurnStateHandle::acknowledge_terminal",
269        )
270    }
271
272    fn turn_limit_reached(&self) -> Result<(), DslTransitionError> {
273        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
274        self.dsl.apply_input(
275            mm_dsl::MeerkatMachineInput::TurnLimitReached,
276            "TurnStateHandle::turn_limit_reached",
277        )
278    }
279
280    fn budget_exhausted(&self) -> Result<(), DslTransitionError> {
281        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
282        self.dsl.apply_input(
283            mm_dsl::MeerkatMachineInput::BudgetExhausted,
284            "TurnStateHandle::budget_exhausted",
285        )
286    }
287
288    fn time_budget_exceeded(&self) -> Result<(), DslTransitionError> {
289        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
290        self.dsl.apply_input(
291            mm_dsl::MeerkatMachineInput::TimeBudgetExceeded,
292            "TurnStateHandle::time_budget_exceeded",
293        )
294    }
295
296    fn force_cancel_no_run(&self) -> Result<(), DslTransitionError> {
297        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
298        self.dsl.apply_input(
299            mm_dsl::MeerkatMachineInput::ForceCancelNoRun,
300            "TurnStateHandle::force_cancel_no_run",
301        )
302    }
303
304    fn run_completed(&self, _run_id: RunId) -> Result<(), DslTransitionError> {
305        // Runtime-backed run terminalization is owned by
306        // MeerkatMachine::Commit after the durable boundary receipt is ready.
307        // Core still emits this effect for standalone/test handles, but this
308        // runtime handle must not provide a second terminal writer.
309        Ok(())
310    }
311
312    fn run_failed(
313        &self,
314        _run_id: RunId,
315        _reason: TurnFailureReason,
316    ) -> Result<(), DslTransitionError> {
317        // Runtime-backed failure terminalization is owned by
318        // MeerkatMachine::Fail/Commit and its durable terminal receipt path.
319        Ok(())
320    }
321
322    fn run_cancelled(&self, _run_id: RunId) -> Result<(), DslTransitionError> {
323        // Runtime-backed cancellation terminalization is owned by machine
324        // commands that can keep lifecycle and durable state aligned.
325        Ok(())
326    }
327
328    fn snapshot(&self) -> TurnStateSnapshot {
329        let state = self.dsl.snapshot_state();
330        let turn_phase = map_turn_phase(state.turn_phase);
331        let barrier_operation_ids: BTreeSet<_> = state
332            .barrier_operation_ids
333            .iter()
334            .filter_map(|id| parse_operation_id(id))
335            .collect();
336        let pending_op_refs = state
337            .pending_op_refs
338            .iter()
339            .filter_map(|id| {
340                parse_operation_id(id).map(|operation_id| AsyncOpRef {
341                    wait_policy: if barrier_operation_ids.contains(&operation_id) {
342                        WaitPolicy::Barrier
343                    } else {
344                        WaitPolicy::Detached
345                    },
346                    operation_id,
347                })
348            })
349            .collect();
350        let active_run_id = if matches!(
351            turn_phase,
352            TurnPhase::Completed | TurnPhase::Failed | TurnPhase::Cancelled
353        ) {
354            None
355        } else {
356            state
357                .current_run_id
358                .as_ref()
359                .and_then(|run_id| uuid::Uuid::parse_str(&run_id.0).ok().map(RunId::from_uuid))
360        };
361        TurnStateSnapshot {
362            active_run_id,
363            loop_state: map_loop_state(state.turn_phase),
364            turn_phase,
365            primitive_kind: state.primitive_kind.map(TurnPrimitiveKind::from),
366            admitted_content_shape: state.admitted_content_shape.map(Into::into),
367            vision_enabled: state.vision_enabled,
368            image_tool_results_enabled: state.image_tool_results_enabled,
369            tool_calls_pending: state.tool_calls_pending,
370            pending_op_refs,
371            barrier_operation_ids,
372            has_barrier_ops: state.has_barrier_ops,
373            barrier_satisfied: state.barrier_satisfied,
374            boundary_count: state.boundary_count,
375            cancel_after_boundary: state.cancel_after_boundary,
376            terminal_outcome: state.terminal_outcome.map(TurnTerminalOutcome::from),
377            terminal_cause_kind: state.terminal_cause_kind.map(Into::into),
378            extraction_attempts: state.extraction_attempts,
379            max_extraction_retries: state.max_extraction_retries,
380            llm_retry_attempt: u32::try_from(state.llm_retry_attempt).unwrap_or(u32::MAX),
381            llm_retry_max_retries: u32::try_from(state.llm_retry_max_retries).unwrap_or(u32::MAX),
382            llm_retry_selected_delay_ms: state.llm_retry_selected_delay_ms,
383        }
384    }
385}
386
387fn parse_operation_id(value: &str) -> Option<OperationId> {
388    uuid::Uuid::parse_str(value).ok().map(OperationId)
389}
390
391/// Exhaustive 1-to-1 projection of the DSL's typed turn phase into the
392/// cross-crate [`TurnPhase`] contract. The compiler enforces that every
393/// DSL variant has a core-facing twin; any new variant in either enum
394/// must be reflected here.
395fn map_turn_phase(phase: mm_dsl::TurnPhase) -> TurnPhase {
396    match phase {
397        mm_dsl::TurnPhase::Ready => TurnPhase::Ready,
398        mm_dsl::TurnPhase::ApplyingPrimitive => TurnPhase::ApplyingPrimitive,
399        mm_dsl::TurnPhase::CallingLlm => TurnPhase::CallingLlm,
400        mm_dsl::TurnPhase::WaitingForOps => TurnPhase::WaitingForOps,
401        mm_dsl::TurnPhase::DrainingBoundary => TurnPhase::DrainingBoundary,
402        mm_dsl::TurnPhase::Extracting => TurnPhase::Extracting,
403        mm_dsl::TurnPhase::ErrorRecovery => TurnPhase::ErrorRecovery,
404        mm_dsl::TurnPhase::Cancelling => TurnPhase::Cancelling,
405        mm_dsl::TurnPhase::Completed => TurnPhase::Completed,
406        mm_dsl::TurnPhase::Failed => TurnPhase::Failed,
407        mm_dsl::TurnPhase::Cancelled => TurnPhase::Cancelled,
408    }
409}
410
411/// Owner-side projection from DSL turn phase to the legacy observable loop
412/// state. Keep this beside `map_turn_phase` so the agent runner receives one
413/// coherent snapshot from the DSL authority instead of reclassifying phases.
414fn map_loop_state(phase: mm_dsl::TurnPhase) -> meerkat_core::LoopState {
415    match phase {
416        mm_dsl::TurnPhase::Ready
417        | mm_dsl::TurnPhase::ApplyingPrimitive
418        | mm_dsl::TurnPhase::CallingLlm => meerkat_core::LoopState::CallingLlm,
419        mm_dsl::TurnPhase::WaitingForOps => meerkat_core::LoopState::WaitingForOps,
420        mm_dsl::TurnPhase::DrainingBoundary | mm_dsl::TurnPhase::Extracting => {
421            meerkat_core::LoopState::DrainingEvents
422        }
423        mm_dsl::TurnPhase::ErrorRecovery => meerkat_core::LoopState::ErrorRecovery,
424        mm_dsl::TurnPhase::Cancelling => meerkat_core::LoopState::Cancelling,
425        mm_dsl::TurnPhase::Completed | mm_dsl::TurnPhase::Failed | mm_dsl::TurnPhase::Cancelled => {
426            meerkat_core::LoopState::Completed
427        }
428    }
429}
430
431#[cfg(test)]
432#[allow(clippy::unwrap_used)]
433mod tests {
434    use super::*;
435    use meerkat_core::retry::{
436        LlmRetryFailure, LlmRetryFailureKind, LlmRetryPlan, LlmRetrySchedule,
437    };
438    use uuid::Uuid;
439
440    fn retry_schedule(attempt: u32) -> LlmRetrySchedule {
441        LlmRetrySchedule {
442            failure: LlmRetryFailure {
443                provider: "test".to_string(),
444                kind: LlmRetryFailureKind::RateLimited,
445                retry_after_ms: Some(1_000),
446                duration_ms: None,
447                message: "rate limited".to_string(),
448            },
449            plan: LlmRetryPlan {
450                attempt,
451                max_retries: 3,
452                computed_delay_ms: 500,
453                selected_delay_ms: 1_000,
454                retry_after_hint_ms: Some(1_000),
455                rate_limit_floor_applied: false,
456                budget_capped: false,
457            },
458        }
459    }
460
461    fn unknown_terminal_cause_reason(message: &'static str) -> TurnFailureReason {
462        TurnFailureReason::with_cause(
463            meerkat_core::TurnTerminalCauseKind::Unknown,
464            meerkat_core::event::AgentErrorClass::Internal,
465            message,
466        )
467    }
468
469    fn specific_terminal_cause_reason(
470        cause_kind: meerkat_core::TurnTerminalCauseKind,
471        message: &'static str,
472    ) -> TurnFailureReason {
473        TurnFailureReason::with_cause(cause_kind, cause_kind.agent_error_class(), message)
474    }
475
476    #[test]
477    fn snapshot_carries_active_run_id_for_runtime_backed_turns() {
478        let handle = RuntimeTurnStateHandle::ephemeral();
479        let run_id = RunId(Uuid::from_u128(7));
480
481        handle
482            .start_conversation_run(
483                run_id.clone(),
484                TurnPrimitiveKind::ConversationTurn,
485                meerkat_core::turn_execution_authority::ContentShape::Conversation,
486                true,
487                false,
488                2,
489            )
490            .unwrap();
491
492        let snapshot = handle.snapshot();
493        assert_eq!(snapshot.active_run_id, Some(run_id.clone()));
494        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
495        assert_eq!(
496            snapshot.primitive_kind,
497            Some(TurnPrimitiveKind::ConversationTurn)
498        );
499    }
500
501    #[test]
502    fn snapshot_clears_active_run_id_after_terminal_turn() {
503        let handle = RuntimeTurnStateHandle::ephemeral();
504        let run_id = RunId(Uuid::from_u128(8));
505
506        handle
507            .start_conversation_run(
508                run_id,
509                TurnPrimitiveKind::ConversationTurn,
510                meerkat_core::turn_execution_authority::ContentShape::Conversation,
511                false,
512                false,
513                0,
514            )
515            .unwrap();
516        handle.primitive_applied().unwrap();
517        handle.llm_returned_terminal().unwrap();
518        handle.boundary_complete().unwrap();
519
520        let snapshot = handle.snapshot();
521        assert_eq!(snapshot.turn_phase, TurnPhase::Completed);
522        assert_eq!(snapshot.active_run_id, None);
523    }
524
525    #[test]
526    fn cancel_after_boundary_cancels_continuation_boundary() {
527        let handle = RuntimeTurnStateHandle::ephemeral();
528        let run_id = RunId(Uuid::from_u128(18));
529
530        handle
531            .start_conversation_run(
532                run_id,
533                TurnPrimitiveKind::ConversationTurn,
534                meerkat_core::turn_execution_authority::ContentShape::Conversation,
535                false,
536                false,
537                0,
538            )
539            .unwrap();
540        handle.primitive_applied().unwrap();
541        handle.llm_returned_tool_calls(1).unwrap();
542        handle
543            .register_pending_ops(BTreeSet::new(), BTreeSet::new())
544            .unwrap();
545        handle.tool_calls_resolved().unwrap();
546        handle.request_cancel_after_boundary().unwrap();
547        handle.boundary_continue().unwrap();
548
549        let snapshot = handle.snapshot();
550        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
551        assert_eq!(
552            snapshot.terminal_outcome,
553            Some(TurnTerminalOutcome::Cancelled)
554        );
555        assert!(!snapshot.cancel_after_boundary);
556        assert_eq!(snapshot.active_run_id, None);
557    }
558
559    #[test]
560    fn cancel_after_boundary_cancels_terminal_boundary() {
561        let handle = RuntimeTurnStateHandle::ephemeral();
562        let run_id = RunId(Uuid::from_u128(19));
563
564        handle
565            .start_conversation_run(
566                run_id,
567                TurnPrimitiveKind::ConversationTurn,
568                meerkat_core::turn_execution_authority::ContentShape::Conversation,
569                false,
570                false,
571                0,
572            )
573            .unwrap();
574        handle.primitive_applied().unwrap();
575        handle.llm_returned_terminal().unwrap();
576        handle.request_cancel_after_boundary().unwrap();
577        handle.boundary_complete().unwrap();
578
579        let snapshot = handle.snapshot();
580        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
581        assert_eq!(
582            snapshot.terminal_outcome,
583            Some(TurnTerminalOutcome::Cancelled)
584        );
585        assert!(!snapshot.cancel_after_boundary);
586        assert_eq!(snapshot.active_run_id, None);
587    }
588
589    #[test]
590    fn immediate_append_derives_content_shape() {
591        let handle = RuntimeTurnStateHandle::ephemeral();
592        let run_id = RunId(Uuid::from_u128(10));
593
594        handle.start_immediate_append(run_id).unwrap();
595
596        assert_eq!(
597            handle.snapshot().admitted_content_shape,
598            Some(meerkat_core::turn_execution_authority::ContentShape::ImmediateAppend)
599        );
600    }
601
602    #[test]
603    fn cancel_after_boundary_cancels_immediate_boundary() {
604        let handle = RuntimeTurnStateHandle::ephemeral();
605        let run_id = RunId(Uuid::from_u128(20));
606
607        handle.start_immediate_append(run_id).unwrap();
608        handle.request_cancel_after_boundary().unwrap();
609        handle.primitive_applied().unwrap();
610
611        let snapshot = handle.snapshot();
612        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
613        assert_eq!(
614            snapshot.terminal_outcome,
615            Some(TurnTerminalOutcome::Cancelled)
616        );
617        assert!(!snapshot.cancel_after_boundary);
618        assert_eq!(snapshot.active_run_id, None);
619    }
620
621    #[test]
622    fn retry_schedule_is_recorded_and_attempt_guarded() {
623        let handle = RuntimeTurnStateHandle::ephemeral();
624        let run_id = RunId(Uuid::from_u128(9));
625
626        handle
627            .start_conversation_run(
628                run_id,
629                TurnPrimitiveKind::ConversationTurn,
630                meerkat_core::turn_execution_authority::ContentShape::Conversation,
631                false,
632                false,
633                0,
634            )
635            .unwrap();
636        handle.primitive_applied().unwrap();
637
638        handle.recoverable_failure(retry_schedule(2)).unwrap();
639
640        let snapshot = handle.snapshot();
641        assert_eq!(snapshot.turn_phase, TurnPhase::ErrorRecovery);
642        assert_eq!(snapshot.llm_retry_attempt, 2);
643        assert_eq!(snapshot.llm_retry_max_retries, 3);
644        assert_eq!(snapshot.llm_retry_selected_delay_ms, 1_000);
645
646        assert!(handle.retry_requested(1).is_err());
647        handle.retry_requested(2).unwrap();
648        assert_eq!(handle.snapshot().turn_phase, TurnPhase::CallingLlm);
649    }
650
651    #[test]
652    fn fatal_failure_unknown_terminal_cause_rejects_before_machine_apply() {
653        let handle = RuntimeTurnStateHandle::ephemeral();
654        let run_id = RunId(Uuid::from_u128(11));
655
656        handle
657            .start_conversation_run(
658                run_id,
659                TurnPrimitiveKind::ConversationTurn,
660                meerkat_core::turn_execution_authority::ContentShape::Conversation,
661                false,
662                false,
663                0,
664            )
665            .unwrap();
666
667        let err = handle
668            .fatal_failure(unknown_terminal_cause_reason(
669                "display text must not classify fatal failure",
670            ))
671            .expect_err("Unknown fatal cause should reject before state mutation");
672
673        assert!(err.is_guard_rejected(), "expected guard rejection: {err:?}");
674        let snapshot = handle.snapshot();
675        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
676        assert_eq!(snapshot.terminal_cause_kind, None);
677
678        handle
679            .fatal_failure(specific_terminal_cause_reason(
680                meerkat_core::TurnTerminalCauseKind::FatalFailure,
681                "explicit fatal failure",
682            ))
683            .expect("specific fatal cause should remain accepted");
684        assert_eq!(
685            handle.snapshot().terminal_cause_kind,
686            Some(meerkat_core::TurnTerminalCauseKind::FatalFailure)
687        );
688    }
689
690    #[test]
691    fn run_failed_effect_does_not_terminalize_runtime_state() {
692        let handle = RuntimeTurnStateHandle::ephemeral();
693        let run_id = RunId(Uuid::from_u128(12));
694
695        handle
696            .start_conversation_run(
697                run_id.clone(),
698                TurnPrimitiveKind::ConversationTurn,
699                meerkat_core::turn_execution_authority::ContentShape::Conversation,
700                false,
701                false,
702                0,
703            )
704            .unwrap();
705
706        handle
707            .run_failed(
708                run_id.clone(),
709                unknown_terminal_cause_reason("display text must not classify run failure"),
710            )
711            .expect("runtime-backed run_failed effect is observation-only");
712
713        let snapshot = handle.snapshot();
714        assert_eq!(snapshot.active_run_id, Some(run_id.clone()));
715        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
716        assert_eq!(snapshot.terminal_cause_kind, None);
717
718        handle
719            .run_completed(run_id.clone())
720            .expect("runtime-backed run_completed effect is observation-only");
721        handle
722            .run_cancelled(run_id)
723            .expect("runtime-backed run_cancelled effect is observation-only");
724        let snapshot = handle.snapshot();
725        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
726        assert_eq!(snapshot.terminal_cause_kind, None);
727    }
728}