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;
10#[cfg(test)]
11use meerkat_core::turn_execution_authority::TurnFailureSourceKind;
12use meerkat_core::turn_execution_authority::{
13    CallTimeoutSource as TurnCallTimeoutSource, CallTimeoutVerdict as TurnCallTimeoutVerdict,
14    ContentShape, LlmFailureRecoveryKind, TurnExecutionEffect, TurnExecutionInput,
15    TurnFailureReason, TurnFailureSource, TurnPhase, TurnPrimitiveKind, TurnTerminalCauseKind,
16    TurnTerminalOutcome, terminal_outcome_for_budget_exceeded,
17};
18
19use super::HandleDslAuthority;
20use crate::meerkat_machine::dsl as mm_dsl;
21
22/// Runtime-backed [`TurnStateHandle`] impl.
23#[derive(Debug)]
24pub struct RuntimeTurnStateHandle {
25    dsl: Arc<HandleDslAuthority>,
26}
27
28impl RuntimeTurnStateHandle {
29    /// Construct a handle backed by the session's shared DSL authority.
30    pub fn new(dsl: Arc<HandleDslAuthority>) -> Self {
31        Self { dsl }
32    }
33
34    /// Construct a handle backed by an ephemeral DSL authority.
35    pub fn ephemeral() -> Self {
36        Self::new(Arc::new(HandleDslAuthority::ephemeral()))
37    }
38}
39
40fn parse_effect_run_id(
41    run_id: &mm_dsl::RunId,
42    context: &'static str,
43) -> Result<RunId, DslTransitionError> {
44    uuid::Uuid::parse_str(&run_id.0)
45        .map(RunId::from_uuid)
46        .map_err(|err| {
47            DslTransitionError::guard_rejected(
48                context,
49                format!(
50                    "generated MeerkatMachine turn effect carried malformed run_id `{}`: {err}",
51                    run_id.0
52                ),
53            )
54        })
55}
56
57fn map_generated_turn_effect(
58    effect: mm_dsl::MeerkatMachineEffect,
59    context: &'static str,
60) -> Result<Option<TurnExecutionEffect>, DslTransitionError> {
61    Ok(Some(match effect {
62        mm_dsl::MeerkatMachineEffect::TurnRunStarted { run_id } => {
63            TurnExecutionEffect::RunStarted {
64                run_id: parse_effect_run_id(&run_id, context)?,
65            }
66        }
67        mm_dsl::MeerkatMachineEffect::TurnBoundaryApplied {
68            run_id,
69            boundary_sequence,
70        } => TurnExecutionEffect::BoundaryApplied {
71            run_id: parse_effect_run_id(&run_id, context)?,
72            boundary_sequence,
73        },
74        mm_dsl::MeerkatMachineEffect::TurnRunCompleted { run_id, .. } => {
75            TurnExecutionEffect::RunCompleted {
76                run_id: parse_effect_run_id(&run_id, context)?,
77            }
78        }
79        mm_dsl::MeerkatMachineEffect::TurnRunFailed {
80            run_id,
81            terminal_cause_kind,
82            error,
83        } => {
84            let cause_kind: TurnTerminalCauseKind = terminal_cause_kind.into();
85            if !cause_kind.is_specific_failure_cause() {
86                return Err(DslTransitionError::guard_rejected(
87                    context,
88                    "generated MeerkatMachine TurnRunFailed effect carried unknown terminal_cause_kind",
89                ));
90            }
91            TurnExecutionEffect::RunFailed {
92                run_id: parse_effect_run_id(&run_id, context)?,
93                reason: TurnFailureReason::with_cause(
94                    cause_kind,
95                    cause_kind.agent_error_class(),
96                    error,
97                ),
98            }
99        }
100        mm_dsl::MeerkatMachineEffect::TurnRunCancelled { run_id, .. } => {
101            TurnExecutionEffect::RunCancelled {
102                run_id: parse_effect_run_id(&run_id, context)?,
103            }
104        }
105        mm_dsl::MeerkatMachineEffect::TurnCheckCompaction => TurnExecutionEffect::CheckCompaction,
106        mm_dsl::MeerkatMachineEffect::LlmFailureRecoveryClassified { recovery } => {
107            TurnExecutionEffect::LlmFailureRecoveryClassified {
108                recovery: match recovery {
109                    mm_dsl::LlmFailureRecoveryKind::Recover => LlmFailureRecoveryKind::Recover,
110                    mm_dsl::LlmFailureRecoveryKind::Exhausted => LlmFailureRecoveryKind::Exhausted,
111                    mm_dsl::LlmFailureRecoveryKind::Fatal => LlmFailureRecoveryKind::Fatal,
112                },
113            }
114        }
115        mm_dsl::MeerkatMachineEffect::AssistantOutputClassified {
116            empty_response_terminal,
117        } => TurnExecutionEffect::AssistantOutputClassified {
118            empty_response_terminal,
119        },
120        mm_dsl::MeerkatMachineEffect::CallTimeoutClassified {
121            verdict,
122            timeout_ms,
123        } => TurnExecutionEffect::CallTimeoutClassified {
124            verdict: match verdict {
125                mm_dsl::CallTimeoutVerdict::RetryableCallTimeout => {
126                    TurnCallTimeoutVerdict::RetryableCallTimeout
127                }
128                mm_dsl::CallTimeoutVerdict::TerminalTurnBudget => {
129                    TurnCallTimeoutVerdict::TerminalTurnBudget
130                }
131            },
132            timeout_ms,
133        },
134        _ => return Ok(None),
135    }))
136}
137
138impl TurnStateHandle for RuntimeTurnStateHandle {
139    fn apply_turn_input(
140        &self,
141        input: TurnExecutionInput,
142    ) -> Result<Vec<TurnExecutionEffect>, DslTransitionError> {
143        let context = "TurnStateHandle::apply_turn_input";
144        let dsl_input = match input {
145            TurnExecutionInput::StartConversationRun {
146                run_id,
147                primitive_kind,
148                admitted_content_shape,
149                vision_enabled,
150                image_tool_results_enabled,
151                max_extraction_retries,
152            } => mm_dsl::MeerkatMachineInput::StartConversationRun {
153                run_id: mm_dsl::RunId::from_domain(&run_id),
154                primitive_kind: mm_dsl::TurnPrimitiveKind::from(primitive_kind),
155                admitted_content_shape: mm_dsl::ContentShape::from(admitted_content_shape),
156                vision_enabled,
157                image_tool_results_enabled,
158                max_extraction_retries,
159            },
160            TurnExecutionInput::StartImmediateAppend { run_id } => {
161                mm_dsl::MeerkatMachineInput::StartImmediateAppend {
162                    run_id: mm_dsl::RunId::from_domain(&run_id),
163                }
164            }
165            TurnExecutionInput::StartImmediateContext { run_id } => {
166                mm_dsl::MeerkatMachineInput::StartImmediateContext {
167                    run_id: mm_dsl::RunId::from_domain(&run_id),
168                }
169            }
170            TurnExecutionInput::PrimitiveApplied { run_id } => {
171                mm_dsl::MeerkatMachineInput::PrimitiveApplied {
172                    run_id: mm_dsl::RunId::from_domain(&run_id),
173                }
174            }
175            TurnExecutionInput::LlmReturnedToolCalls { run_id, tool_count } => {
176                mm_dsl::MeerkatMachineInput::LlmReturnedToolCalls {
177                    run_id: mm_dsl::RunId::from_domain(&run_id),
178                    tool_count: u64::from(tool_count),
179                }
180            }
181            TurnExecutionInput::LlmReturnedTerminal { run_id } => {
182                mm_dsl::MeerkatMachineInput::LlmReturnedTerminal {
183                    run_id: mm_dsl::RunId::from_domain(&run_id),
184                }
185            }
186            TurnExecutionInput::RegisterPendingOps {
187                run_id,
188                op_refs,
189                barrier_operation_ids,
190                ..
191            } => mm_dsl::MeerkatMachineInput::RegisterPendingOps {
192                run_id: mm_dsl::RunId::from_domain(&run_id),
193                op_refs: op_refs
194                    .iter()
195                    .map(|op_ref| op_ref.operation_id.to_string())
196                    .collect(),
197                // #354: barrier ids are now a typed `Set<OperationId>` in the
198                // DSL. The token repr stays the plain-UUID Display string that
199                // `parse_operation_id` round-trips (NOT the JSON `from_domain`
200                // form), so the projection back to domain `OperationId` is
201                // lossless.
202                barrier_operation_ids: barrier_operation_ids
203                    .iter()
204                    .map(|id| mm_dsl::OperationId::from(id.to_string()))
205                    .collect(),
206            },
207            TurnExecutionInput::ToolCallsResolved { run_id } => {
208                mm_dsl::MeerkatMachineInput::ToolCallsResolved {
209                    run_id: mm_dsl::RunId::from_domain(&run_id),
210                }
211            }
212            TurnExecutionInput::OpsBarrierSatisfied {
213                run_id,
214                operation_ids,
215            } => mm_dsl::MeerkatMachineInput::OpsBarrierSatisfied {
216                run_id: mm_dsl::RunId::from_domain(&run_id),
217                // #354: typed `Set<OperationId>`; same plain-UUID token repr.
218                operation_ids: operation_ids
219                    .iter()
220                    .map(|id| mm_dsl::OperationId::from(id.to_string()))
221                    .collect(),
222            },
223            TurnExecutionInput::BoundaryContinue { run_id } => {
224                mm_dsl::MeerkatMachineInput::BoundaryContinue {
225                    run_id: mm_dsl::RunId::from_domain(&run_id),
226                }
227            }
228            TurnExecutionInput::BoundaryComplete { run_id } => {
229                mm_dsl::MeerkatMachineInput::BoundaryComplete {
230                    run_id: mm_dsl::RunId::from_domain(&run_id),
231                }
232            }
233            TurnExecutionInput::RecoverableFailure { run_id, retry } => {
234                mm_dsl::MeerkatMachineInput::RecoverableFailure {
235                    run_id: mm_dsl::RunId::from_domain(&run_id),
236                    failure_kind: retry.failure.kind.into(),
237                    retry_attempt: u64::from(retry.plan.attempt),
238                    max_retries: u64::from(retry.plan.max_retries),
239                    selected_delay_ms: retry.plan.selected_delay_ms,
240                    error: retry.failure.message,
241                }
242            }
243            TurnExecutionInput::FatalFailure { run_id, failure } => {
244                mm_dsl::MeerkatMachineInput::FatalFailure {
245                    run_id: mm_dsl::RunId::from_domain(&run_id),
246                    terminal_failure_source: mm_dsl::RunFailureSourceKind::from(
247                        failure.source_kind,
248                    ),
249                    error: failure.message,
250                }
251            }
252            TurnExecutionInput::RetryRequested {
253                run_id,
254                retry_attempt,
255            } => mm_dsl::MeerkatMachineInput::RetryRequested {
256                run_id: mm_dsl::RunId::from_domain(&run_id),
257                retry_attempt: u64::from(retry_attempt),
258            },
259            TurnExecutionInput::ClassifyLlmFailureRecovery {
260                failure_kind,
261                retry_attempt,
262                max_retries,
263            } => mm_dsl::MeerkatMachineInput::ClassifyLlmFailureRecovery {
264                failure_kind: failure_kind.map(Into::into),
265                retry_attempt: u64::from(retry_attempt),
266                max_retries: u64::from(max_retries),
267            },
268            TurnExecutionInput::ClassifyAssistantOutput {
269                has_visible_or_actionable,
270            } => mm_dsl::MeerkatMachineInput::ClassifyAssistantOutput {
271                has_visible_or_actionable,
272            },
273            TurnExecutionInput::ClassifyCallTimeout { source, timeout_ms } => {
274                mm_dsl::MeerkatMachineInput::ClassifyCallTimeout {
275                    source: match source {
276                        TurnCallTimeoutSource::CallBudget => mm_dsl::CallTimeoutSource::CallBudget,
277                        TurnCallTimeoutSource::TurnBudget => mm_dsl::CallTimeoutSource::TurnBudget,
278                    },
279                    timeout_ms,
280                }
281            }
282            TurnExecutionInput::CancelNow { run_id } => mm_dsl::MeerkatMachineInput::CancelNow {
283                run_id: mm_dsl::RunId::from_domain(&run_id),
284            },
285            TurnExecutionInput::CancelAfterBoundary { run_id } => {
286                mm_dsl::MeerkatMachineInput::RequestCancelAfterBoundary {
287                    run_id: mm_dsl::RunId::from_domain(&run_id),
288                }
289            }
290            TurnExecutionInput::CancellationObserved { run_id } => {
291                mm_dsl::MeerkatMachineInput::CancellationObserved {
292                    run_id: mm_dsl::RunId::from_domain(&run_id),
293                }
294            }
295            TurnExecutionInput::AcknowledgeTerminal { run_id } => {
296                let outcome = self.snapshot().terminal_outcome.ok_or_else(|| {
297                    DslTransitionError::guard_rejected(
298                        context,
299                        "generated MeerkatMachine terminal outcome missing for AcknowledgeTerminal",
300                    )
301                })?;
302                mm_dsl::MeerkatMachineInput::AcknowledgeTerminal {
303                    run_id: mm_dsl::RunId::from_domain(&run_id),
304                    outcome: mm_dsl::TurnTerminalOutcome::from(outcome),
305                }
306            }
307            TurnExecutionInput::TurnLimitReached {
308                run_id,
309                turn_count,
310                max_turns,
311            } => mm_dsl::MeerkatMachineInput::TurnLimitReached {
312                run_id: mm_dsl::RunId::from_domain(&run_id),
313                turn_count,
314                max_turns,
315            },
316            TurnExecutionInput::BudgetExhausted { run_id } => {
317                mm_dsl::MeerkatMachineInput::BudgetExhausted {
318                    run_id: mm_dsl::RunId::from_domain(&run_id),
319                }
320            }
321            TurnExecutionInput::TimeBudgetExceeded { run_id } => {
322                mm_dsl::MeerkatMachineInput::TimeBudgetExceeded {
323                    run_id: mm_dsl::RunId::from_domain(&run_id),
324                }
325            }
326            TurnExecutionInput::BudgetLimitExceeded { run_id, exceeded } => {
327                match terminal_outcome_for_budget_exceeded(exceeded) {
328                    TurnTerminalOutcome::TimeBudgetExceeded => {
329                        mm_dsl::MeerkatMachineInput::TimeBudgetExceeded {
330                            run_id: mm_dsl::RunId::from_domain(&run_id),
331                        }
332                    }
333                    TurnTerminalOutcome::BudgetExhausted => {
334                        mm_dsl::MeerkatMachineInput::BudgetExhausted {
335                            run_id: mm_dsl::RunId::from_domain(&run_id),
336                        }
337                    }
338                    _ => unreachable!("budget exceeded maps only to budget terminal outcomes"),
339                }
340            }
341            TurnExecutionInput::EnterExtraction {
342                run_id,
343                max_retries,
344            } => mm_dsl::MeerkatMachineInput::EnterExtraction {
345                run_id: mm_dsl::RunId::from_domain(&run_id),
346                max_extraction_retries: u64::from(max_retries),
347            },
348            TurnExecutionInput::ExtractionValidationPassed { run_id } => {
349                mm_dsl::MeerkatMachineInput::ExtractionValidationPassed {
350                    run_id: mm_dsl::RunId::from_domain(&run_id),
351                }
352            }
353            TurnExecutionInput::ExtractionValidationFailed { run_id, error } => {
354                mm_dsl::MeerkatMachineInput::ExtractionValidationFailed {
355                    run_id: mm_dsl::RunId::from_domain(&run_id),
356                    error,
357                }
358            }
359            TurnExecutionInput::ExtractionFailed { run_id, error } => {
360                mm_dsl::MeerkatMachineInput::ExtractionFailed {
361                    run_id: mm_dsl::RunId::from_domain(&run_id),
362                    error,
363                }
364            }
365            TurnExecutionInput::ExtractionStart { run_id } => {
366                mm_dsl::MeerkatMachineInput::ExtractionStart {
367                    run_id: mm_dsl::RunId::from_domain(&run_id),
368                }
369            }
370            TurnExecutionInput::ForceCancelNoRun => mm_dsl::MeerkatMachineInput::ForceCancelNoRun,
371        };
372        self.dsl
373            .apply_input_with_effects(dsl_input, context)?
374            .into_iter()
375            .map(|effect| map_generated_turn_effect(effect, context))
376            .filter_map(Result::transpose)
377            .collect()
378    }
379
380    fn start_conversation_run(
381        &self,
382        run_id: RunId,
383        primitive_kind: TurnPrimitiveKind,
384        admitted_content_shape: ContentShape,
385        vision_enabled: bool,
386        image_tool_results_enabled: bool,
387        max_extraction_retries: u64,
388    ) -> Result<(), DslTransitionError> {
389        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
390        self.dsl.apply_input(
391            mm_dsl::MeerkatMachineInput::StartConversationRun {
392                run_id: mm_dsl::RunId::from_domain(&run_id),
393                primitive_kind: mm_dsl::TurnPrimitiveKind::from(primitive_kind),
394                admitted_content_shape: mm_dsl::ContentShape::from(admitted_content_shape),
395                vision_enabled,
396                image_tool_results_enabled,
397                max_extraction_retries,
398            },
399            "TurnStateHandle::start_conversation_run",
400        )
401    }
402
403    fn start_immediate_append(&self, run_id: RunId) -> Result<(), DslTransitionError> {
404        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
405        self.dsl.apply_input(
406            mm_dsl::MeerkatMachineInput::StartImmediateAppend {
407                run_id: mm_dsl::RunId::from_domain(&run_id),
408            },
409            "TurnStateHandle::start_immediate_append",
410        )
411    }
412
413    fn start_immediate_context(&self, run_id: RunId) -> Result<(), DslTransitionError> {
414        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
415        self.dsl.apply_input(
416            mm_dsl::MeerkatMachineInput::StartImmediateContext {
417                run_id: mm_dsl::RunId::from_domain(&run_id),
418            },
419            "TurnStateHandle::start_immediate_context",
420        )
421    }
422
423    fn primitive_applied(&self, run_id: RunId) -> Result<(), DslTransitionError> {
424        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
425        self.dsl.apply_input(
426            mm_dsl::MeerkatMachineInput::PrimitiveApplied {
427                run_id: mm_dsl::RunId::from_domain(&run_id),
428            },
429            "TurnStateHandle::primitive_applied",
430        )
431    }
432
433    fn llm_returned_tool_calls(
434        &self,
435        run_id: RunId,
436        tool_count: u64,
437    ) -> Result<(), DslTransitionError> {
438        self.apply_turn_input(TurnExecutionInput::LlmReturnedToolCalls {
439            run_id,
440            tool_count: u32::try_from(tool_count).map_err(|_| {
441                DslTransitionError::guard_rejected(
442                    "TurnStateHandle::llm_returned_tool_calls",
443                    "tool_count exceeds u32 turn input range",
444                )
445            })?,
446        })
447        .map(|_| ())
448    }
449
450    fn llm_returned_terminal(&self, run_id: RunId) -> Result<(), DslTransitionError> {
451        self.apply_turn_input(TurnExecutionInput::LlmReturnedTerminal { run_id })
452            .map(|_| ())
453    }
454
455    fn register_pending_ops(
456        &self,
457        run_id: RunId,
458        op_refs: BTreeSet<AsyncOpRef>,
459        barrier_operation_ids: BTreeSet<OperationId>,
460    ) -> Result<(), DslTransitionError> {
461        let has_barrier_ops = !barrier_operation_ids.is_empty();
462        self.apply_turn_input(TurnExecutionInput::RegisterPendingOps {
463            run_id,
464            op_refs: op_refs.into_iter().collect(),
465            barrier_operation_ids: barrier_operation_ids.into_iter().collect(),
466            has_barrier_ops,
467        })
468        .map(|_| ())
469    }
470
471    fn tool_calls_resolved(&self, run_id: RunId) -> Result<(), DslTransitionError> {
472        self.apply_turn_input(TurnExecutionInput::ToolCallsResolved { run_id })
473            .map(|_| ())
474    }
475
476    fn ops_barrier_satisfied(
477        &self,
478        run_id: RunId,
479        operation_ids: BTreeSet<OperationId>,
480    ) -> Result<(), DslTransitionError> {
481        self.apply_turn_input(TurnExecutionInput::OpsBarrierSatisfied {
482            run_id,
483            operation_ids: operation_ids.into_iter().collect(),
484        })
485        .map(|_| ())
486    }
487
488    fn boundary_continue(&self, run_id: RunId) -> Result<(), DslTransitionError> {
489        self.apply_turn_input(TurnExecutionInput::BoundaryContinue { run_id })
490            .map(|_| ())
491    }
492
493    fn boundary_complete(&self, run_id: RunId) -> Result<(), DslTransitionError> {
494        self.apply_turn_input(TurnExecutionInput::BoundaryComplete { run_id })
495            .map(|_| ())
496    }
497
498    fn enter_extraction(&self, run_id: RunId, max_retries: u32) -> Result<(), DslTransitionError> {
499        self.apply_turn_input(TurnExecutionInput::EnterExtraction {
500            run_id,
501            max_retries,
502        })
503        .map(|_| ())
504    }
505
506    fn extraction_start(&self, run_id: RunId) -> Result<(), DslTransitionError> {
507        self.apply_turn_input(TurnExecutionInput::ExtractionStart { run_id })
508            .map(|_| ())
509    }
510
511    fn extraction_validation_passed(&self, run_id: RunId) -> Result<(), DslTransitionError> {
512        self.apply_turn_input(TurnExecutionInput::ExtractionValidationPassed { run_id })
513            .map(|_| ())
514    }
515
516    fn extraction_validation_failed(
517        &self,
518        run_id: RunId,
519        error: String,
520    ) -> Result<(), DslTransitionError> {
521        self.apply_turn_input(TurnExecutionInput::ExtractionValidationFailed { run_id, error })
522            .map(|_| ())
523    }
524
525    fn extraction_failed(&self, run_id: RunId, error: String) -> Result<(), DslTransitionError> {
526        self.apply_turn_input(TurnExecutionInput::ExtractionFailed { run_id, error })
527            .map(|_| ())
528    }
529
530    fn recoverable_failure(
531        &self,
532        run_id: RunId,
533        retry: LlmRetrySchedule,
534    ) -> Result<(), DslTransitionError> {
535        self.apply_turn_input(TurnExecutionInput::RecoverableFailure { run_id, retry })
536            .map(|_| ())
537    }
538
539    fn fatal_failure(
540        &self,
541        run_id: RunId,
542        failure: TurnFailureSource,
543    ) -> Result<(), DslTransitionError> {
544        self.apply_turn_input(TurnExecutionInput::FatalFailure { run_id, failure })
545            .map(|_| ())
546    }
547
548    fn retry_requested(&self, run_id: RunId, retry_attempt: u32) -> Result<(), DslTransitionError> {
549        self.apply_turn_input(TurnExecutionInput::RetryRequested {
550            run_id,
551            retry_attempt,
552        })
553        .map(|_| ())
554    }
555
556    fn cancel_now(&self, run_id: RunId) -> Result<(), DslTransitionError> {
557        self.apply_turn_input(TurnExecutionInput::CancelNow { run_id })
558            .map(|_| ())
559    }
560
561    fn request_cancel_after_boundary(&self, run_id: RunId) -> Result<(), DslTransitionError> {
562        self.apply_turn_input(TurnExecutionInput::CancelAfterBoundary { run_id })
563            .map(|_| ())
564    }
565
566    fn cancellation_observed(&self, run_id: RunId) -> Result<(), DslTransitionError> {
567        self.apply_turn_input(TurnExecutionInput::CancellationObserved { run_id })
568            .map(|_| ())
569    }
570
571    fn acknowledge_terminal(&self, run_id: RunId) -> Result<(), DslTransitionError> {
572        self.apply_turn_input(TurnExecutionInput::AcknowledgeTerminal { run_id })
573            .map(|_| ())
574    }
575
576    fn turn_limit_reached(
577        &self,
578        run_id: RunId,
579        turn_count: u64,
580        max_turns: u64,
581    ) -> Result<(), DslTransitionError> {
582        self.apply_turn_input(TurnExecutionInput::TurnLimitReached {
583            run_id,
584            turn_count,
585            max_turns,
586        })
587        .map(|_| ())
588    }
589
590    fn budget_exhausted(&self, run_id: RunId) -> Result<(), DslTransitionError> {
591        self.apply_turn_input(TurnExecutionInput::BudgetExhausted { run_id })
592            .map(|_| ())
593    }
594
595    fn time_budget_exceeded(&self, run_id: RunId) -> Result<(), DslTransitionError> {
596        self.apply_turn_input(TurnExecutionInput::TimeBudgetExceeded { run_id })
597            .map(|_| ())
598    }
599
600    fn force_cancel_no_run(&self) -> Result<(), DslTransitionError> {
601        // intra-machine: no route; dispatcher not applicable (handle targets the meerkat DSL directly, not a CompositionDispatcher seam)
602        self.dsl.apply_input(
603            mm_dsl::MeerkatMachineInput::ForceCancelNoRun,
604            "TurnStateHandle::force_cancel_no_run",
605        )
606    }
607
608    fn run_completed(&self, _run_id: RunId) -> Result<(), DslTransitionError> {
609        // Runtime-backed run terminalization is owned by
610        // MeerkatMachine::Commit after the durable boundary receipt is ready.
611        // Core still emits this effect for standalone/test handles, but this
612        // runtime handle must not provide a second terminal writer.
613        Ok(())
614    }
615
616    fn run_failed(
617        &self,
618        _run_id: RunId,
619        _reason: TurnFailureReason,
620    ) -> Result<(), DslTransitionError> {
621        // Runtime-backed failure terminalization is owned by
622        // MeerkatMachine::Fail/Commit and its durable terminal receipt path.
623        Ok(())
624    }
625
626    fn run_cancelled(&self, _run_id: RunId) -> Result<(), DslTransitionError> {
627        // Runtime-backed cancellation terminalization is owned by machine
628        // commands that can keep lifecycle and durable state aligned.
629        Ok(())
630    }
631
632    #[allow(clippy::expect_used)]
633    fn snapshot(&self) -> TurnStateSnapshot {
634        let state = self.dsl.snapshot_state();
635        let turn_phase = map_turn_phase(state.turn_phase);
636        let barrier_operation_ids: BTreeSet<_> = state
637            .barrier_operation_ids
638            .iter()
639            .map(|id| parse_operation_id(id.0.as_str()))
640            .collect();
641        let pending_op_refs = state
642            .pending_op_refs
643            .iter()
644            .map(|id| {
645                let operation_id = parse_operation_id(id);
646                AsyncOpRef {
647                    wait_policy: if barrier_operation_ids.contains(&operation_id) {
648                        WaitPolicy::Barrier
649                    } else {
650                        WaitPolicy::Detached
651                    },
652                    operation_id,
653                }
654            })
655            .collect();
656        let turn_terminal = classify_turn_terminal(&state);
657        let active_run_id = if turn_terminal {
658            None
659        } else {
660            state.current_run_id.as_ref().map(parse_snapshot_run_id)
661        };
662        TurnStateSnapshot {
663            active_run_id,
664            loop_state: map_loop_state(state.turn_phase),
665            turn_phase,
666            turn_terminal,
667            primitive_kind: state.primitive_kind.map(TurnPrimitiveKind::from),
668            admitted_content_shape: state.admitted_content_shape.map(Into::into),
669            vision_enabled: state.vision_enabled,
670            image_tool_results_enabled: state.image_tool_results_enabled,
671            tool_calls_pending: state.tool_calls_pending,
672            pending_op_refs,
673            barrier_operation_ids,
674            has_barrier_ops: state.has_barrier_ops,
675            barrier_satisfied: state.barrier_satisfied,
676            boundary_count: state.boundary_count,
677            cancel_after_boundary: state.cancel_after_boundary,
678            terminal_outcome: state.terminal_outcome.map(TurnTerminalOutcome::from),
679            terminal_cause_kind: state.terminal_cause_kind.map(Into::into),
680            extraction_attempts: state.extraction_attempts,
681            max_extraction_retries: state.max_extraction_retries,
682            extraction_active: state.extraction_active,
683            llm_retry_attempt: u32::try_from(state.llm_retry_attempt)
684                .expect("generated MeerkatMachine llm_retry_attempt must fit u32"),
685            llm_retry_max_retries: u32::try_from(state.llm_retry_max_retries)
686                .expect("generated MeerkatMachine llm_retry_max_retries must fit u32"),
687            llm_retry_selected_delay_ms: state.llm_retry_selected_delay_ms,
688        }
689    }
690}
691
692#[allow(clippy::expect_used)]
693fn parse_operation_id(value: &str) -> OperationId {
694    uuid::Uuid::parse_str(value)
695        .map(OperationId)
696        .expect("generated MeerkatMachine operation id projection must be well formed")
697}
698
699#[allow(clippy::expect_used)]
700fn parse_snapshot_run_id(run_id: &mm_dsl::RunId) -> RunId {
701    uuid::Uuid::parse_str(&run_id.0)
702        .map(RunId::from_uuid)
703        .expect("generated MeerkatMachine current_run_id projection must be well formed")
704}
705
706/// Mirror the canonical MeerkatMachine turn-terminality verdict over the
707/// recovered turn state.
708///
709/// The terminality verdict (which turn phases are terminal) is a machine fact:
710/// this owner extracts no fact — it recovers a read-only authority from the DSL
711/// state, drives the `ClassifyTurnTerminality` input, and mirrors the emitted
712/// `TurnTerminalityClassified.terminal`. It decides nothing. Fails closed:
713/// an unclassifiable state is treated as terminal so a live run is never assumed
714/// to still be active off an unreadable snapshot.
715fn classify_turn_terminal(state: &mm_dsl::MeerkatMachineState) -> bool {
716    let Ok(mut authority) = mm_dsl::MeerkatMachineAuthority::recover_from_state(state.clone())
717    else {
718        return true;
719    };
720    let Ok(transition) = mm_dsl::MeerkatMachineMutator::apply(
721        &mut authority,
722        mm_dsl::MeerkatMachineInput::ClassifyTurnTerminality {},
723    ) else {
724        return true;
725    };
726    let mut classified = None;
727    for effect in transition.effects() {
728        if let mm_dsl::MeerkatMachineEffect::TurnTerminalityClassified { terminal } = effect
729            && classified.replace(*terminal).is_some()
730        {
731            return true;
732        }
733    }
734    classified.unwrap_or(true)
735}
736
737/// Exhaustive 1-to-1 projection of the DSL's typed turn phase into the
738/// cross-crate [`TurnPhase`] contract. The compiler enforces that every
739/// DSL variant has a core-facing twin; any new variant in either enum
740/// must be reflected here.
741fn map_turn_phase(phase: mm_dsl::TurnPhase) -> TurnPhase {
742    match phase {
743        mm_dsl::TurnPhase::Ready => TurnPhase::Ready,
744        mm_dsl::TurnPhase::ApplyingPrimitive => TurnPhase::ApplyingPrimitive,
745        mm_dsl::TurnPhase::CallingLlm => TurnPhase::CallingLlm,
746        mm_dsl::TurnPhase::WaitingForOps => TurnPhase::WaitingForOps,
747        mm_dsl::TurnPhase::DrainingBoundary => TurnPhase::DrainingBoundary,
748        mm_dsl::TurnPhase::Extracting => TurnPhase::Extracting,
749        mm_dsl::TurnPhase::ErrorRecovery => TurnPhase::ErrorRecovery,
750        mm_dsl::TurnPhase::Cancelling => TurnPhase::Cancelling,
751        mm_dsl::TurnPhase::Completed => TurnPhase::Completed,
752        mm_dsl::TurnPhase::Failed => TurnPhase::Failed,
753        mm_dsl::TurnPhase::Cancelled => TurnPhase::Cancelled,
754    }
755}
756
757/// Owner-side projection from DSL turn phase to the legacy observable loop
758/// state. Keep this beside `map_turn_phase` so the agent runner receives one
759/// coherent snapshot from the DSL authority instead of reclassifying phases.
760fn map_loop_state(phase: mm_dsl::TurnPhase) -> meerkat_core::LoopState {
761    match phase {
762        mm_dsl::TurnPhase::Ready
763        | mm_dsl::TurnPhase::ApplyingPrimitive
764        | mm_dsl::TurnPhase::CallingLlm => meerkat_core::LoopState::CallingLlm,
765        mm_dsl::TurnPhase::WaitingForOps => meerkat_core::LoopState::WaitingForOps,
766        mm_dsl::TurnPhase::DrainingBoundary | mm_dsl::TurnPhase::Extracting => {
767            meerkat_core::LoopState::DrainingEvents
768        }
769        mm_dsl::TurnPhase::ErrorRecovery => meerkat_core::LoopState::ErrorRecovery,
770        mm_dsl::TurnPhase::Cancelling => meerkat_core::LoopState::Cancelling,
771        mm_dsl::TurnPhase::Completed | mm_dsl::TurnPhase::Failed | mm_dsl::TurnPhase::Cancelled => {
772            meerkat_core::LoopState::Completed
773        }
774    }
775}
776
777#[cfg(test)]
778#[allow(clippy::unwrap_used)]
779mod tests {
780    use super::*;
781    use meerkat_core::retry::{
782        LlmRetryFailure, LlmRetryFailureKind, LlmRetryPlan, LlmRetrySchedule,
783    };
784    use uuid::Uuid;
785
786    fn retry_schedule(attempt: u32) -> LlmRetrySchedule {
787        retry_schedule_with_kind(attempt, 3, LlmRetryFailureKind::RateLimited)
788    }
789
790    fn retry_schedule_with_kind(
791        attempt: u32,
792        max_retries: u32,
793        kind: LlmRetryFailureKind,
794    ) -> LlmRetrySchedule {
795        LlmRetrySchedule {
796            failure: LlmRetryFailure {
797                provider: "test".to_string(),
798                kind,
799                retry_after_ms: Some(1_000),
800                duration_ms: None,
801                message: "rate limited".to_string(),
802            },
803            plan: LlmRetryPlan {
804                attempt,
805                max_retries,
806                computed_delay_ms: 500,
807                selected_delay_ms: 1_000,
808                retry_after_hint_ms: Some(1_000),
809                rate_limit_floor_applied: false,
810                budget_capped: false,
811            },
812        }
813    }
814
815    fn start_running_conversation_turn(handle: &RuntimeTurnStateHandle, run_id: &RunId) {
816        handle
817            .start_conversation_run(
818                run_id.clone(),
819                TurnPrimitiveKind::ConversationTurn,
820                meerkat_core::turn_execution_authority::ContentShape::Conversation,
821                false,
822                false,
823                0,
824            )
825            .unwrap();
826        handle.primitive_applied(run_id.clone()).unwrap();
827    }
828
829    fn unknown_failure_source(message: &'static str) -> TurnFailureSource {
830        TurnFailureSource::new(TurnFailureSourceKind::Unknown, message)
831    }
832
833    fn failure_source(
834        source_kind: TurnFailureSourceKind,
835        message: &'static str,
836    ) -> TurnFailureSource {
837        TurnFailureSource::new(source_kind, message)
838    }
839
840    #[test]
841    fn snapshot_carries_active_run_id_for_runtime_backed_turns() {
842        let handle = RuntimeTurnStateHandle::ephemeral();
843        let run_id = RunId(Uuid::from_u128(7));
844
845        handle
846            .start_conversation_run(
847                run_id.clone(),
848                TurnPrimitiveKind::ConversationTurn,
849                meerkat_core::turn_execution_authority::ContentShape::Conversation,
850                true,
851                false,
852                2,
853            )
854            .unwrap();
855
856        let snapshot = handle.snapshot();
857        assert_eq!(snapshot.active_run_id, Some(run_id.clone()));
858        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
859        assert_eq!(
860            snapshot.primitive_kind,
861            Some(TurnPrimitiveKind::ConversationTurn)
862        );
863    }
864
865    #[test]
866    fn primitive_applied_rejects_mismatched_run_id() {
867        let handle = RuntimeTurnStateHandle::ephemeral();
868        let run_id = RunId(Uuid::from_u128(21));
869        let stale_run_id = RunId(Uuid::from_u128(22));
870
871        handle
872            .start_conversation_run(
873                run_id.clone(),
874                TurnPrimitiveKind::ConversationTurn,
875                meerkat_core::turn_execution_authority::ContentShape::Conversation,
876                false,
877                false,
878                0,
879            )
880            .unwrap();
881
882        assert!(handle.primitive_applied(stale_run_id).is_err());
883        let snapshot = handle.snapshot();
884        assert_eq!(snapshot.active_run_id, Some(run_id));
885        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
886    }
887
888    #[test]
889    fn post_primitive_observation_rejects_mismatched_run_id() {
890        let handle = RuntimeTurnStateHandle::ephemeral();
891        let run_id = RunId(Uuid::from_u128(23));
892        let stale_run_id = RunId(Uuid::from_u128(24));
893
894        handle
895            .start_conversation_run(
896                run_id.clone(),
897                TurnPrimitiveKind::ConversationTurn,
898                meerkat_core::turn_execution_authority::ContentShape::Conversation,
899                false,
900                false,
901                0,
902            )
903            .unwrap();
904        handle.primitive_applied(run_id.clone()).unwrap();
905
906        assert!(handle.llm_returned_terminal(stale_run_id).is_err());
907        let snapshot = handle.snapshot();
908        assert_eq!(snapshot.active_run_id, Some(run_id));
909        assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
910    }
911
912    #[test]
913    fn turn_limit_handle_rejects_unreached_limit() {
914        let handle = RuntimeTurnStateHandle::ephemeral();
915        let run_id = RunId(Uuid::from_u128(31));
916        start_running_conversation_turn(&handle, &run_id);
917
918        handle
919            .turn_limit_reached(run_id.clone(), 1, 2)
920            .expect_err("machine guard must reject turn_count below max_turns");
921        let snapshot = handle.snapshot();
922        assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
923        assert_eq!(snapshot.terminal_cause_kind, None);
924
925        handle
926            .turn_limit_reached(run_id, 2, 2)
927            .expect("turn limit reached at the boundary");
928        let snapshot = handle.snapshot();
929        assert_eq!(snapshot.turn_phase, TurnPhase::Failed);
930        assert_eq!(
931            snapshot.terminal_cause_kind,
932            Some(TurnTerminalCauseKind::TurnLimitReached)
933        );
934    }
935
936    #[test]
937    fn fatal_failure_rejects_max_turns_source() {
938        let handle = RuntimeTurnStateHandle::ephemeral();
939        let run_id = RunId(Uuid::from_u128(32));
940        start_running_conversation_turn(&handle, &run_id);
941
942        handle
943            .fatal_failure(
944                run_id.clone(),
945                failure_source(TurnFailureSourceKind::MaxTurnsReached, "max turns"),
946            )
947            .expect_err("turn-limit terminality must use counted TurnLimitReached input");
948        let snapshot = handle.snapshot();
949        assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
950        assert_eq!(snapshot.terminal_cause_kind, None);
951    }
952
953    #[test]
954    fn snapshot_clears_active_run_id_after_terminal_turn() {
955        let handle = RuntimeTurnStateHandle::ephemeral();
956        let run_id = RunId(Uuid::from_u128(8));
957
958        handle
959            .start_conversation_run(
960                run_id.clone(),
961                TurnPrimitiveKind::ConversationTurn,
962                meerkat_core::turn_execution_authority::ContentShape::Conversation,
963                false,
964                false,
965                0,
966            )
967            .unwrap();
968        handle.primitive_applied(run_id.clone()).unwrap();
969        handle.llm_returned_terminal(run_id.clone()).unwrap();
970        handle.boundary_complete(run_id).unwrap();
971
972        let snapshot = handle.snapshot();
973        assert_eq!(snapshot.turn_phase, TurnPhase::Completed);
974        assert_eq!(snapshot.active_run_id, None);
975    }
976
977    #[test]
978    fn cancel_after_boundary_cancels_continuation_boundary() {
979        let handle = RuntimeTurnStateHandle::ephemeral();
980        let run_id = RunId(Uuid::from_u128(18));
981
982        handle
983            .start_conversation_run(
984                run_id.clone(),
985                TurnPrimitiveKind::ConversationTurn,
986                meerkat_core::turn_execution_authority::ContentShape::Conversation,
987                false,
988                false,
989                0,
990            )
991            .unwrap();
992        handle.primitive_applied(run_id.clone()).unwrap();
993        handle.llm_returned_tool_calls(run_id.clone(), 1).unwrap();
994        handle
995            .register_pending_ops(run_id.clone(), BTreeSet::new(), BTreeSet::new())
996            .unwrap();
997        handle.tool_calls_resolved(run_id.clone()).unwrap();
998        handle
999            .request_cancel_after_boundary(run_id.clone())
1000            .unwrap();
1001        handle.boundary_continue(run_id).unwrap();
1002
1003        let snapshot = handle.snapshot();
1004        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
1005        assert_eq!(
1006            snapshot.terminal_outcome,
1007            Some(TurnTerminalOutcome::Cancelled)
1008        );
1009        assert!(!snapshot.cancel_after_boundary);
1010        assert_eq!(snapshot.active_run_id, None);
1011    }
1012
1013    #[test]
1014    fn cancel_after_boundary_cancels_terminal_boundary() {
1015        let handle = RuntimeTurnStateHandle::ephemeral();
1016        let run_id = RunId(Uuid::from_u128(19));
1017
1018        handle
1019            .start_conversation_run(
1020                run_id.clone(),
1021                TurnPrimitiveKind::ConversationTurn,
1022                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1023                false,
1024                false,
1025                0,
1026            )
1027            .unwrap();
1028        handle.primitive_applied(run_id.clone()).unwrap();
1029        handle.llm_returned_terminal(run_id.clone()).unwrap();
1030        handle
1031            .request_cancel_after_boundary(run_id.clone())
1032            .unwrap();
1033        handle.boundary_complete(run_id).unwrap();
1034
1035        let snapshot = handle.snapshot();
1036        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
1037        assert_eq!(
1038            snapshot.terminal_outcome,
1039            Some(TurnTerminalOutcome::Cancelled)
1040        );
1041        assert!(!snapshot.cancel_after_boundary);
1042        assert_eq!(snapshot.active_run_id, None);
1043    }
1044
1045    #[test]
1046    fn immediate_append_derives_content_shape() {
1047        let handle = RuntimeTurnStateHandle::ephemeral();
1048        let run_id = RunId(Uuid::from_u128(10));
1049
1050        handle.start_immediate_append(run_id).unwrap();
1051
1052        assert_eq!(
1053            handle.snapshot().admitted_content_shape,
1054            Some(meerkat_core::turn_execution_authority::ContentShape::ImmediateAppend)
1055        );
1056    }
1057
1058    #[test]
1059    fn cancel_after_boundary_cancels_immediate_boundary() {
1060        let handle = RuntimeTurnStateHandle::ephemeral();
1061        let run_id = RunId(Uuid::from_u128(20));
1062
1063        handle.start_immediate_append(run_id.clone()).unwrap();
1064        handle
1065            .request_cancel_after_boundary(run_id.clone())
1066            .unwrap();
1067        handle.primitive_applied(run_id).unwrap();
1068
1069        let snapshot = handle.snapshot();
1070        assert_eq!(snapshot.turn_phase, TurnPhase::Cancelled);
1071        assert_eq!(
1072            snapshot.terminal_outcome,
1073            Some(TurnTerminalOutcome::Cancelled)
1074        );
1075        assert!(!snapshot.cancel_after_boundary);
1076        assert_eq!(snapshot.active_run_id, None);
1077    }
1078
1079    #[test]
1080    fn retry_schedule_is_recorded_and_attempt_guarded() {
1081        let handle = RuntimeTurnStateHandle::ephemeral();
1082        let run_id = RunId(Uuid::from_u128(9));
1083
1084        handle
1085            .start_conversation_run(
1086                run_id.clone(),
1087                TurnPrimitiveKind::ConversationTurn,
1088                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1089                false,
1090                false,
1091                0,
1092            )
1093            .unwrap();
1094        handle.primitive_applied(run_id.clone()).unwrap();
1095
1096        handle
1097            .recoverable_failure(run_id.clone(), retry_schedule(2))
1098            .unwrap();
1099
1100        let snapshot = handle.snapshot();
1101        assert_eq!(snapshot.turn_phase, TurnPhase::ErrorRecovery);
1102        assert_eq!(snapshot.llm_retry_attempt, 2);
1103        assert_eq!(snapshot.llm_retry_max_retries, 3);
1104        assert_eq!(snapshot.llm_retry_selected_delay_ms, 1_000);
1105
1106        assert!(handle.retry_requested(run_id.clone(), 1).is_err());
1107        handle.retry_requested(run_id, 2).unwrap();
1108        assert_eq!(handle.snapshot().turn_phase, TurnPhase::CallingLlm);
1109    }
1110
1111    /// P0 Dogma Invariant 1: the machine — not the shell `RetryPolicy` — is the
1112    /// authority on retry exhaustion. A `RecoverableFailure` whose one-based
1113    /// `retry_attempt` exceeds `max_retries` must be machine-rejected so the
1114    /// turn cannot enter `ErrorRecovery` past exhaustion.
1115    #[test]
1116    fn recoverable_failure_past_exhaustion_is_machine_rejected() {
1117        let handle = RuntimeTurnStateHandle::ephemeral();
1118        let run_id = RunId(Uuid::from_u128(31));
1119        start_running_conversation_turn(&handle, &run_id);
1120
1121        // attempt 4 with max_retries 3 is past exhaustion.
1122        let exhausted = retry_schedule_with_kind(4, 3, LlmRetryFailureKind::RateLimited);
1123        let err = handle
1124            .recoverable_failure(run_id.clone(), exhausted)
1125            .expect_err("exhausted retry must be rejected by the machine");
1126        assert!(err.is_guard_rejected(), "expected guard rejection: {err:?}");
1127
1128        // The turn never entered recovery; it remains in CallingLlm.
1129        let snapshot = handle.snapshot();
1130        assert_eq!(snapshot.turn_phase, TurnPhase::CallingLlm);
1131        assert_eq!(snapshot.llm_retry_attempt, 0);
1132
1133        // A retry at the exhaustion boundary (attempt == max_retries) is still
1134        // legitimate and the machine accepts it.
1135        let last = retry_schedule_with_kind(3, 3, LlmRetryFailureKind::NetworkTimeout);
1136        handle.recoverable_failure(run_id, last).unwrap();
1137        let snapshot = handle.snapshot();
1138        assert_eq!(snapshot.turn_phase, TurnPhase::ErrorRecovery);
1139        assert_eq!(snapshot.llm_retry_attempt, 3);
1140        assert_eq!(snapshot.llm_retry_max_retries, 3);
1141    }
1142
1143    #[test]
1144    fn fatal_failure_unknown_source_rejects_before_machine_apply() {
1145        let handle = RuntimeTurnStateHandle::ephemeral();
1146        let run_id = RunId(Uuid::from_u128(11));
1147
1148        handle
1149            .start_conversation_run(
1150                run_id.clone(),
1151                TurnPrimitiveKind::ConversationTurn,
1152                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1153                false,
1154                false,
1155                0,
1156            )
1157            .unwrap();
1158
1159        let err = handle
1160            .fatal_failure(
1161                run_id.clone(),
1162                unknown_failure_source("display text must not classify fatal failure"),
1163            )
1164            .expect_err("unknown fatal source should reject before state mutation");
1165
1166        assert!(err.is_guard_rejected(), "expected guard rejection: {err:?}");
1167        let snapshot = handle.snapshot();
1168        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1169        assert_eq!(snapshot.terminal_cause_kind, None);
1170
1171        handle
1172            .fatal_failure(
1173                run_id,
1174                failure_source(TurnFailureSourceKind::InternalError, "fatal failure"),
1175            )
1176            .expect("specific fatal source should remain accepted");
1177        assert_eq!(
1178            handle.snapshot().terminal_cause_kind,
1179            Some(meerkat_core::TurnTerminalCauseKind::FatalFailure)
1180        );
1181    }
1182
1183    #[test]
1184    fn run_failed_effect_does_not_terminalize_runtime_state() {
1185        let handle = RuntimeTurnStateHandle::ephemeral();
1186        let run_id = RunId(Uuid::from_u128(12));
1187
1188        handle
1189            .start_conversation_run(
1190                run_id.clone(),
1191                TurnPrimitiveKind::ConversationTurn,
1192                meerkat_core::turn_execution_authority::ContentShape::Conversation,
1193                false,
1194                false,
1195                0,
1196            )
1197            .unwrap();
1198
1199        handle
1200            .run_failed(
1201                run_id.clone(),
1202                TurnFailureReason::with_cause(
1203                    meerkat_core::TurnTerminalCauseKind::Unknown,
1204                    meerkat_core::event::AgentErrorClass::Internal,
1205                    "display text must not classify run failure",
1206                ),
1207            )
1208            .expect("runtime-backed run_failed effect is observation-only");
1209
1210        let snapshot = handle.snapshot();
1211        assert_eq!(snapshot.active_run_id, Some(run_id.clone()));
1212        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1213        assert_eq!(snapshot.terminal_cause_kind, None);
1214
1215        handle
1216            .run_completed(run_id.clone())
1217            .expect("runtime-backed run_completed effect is observation-only");
1218        handle
1219            .run_cancelled(run_id)
1220            .expect("runtime-backed run_cancelled effect is observation-only");
1221        let snapshot = handle.snapshot();
1222        assert_eq!(snapshot.turn_phase, TurnPhase::ApplyingPrimitive);
1223        assert_eq!(snapshot.terminal_cause_kind, None);
1224    }
1225}