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