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