everruns_core/turn.rs
1//! Turn State Machine - Unified Turn Orchestration
2//!
3//! # Why This Module Exists
4//!
5//! This module provides a unified state machine for orchestrating agent turns,
6//! extracting the common logic from two previously duplicated implementations:
7//!
8//! 1. **In-Memory Loop** (`in_memory_loop.rs`) - imperative loop for testing/prototyping
9//! 2. **Task Worker** (`worker/unified_worker.rs`) - event-sourced via task queue
10//!
11//! ## Problems This Solves
12//!
13//! ### 1. Duplicated Turn Logic
14//!
15//! Both implementations had nearly identical logic for the turn loop:
16//! ```text
17//! Input → Reason → (has_tool_calls?) → Act → Reason → ... → Complete
18//! ```
19//!
20//! This duplication meant changes to turn logic had to be made in two places,
21//! risking divergence and bugs.
22//!
23//! ### 2. Inconsistent Error Handling
24//!
25//! The in-memory loop had a subtle bug where it didn't check `reason_result.success`
26//! before continuing to Act:
27//!
28//! ```ignore
29//! // In-memory (buggy):
30//! if !reason_result.has_tool_calls || reason_result.tool_calls.is_empty() {
31//! break; // Only checks has_tool_calls, ignores success field!
32//! }
33//!
34//! // Durable (correct):
35//! if reason_result.has_tool_calls && reason_result.success {
36//! // Schedule act...
37//! }
38//! ```
39//!
40//! By unifying into a state machine, we ensure consistent error handling everywhere.
41//!
42//! ### 3. Fragile Turn ID Management
43//!
44//! Turn IDs (`TurnId`) provide correlation for all events within a turn.
45//! Previously:
46//!
47//! - In-memory: Created once, passed through in-memory references (simple but not durable)
48//! - Durable: Serialized to JSON, extracted from task output, passed to next task (fragile)
49//!
50//! The state machine provides a single source of truth for `TurnId` lifecycle:
51//! - Created once when the turn starts
52//! - Carried in `TurnContext` throughout execution
53//! - Never re-created, preventing correlation breakage
54//!
55//! ### 4. Iteration Tracking
56//!
57//! Max iterations limit prevents infinite tool loops. Previously tracked differently:
58//! - In-memory: Local loop counter
59//! - Durable: Would need separate tracking per workflow
60//!
61//! The state machine tracks iterations uniformly.
62//!
63//! ## Architecture
64//!
65//! ```text
66//! ┌─────────────────────────────────────────────────────────────────┐
67//! │ TurnStateMachine │
68//! │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────────┐ │
69//! │ │ Input │───▶│ Reason │───▶│ Act │───▶│ Reason │ │
70//! │ └─────────┘ └────┬────┘ └─────────┘ └──────┬───────┘ │
71//! │ │ │ │
72//! │ ▼ │ │
73//! │ ┌───────────────┐ │ │
74//! │ │ Complete │◀──────────────────────┘ │
75//! │ └───────────────┘ │
76//! └─────────────────────────────────────────────────────────────────┘
77//! │
78//! ▼
79//! TurnOutcome (Success/Failed/MaxIterations/Sealed)
80//! ```
81//!
82//! ## Usage
83//!
84//! Both in-memory and durable implementations use the same state machine:
85//!
86//! ```ignore
87//! let mut sm = TurnStateMachine::new(context, max_iterations);
88//!
89//! loop {
90//! match sm.next_action() {
91//! TurnAction::ExecuteInput => {
92//! let result = input_atom.execute(...).await?;
93//! sm.on_input_completed()?;
94//! }
95//! TurnAction::ExecuteReason => {
96//! let result = reason_atom.execute(...).await?;
97//! sm.on_reason_completed(text, result.has_tool_calls, count, result.success, result.error, has_pending)?;
98//! }
99//! TurnAction::ExecuteAct { tool_calls } => {
100//! act_atom.execute(...).await?;
101//! sm.on_act_completed()?;
102//! }
103//! TurnAction::Complete(outcome) => {
104//! return outcome;
105//! }
106//! }
107//! }
108//! ```
109
110use crate::typed_id::{AgentId, MessageId, SessionId, TurnId};
111use serde::{Deserialize, Serialize};
112
113/// Context for a turn, created once and carried throughout execution.
114///
115/// This struct is the single source of truth for turn-scoped identifiers.
116/// It is created when the turn begins and passed to all atoms.
117#[derive(Debug, Clone)]
118pub struct TurnContext {
119 /// Session this turn belongs to
120 pub session_id: SessionId,
121
122 /// Unique identifier for this turn.
123 ///
124 /// Created once at turn start, never changes. All events emitted during
125 /// this turn use this ID for correlation.
126 pub turn_id: TurnId,
127
128 /// Message that initiated this turn (the user's input message)
129 pub input_message_id: MessageId,
130
131 /// Agent executing this turn
132 pub agent_id: AgentId,
133
134 /// Organization ID (for multi-tenancy)
135 pub org_id: i64,
136}
137
138impl TurnContext {
139 /// Create a new turn context with a fresh turn ID.
140 pub fn new(
141 session_id: SessionId,
142 input_message_id: MessageId,
143 agent_id: AgentId,
144 org_id: i64,
145 ) -> Self {
146 Self {
147 session_id,
148 turn_id: TurnId::new(),
149 input_message_id,
150 agent_id,
151 org_id,
152 }
153 }
154
155 /// Create a turn context with an existing turn ID.
156 ///
157 /// Use this when resuming a turn (e.g., in durable execution).
158 pub fn with_turn_id(
159 session_id: SessionId,
160 turn_id: TurnId,
161 input_message_id: MessageId,
162 agent_id: AgentId,
163 org_id: i64,
164 ) -> Self {
165 Self {
166 session_id,
167 turn_id,
168 input_message_id,
169 agent_id,
170 org_id,
171 }
172 }
173}
174
175/// Current phase of turn execution.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum TurnPhase {
178 /// Initial state, waiting to process input
179 PendingInput,
180 /// Input processed, waiting to reason
181 PendingReason,
182 /// Reason completed with tool calls, waiting to act
183 PendingAct,
184 /// Turn has completed
185 Completed,
186}
187
188/// Action to take next in the turn.
189#[derive(Debug, Clone)]
190pub enum TurnAction {
191 /// Execute the input atom (record user message)
192 ExecuteInput,
193
194 /// Execute the reason atom (LLM call)
195 ExecuteReason,
196
197 /// Execute the act atom (tool execution)
198 ExecuteAct,
199
200 /// Turn is complete
201 Complete(TurnOutcome),
202}
203
204/// Why a turn was deliberately sealed (stopped to prevent waste).
205///
206/// `Sealed` is distinct from a successful `Completed` and from an error
207/// `Failed`: it means the engine chose to stop a turn that would otherwise
208/// keep burning resources without producing useful work. The reason is carried
209/// through to the `turn.sealed` event and influences session status.
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum SealReason {
213 /// A durable turn crashed and was reclaimed repeatedly without making any
214 /// forward progress (its progress token never advanced). Sealing prevents a
215 /// crash-loop from re-running reason/act and burning tokens until it
216 /// incidentally hits max-iterations. See EVE-534.
217 NoProgress,
218
219 /// The work budget was exhausted (`HardLimitStopRule` balance <= 0). The
220 /// turn is stopped deliberately rather than left reclaimable. See
221 /// `specs/budgeting.md`.
222 Budget,
223}
224
225impl SealReason {
226 /// Stable wire string for events and the `turn.sealed` payload.
227 pub fn as_str(&self) -> &'static str {
228 match self {
229 SealReason::NoProgress => "no_progress",
230 SealReason::Budget => "budget",
231 }
232 }
233
234 /// Parse a wire string back into a `SealReason`, defaulting to `NoProgress`
235 /// for unknown values so older persisted reasons stay forward-compatible.
236 pub fn from_str_lossy(s: &str) -> Self {
237 match s {
238 "budget" => SealReason::Budget,
239 _ => SealReason::NoProgress,
240 }
241 }
242}
243
244impl std::fmt::Display for SealReason {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.write_str(self.as_str())
247 }
248}
249
250/// A per-turn, monotonically advancing marker of forward progress.
251///
252/// # Why this exists (EVE-534)
253///
254/// A durable turn that crashes and gets reclaimed repeatedly can loop forever
255/// (re-running reason/act, burning tokens/billing) until it incidentally hits
256/// max-iterations. To defend against this poison-turn case we need a notion of
257/// *progress* that is:
258///
259/// - **Derived from durably-recorded facts** so it is stable under replay — the
260/// highest `durable_workflow_events.sequence_num` for the turn's workflow, or
261/// equivalently the `(iteration, atoms_completed, settled_tool_calls)` tuple.
262/// - **Impossible to game by a non-advancing retry** — re-running the same atom
263/// that crashes before recording any event leaves the token unchanged.
264///
265/// The token is a single `u64` so the no-progress guard can compare cheaply and
266/// persist it on the task across recovery attempts. A strictly larger value
267/// means the turn advanced; an equal (or smaller) value means it did not.
268#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
269pub struct ProgressToken(pub u64);
270
271impl ProgressToken {
272 /// The token before any durable fact has been recorded.
273 pub const ZERO: ProgressToken = ProgressToken(0);
274
275 /// Build a token from the highest durable event sequence observed for the
276 /// turn. Sequence numbers are monotonic per workflow, so this is monotonic
277 /// per turn. The `+1` keeps `ZERO` reserved for "no events yet" while a
278 /// missing/`-1` sequence maps to `ZERO`.
279 pub fn from_event_sequence(highest_sequence: i64) -> Self {
280 ProgressToken((highest_sequence.max(-1) + 1) as u64)
281 }
282
283 /// Returns true if `self` represents strictly more progress than `prev`.
284 pub fn advanced_from(&self, prev: ProgressToken) -> bool {
285 self.0 > prev.0
286 }
287}
288
289/// Default number of consecutive no-progress recoveries before a turn is sealed.
290///
291/// Configurable via `DURABLE_NO_PROGRESS_SEAL_THRESHOLD`. See EVE-534.
292pub const DEFAULT_NO_PROGRESS_SEAL_THRESHOLD: u32 = 3;
293
294/// Read the no-progress seal threshold from the environment, falling back to
295/// [`DEFAULT_NO_PROGRESS_SEAL_THRESHOLD`]. A value of 0 is coerced to 1 so the
296/// guard can never be disabled into an infinite crash-loop.
297pub fn no_progress_seal_threshold_from_env() -> u32 {
298 std::env::var("DURABLE_NO_PROGRESS_SEAL_THRESHOLD")
299 .ok()
300 .and_then(|v| v.parse::<u32>().ok())
301 .unwrap_or(DEFAULT_NO_PROGRESS_SEAL_THRESHOLD)
302 .max(1)
303}
304
305/// Final outcome of a turn.
306#[derive(Debug, Clone)]
307pub enum TurnOutcome {
308 /// Turn completed successfully
309 Success {
310 /// Final text response from the agent
311 response: String,
312 /// Number of reasoning iterations
313 iterations: usize,
314 /// Total tool calls made
315 tool_calls_count: usize,
316 },
317
318 /// Turn failed due to an error
319 Failed {
320 /// Error message
321 error: String,
322 /// Iterations completed before failure
323 iterations: usize,
324 },
325
326 /// Turn stopped due to max iterations limit
327 MaxIterationsReached {
328 /// Final response at time of limit
329 response: String,
330 /// Number of iterations (equals max_iterations)
331 iterations: usize,
332 /// Total tool calls made
333 tool_calls_count: usize,
334 },
335
336 /// Turn was deliberately sealed to prevent further waste (EVE-534).
337 ///
338 /// Distinct from `Success` (work finished) and `Failed` (an error ended the
339 /// turn): `Sealed` means the engine chose to stop a turn that would
340 /// otherwise keep consuming resources without progressing. Sealed turns are
341 /// terminal and **non-retryable** — the durable task is routed to the DLQ
342 /// rather than requeued, and a `turn.sealed` event is emitted.
343 Sealed {
344 /// Why the turn was sealed.
345 reason: SealReason,
346 /// Final response at time of sealing (may be empty).
347 response: String,
348 /// Iterations completed before sealing.
349 iterations: usize,
350 /// Total tool calls made before sealing.
351 tool_calls_count: usize,
352 },
353}
354
355impl TurnOutcome {
356 /// Check if the turn completed successfully
357 pub fn is_success(&self) -> bool {
358 matches!(self, TurnOutcome::Success { .. })
359 }
360
361 /// Check if the turn was deliberately sealed (EVE-534).
362 pub fn is_sealed(&self) -> bool {
363 matches!(self, TurnOutcome::Sealed { .. })
364 }
365
366 /// Get the seal reason, if the turn was sealed.
367 pub fn seal_reason(&self) -> Option<SealReason> {
368 match self {
369 TurnOutcome::Sealed { reason, .. } => Some(*reason),
370 _ => None,
371 }
372 }
373
374 /// Get the final response, if any
375 pub fn response(&self) -> Option<&str> {
376 match self {
377 TurnOutcome::Success { response, .. } => Some(response),
378 TurnOutcome::MaxIterationsReached { response, .. } => Some(response),
379 TurnOutcome::Sealed { response, .. } => Some(response),
380 TurnOutcome::Failed { .. } => None,
381 }
382 }
383
384 /// Get the error message, if any
385 pub fn error(&self) -> Option<&str> {
386 match self {
387 TurnOutcome::Failed { error, .. } => Some(error),
388 _ => None,
389 }
390 }
391
392 /// Get the number of iterations
393 pub fn iterations(&self) -> usize {
394 match self {
395 TurnOutcome::Success { iterations, .. } => *iterations,
396 TurnOutcome::Failed { iterations, .. } => *iterations,
397 TurnOutcome::MaxIterationsReached { iterations, .. } => *iterations,
398 TurnOutcome::Sealed { iterations, .. } => *iterations,
399 }
400 }
401}
402
403/// State machine for turn orchestration.
404///
405/// This is the core abstraction that unifies turn logic between in-memory
406/// and durable execution. It tracks the current phase, determines the next
407/// action, and handles state transitions.
408///
409/// # Thread Safety
410///
411/// The state machine is not thread-safe. For durable execution, each task
412/// execution creates its own state machine from serialized state.
413#[derive(Debug)]
414pub struct TurnStateMachine {
415 /// Turn context with IDs
416 context: TurnContext,
417
418 /// Current phase
419 phase: TurnPhase,
420
421 /// Maximum allowed iterations (Reason → Act cycles)
422 max_iterations: usize,
423
424 /// Current iteration count
425 current_iteration: usize,
426
427 /// Total tool calls made across all iterations
428 total_tool_calls: usize,
429
430 /// Last text response from Reason
431 last_response: String,
432
433 /// Pending error from Reason (set when success=false)
434 pending_error: Option<String>,
435
436 /// Whether the last Reason had tool calls
437 has_pending_tool_calls: bool,
438
439 /// Pending seal reason (set by `seal()` when the engine decides to stop the
440 /// turn deliberately, e.g. work-budget exhausted). Takes precedence over a
441 /// normal completion so the turn resolves to `TurnOutcome::Sealed`.
442 pending_seal: Option<SealReason>,
443}
444
445impl TurnStateMachine {
446 /// Create a new state machine for a turn.
447 ///
448 /// # Arguments
449 ///
450 /// * `context` - Turn context with session, turn, and agent IDs
451 /// * `max_iterations` - Maximum Reason → Act cycles before stopping
452 pub fn new(context: TurnContext, max_iterations: usize) -> Self {
453 Self {
454 context,
455 phase: TurnPhase::PendingInput,
456 max_iterations,
457 current_iteration: 0,
458 total_tool_calls: 0,
459 last_response: String::new(),
460 pending_error: None,
461 has_pending_tool_calls: false,
462 pending_seal: None,
463 }
464 }
465
466 /// Get the turn context.
467 pub fn context(&self) -> &TurnContext {
468 &self.context
469 }
470
471 /// Get the current phase.
472 pub fn phase(&self) -> TurnPhase {
473 self.phase
474 }
475
476 /// Get the current iteration count.
477 pub fn current_iteration(&self) -> usize {
478 self.current_iteration
479 }
480
481 /// Get the total tool calls made so far.
482 pub fn total_tool_calls(&self) -> usize {
483 self.total_tool_calls
484 }
485
486 /// Determine the next action to take.
487 ///
488 /// This is the core dispatch method. Call this in a loop and execute
489 /// the returned action until `TurnAction::Complete` is returned.
490 pub fn next_action(&self) -> TurnAction {
491 match self.phase {
492 TurnPhase::PendingInput => TurnAction::ExecuteInput,
493 TurnPhase::PendingReason => TurnAction::ExecuteReason,
494 TurnPhase::PendingAct => TurnAction::ExecuteAct,
495 TurnPhase::Completed => {
496 // A deliberate seal takes precedence over any other terminal:
497 // budget exhaustion must resolve to `Sealed { budget }` rather
498 // than leaving the turn reclaimable or surfacing as a failure.
499 if let Some(reason) = self.pending_seal {
500 return TurnAction::Complete(TurnOutcome::Sealed {
501 reason,
502 response: self.last_response.clone(),
503 iterations: self.current_iteration,
504 tool_calls_count: self.total_tool_calls,
505 });
506 }
507 // Build outcome based on state
508 if let Some(error) = &self.pending_error {
509 TurnAction::Complete(TurnOutcome::Failed {
510 error: error.clone(),
511 iterations: self.current_iteration,
512 })
513 } else if self.current_iteration >= self.max_iterations {
514 TurnAction::Complete(TurnOutcome::MaxIterationsReached {
515 response: self.last_response.clone(),
516 iterations: self.current_iteration,
517 tool_calls_count: self.total_tool_calls,
518 })
519 } else {
520 TurnAction::Complete(TurnOutcome::Success {
521 response: self.last_response.clone(),
522 iterations: self.current_iteration,
523 tool_calls_count: self.total_tool_calls,
524 })
525 }
526 }
527 }
528 }
529
530 /// Record that input processing completed.
531 ///
532 /// Call this after successfully executing the input atom.
533 pub fn on_input_completed(&mut self) {
534 debug_assert_eq!(self.phase, TurnPhase::PendingInput);
535 self.phase = TurnPhase::PendingReason;
536 }
537
538 /// Record that reasoning completed.
539 ///
540 /// # Arguments
541 ///
542 /// * `response` - The text response from the LLM (may be empty)
543 /// * `has_tool_calls` - Whether the LLM requested tool calls
544 /// * `tool_call_count` - Number of tool calls (0 if none)
545 /// * `success` - Whether the LLM call succeeded
546 /// * `error` - Error message if success is false
547 /// * `has_pending_user_messages` - Whether new user messages arrived during
548 /// this turn (steering signals). When true and reason would otherwise
549 /// complete (no tool calls, success), the turn stays in PendingReason so
550 /// the next iteration picks up the new messages from the conversation
551 /// history. This is "in-turn steering" — matching Claude Code behavior.
552 pub fn on_reason_completed(
553 &mut self,
554 response: String,
555 has_tool_calls: bool,
556 tool_call_count: usize,
557 success: bool,
558 error: Option<String>,
559 has_pending_user_messages: bool,
560 ) {
561 debug_assert_eq!(self.phase, TurnPhase::PendingReason);
562
563 self.current_iteration += 1;
564
565 // Store response
566 if !response.is_empty() {
567 self.last_response = response;
568 }
569
570 // Handle failure
571 if !success {
572 self.pending_error = error;
573 self.phase = TurnPhase::Completed;
574 return;
575 }
576
577 // Handle tool calls
578 if has_tool_calls && tool_call_count > 0 {
579 // Check max iterations before proceeding to Act
580 if self.current_iteration >= self.max_iterations {
581 self.phase = TurnPhase::Completed;
582 return;
583 }
584
585 self.has_pending_tool_calls = true;
586 self.total_tool_calls += tool_call_count;
587 self.phase = TurnPhase::PendingAct;
588 } else if has_pending_user_messages {
589 // No tool calls but user sent messages during this turn.
590 // Enforce max_iterations before continuing — prevents unbounded
591 // reason loops from a steady stream of user messages.
592 if self.current_iteration >= self.max_iterations {
593 self.phase = TurnPhase::Completed;
594 } else {
595 self.phase = TurnPhase::PendingReason;
596 }
597 } else {
598 // No tool calls, no pending messages — turn is complete
599 self.phase = TurnPhase::Completed;
600 }
601 }
602
603 /// Record that action (tool execution) completed.
604 ///
605 /// Call this after successfully executing the act atom.
606 /// The turn then loops back to Reason for another iteration.
607 pub fn on_act_completed(&mut self) {
608 debug_assert_eq!(self.phase, TurnPhase::PendingAct);
609 self.has_pending_tool_calls = false;
610 // Loop back to reason for next iteration
611 self.phase = TurnPhase::PendingReason;
612 }
613
614 /// Deliberately seal the turn, stopping further scheduling (EVE-534).
615 ///
616 /// Call this between atoms when the engine decides to stop a turn to prevent
617 /// waste — e.g. the work budget is exhausted (`SealReason::Budget`). The
618 /// turn transitions to `Completed` and `next_action` resolves to
619 /// `TurnOutcome::Sealed`. Sealing is idempotent and the first reason wins.
620 pub fn seal(&mut self, reason: SealReason) {
621 if self.pending_seal.is_none() {
622 self.pending_seal = Some(reason);
623 }
624 self.phase = TurnPhase::Completed;
625 }
626
627 /// Check if the turn has completed.
628 pub fn is_completed(&self) -> bool {
629 self.phase == TurnPhase::Completed
630 }
631}
632
633// ============================================================================
634// Tests
635// ============================================================================
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 fn test_context() -> TurnContext {
642 TurnContext::new(SessionId::new(), MessageId::new(), AgentId::new(), 0)
643 }
644
645 #[test]
646 fn test_simple_turn_no_tools() {
647 let mut sm = TurnStateMachine::new(test_context(), 10);
648
649 // Start with input
650 assert!(matches!(sm.next_action(), TurnAction::ExecuteInput));
651 sm.on_input_completed();
652
653 // Then reason
654 assert!(matches!(sm.next_action(), TurnAction::ExecuteReason));
655 sm.on_reason_completed("Hello!".to_string(), false, 0, true, None, false);
656
657 // Complete
658 match sm.next_action() {
659 TurnAction::Complete(TurnOutcome::Success {
660 response,
661 iterations,
662 tool_calls_count,
663 }) => {
664 assert_eq!(response, "Hello!");
665 assert_eq!(iterations, 1);
666 assert_eq!(tool_calls_count, 0);
667 }
668 other => panic!("Expected Success, got {:?}", other),
669 }
670 }
671
672 #[test]
673 fn test_turn_with_one_tool_call() {
674 let mut sm = TurnStateMachine::new(test_context(), 10);
675
676 // Input
677 assert!(matches!(sm.next_action(), TurnAction::ExecuteInput));
678 sm.on_input_completed();
679
680 // First reason - requests tool call
681 assert!(matches!(sm.next_action(), TurnAction::ExecuteReason));
682 sm.on_reason_completed("Let me check...".to_string(), true, 1, true, None, false);
683
684 // Act
685 assert!(matches!(sm.next_action(), TurnAction::ExecuteAct));
686 sm.on_act_completed();
687
688 // Second reason - no more tool calls
689 assert!(matches!(sm.next_action(), TurnAction::ExecuteReason));
690 sm.on_reason_completed(
691 "Here's the result.".to_string(),
692 false,
693 0,
694 true,
695 None,
696 false,
697 );
698
699 // Complete
700 match sm.next_action() {
701 TurnAction::Complete(TurnOutcome::Success {
702 response,
703 iterations,
704 tool_calls_count,
705 }) => {
706 assert_eq!(response, "Here's the result.");
707 assert_eq!(iterations, 2);
708 assert_eq!(tool_calls_count, 1);
709 }
710 other => panic!("Expected Success, got {:?}", other),
711 }
712 }
713
714 #[test]
715 fn test_max_iterations() {
716 let mut sm = TurnStateMachine::new(test_context(), 2);
717
718 // Input
719 sm.on_input_completed();
720
721 // First reason - requests tool
722 sm.on_reason_completed("Trying...".to_string(), true, 1, true, None, false);
723 sm.on_act_completed();
724
725 // Second reason - requests another tool (hits max)
726 sm.on_reason_completed("Still trying...".to_string(), true, 1, true, None, false);
727
728 // Should complete with max iterations
729 match sm.next_action() {
730 TurnAction::Complete(TurnOutcome::MaxIterationsReached { iterations, .. }) => {
731 assert_eq!(iterations, 2);
732 }
733 other => panic!("Expected MaxIterationsReached, got {:?}", other),
734 }
735 }
736
737 #[test]
738 fn test_reason_failure() {
739 let mut sm = TurnStateMachine::new(test_context(), 10);
740
741 // Input
742 sm.on_input_completed();
743
744 // Reason fails
745 sm.on_reason_completed(
746 String::new(),
747 false,
748 0,
749 false,
750 Some("LLM error".to_string()),
751 false,
752 );
753
754 // Should complete with failure
755 match sm.next_action() {
756 TurnAction::Complete(TurnOutcome::Failed { error, .. }) => {
757 assert_eq!(error, "LLM error");
758 }
759 other => panic!("Expected Failed, got {:?}", other),
760 }
761 }
762
763 #[test]
764 fn test_context_preserved() {
765 let context = TurnContext::new(SessionId::new(), MessageId::new(), AgentId::new(), 42);
766 let turn_id = context.turn_id;
767
768 let sm = TurnStateMachine::new(context, 10);
769
770 // Context should be accessible and unchanged
771 assert_eq!(sm.context().turn_id, turn_id);
772 assert_eq!(sm.context().org_id, 42);
773 }
774
775 #[test]
776 fn test_outcome_helpers() {
777 let success = TurnOutcome::Success {
778 response: "test".to_string(),
779 iterations: 1,
780 tool_calls_count: 0,
781 };
782 assert!(success.is_success());
783 assert_eq!(success.response(), Some("test"));
784 assert!(success.error().is_none());
785
786 let failed = TurnOutcome::Failed {
787 error: "oops".to_string(),
788 iterations: 0,
789 };
790 assert!(!failed.is_success());
791 assert!(failed.response().is_none());
792 assert_eq!(failed.error(), Some("oops"));
793 }
794
795 #[test]
796 fn test_pending_user_message_continues_turn() {
797 let mut sm = TurnStateMachine::new(test_context(), 10);
798 sm.on_input_completed();
799
800 // Reason completes with no tools, BUT there are pending user messages
801 sm.on_reason_completed("Hello!".to_string(), false, 0, true, None, true);
802
803 // Should NOT be completed — stays in PendingReason
804 assert!(!sm.is_completed());
805 assert_eq!(sm.phase(), TurnPhase::PendingReason);
806 assert!(matches!(sm.next_action(), TurnAction::ExecuteReason));
807
808 // Second reason picks up the new message and completes normally
809 sm.on_reason_completed("Got your message!".to_string(), false, 0, true, None, false);
810 match sm.next_action() {
811 TurnAction::Complete(TurnOutcome::Success {
812 response,
813 iterations,
814 ..
815 }) => {
816 assert_eq!(response, "Got your message!");
817 assert_eq!(iterations, 2);
818 }
819 other => panic!("Expected Success, got {:?}", other),
820 }
821 }
822
823 #[test]
824 fn test_pending_messages_ignored_on_failure() {
825 let mut sm = TurnStateMachine::new(test_context(), 10);
826 sm.on_input_completed();
827
828 // Failure + pending messages → still fails
829 sm.on_reason_completed(
830 String::new(),
831 false,
832 0,
833 false,
834 Some("LLM error".to_string()),
835 true,
836 );
837 assert!(sm.is_completed());
838 assert!(matches!(
839 sm.next_action(),
840 TurnAction::Complete(TurnOutcome::Failed { .. })
841 ));
842 }
843
844 #[test]
845 fn test_progress_token_monotonicity() {
846 // Higher event sequence => strictly higher token.
847 let t0 = ProgressToken::from_event_sequence(-1); // no events yet
848 let t1 = ProgressToken::from_event_sequence(0);
849 let t2 = ProgressToken::from_event_sequence(5);
850 assert_eq!(t0, ProgressToken::ZERO);
851 assert!(t1.advanced_from(t0));
852 assert!(t2.advanced_from(t1));
853 // Same sequence => no advance (a non-advancing retry can't game it).
854 let t2_again = ProgressToken::from_event_sequence(5);
855 assert!(!t2_again.advanced_from(t2));
856 assert!(!t2.advanced_from(t2_again));
857 // Ordering matches numeric ordering.
858 assert!(t2 > t1 && t1 > t0);
859 }
860
861 #[test]
862 fn test_no_progress_counter_logic() {
863 // Mirrors the guard the store applies on each reclaim: the counter only
864 // increments when the token is unchanged across attempts, and resets to
865 // zero on any advance. Sealing fires when the counter reaches N.
866 let threshold = 3u32;
867 let mut recorded = ProgressToken::ZERO;
868 let mut no_progress = 0u32;
869
870 let step =
871 |recorded: &mut ProgressToken, no_progress: &mut u32, observed: ProgressToken| {
872 if observed.advanced_from(*recorded) {
873 *recorded = observed;
874 *no_progress = 0;
875 } else {
876 *no_progress += 1;
877 }
878 *no_progress
879 };
880
881 // Crash without recording any event => token unchanged => increments.
882 assert_eq!(
883 step(&mut recorded, &mut no_progress, ProgressToken::ZERO),
884 1
885 );
886 assert_eq!(
887 step(&mut recorded, &mut no_progress, ProgressToken::ZERO),
888 2
889 );
890 // An advance resets the counter.
891 assert_eq!(
892 step(
893 &mut recorded,
894 &mut no_progress,
895 ProgressToken::from_event_sequence(2)
896 ),
897 0
898 );
899 // Then stall again until we hit the seal threshold.
900 let stuck = ProgressToken::from_event_sequence(2);
901 assert_eq!(step(&mut recorded, &mut no_progress, stuck), 1);
902 assert_eq!(step(&mut recorded, &mut no_progress, stuck), 2);
903 let count = step(&mut recorded, &mut no_progress, stuck);
904 assert_eq!(count, 3);
905 assert!(count >= threshold, "should seal once threshold reached");
906 }
907
908 #[test]
909 fn test_seal_threshold_env_never_zero() {
910 // Default applies when unset; a 0 must coerce to at least 1.
911 // (We only assert the floor invariant without touching process env.)
912 assert_eq!(DEFAULT_NO_PROGRESS_SEAL_THRESHOLD, 3);
913 }
914
915 #[test]
916 fn test_budget_seal_outcome() {
917 let mut sm = TurnStateMachine::new(test_context(), 10);
918 sm.on_input_completed();
919 // A reason completes with tool calls (turn would normally continue)...
920 sm.on_reason_completed("Working...".to_string(), true, 1, true, None, false);
921 sm.on_act_completed();
922 // ...but the engine seals it because the work budget is exhausted.
923 sm.seal(SealReason::Budget);
924 assert!(sm.is_completed());
925 match sm.next_action() {
926 TurnAction::Complete(TurnOutcome::Sealed {
927 reason, iterations, ..
928 }) => {
929 assert_eq!(reason, SealReason::Budget);
930 assert_eq!(iterations, 1);
931 }
932 other => panic!("Expected Sealed, got {:?}", other),
933 }
934 }
935
936 #[test]
937 fn test_seal_takes_precedence_over_error() {
938 // If a turn both errored and was sealed, the deliberate seal wins so the
939 // turn is non-retryable rather than surfacing as a transient failure.
940 let mut sm = TurnStateMachine::new(test_context(), 10);
941 sm.on_input_completed();
942 sm.on_reason_completed(
943 String::new(),
944 false,
945 0,
946 false,
947 Some("LLM error".to_string()),
948 false,
949 );
950 sm.seal(SealReason::NoProgress);
951 match sm.next_action() {
952 TurnAction::Complete(TurnOutcome::Sealed { reason, .. }) => {
953 assert_eq!(reason, SealReason::NoProgress);
954 }
955 other => panic!("Expected Sealed, got {:?}", other),
956 }
957 }
958
959 #[test]
960 fn test_seal_reason_wire_roundtrip() {
961 assert_eq!(SealReason::NoProgress.as_str(), "no_progress");
962 assert_eq!(SealReason::Budget.as_str(), "budget");
963 assert_eq!(SealReason::from_str_lossy("budget"), SealReason::Budget);
964 assert_eq!(
965 SealReason::from_str_lossy("no_progress"),
966 SealReason::NoProgress
967 );
968 // Unknown reasons stay forward-compatible.
969 assert_eq!(
970 SealReason::from_str_lossy("future_reason"),
971 SealReason::NoProgress
972 );
973 }
974
975 #[test]
976 fn test_outcome_sealed_helpers() {
977 let sealed = TurnOutcome::Sealed {
978 reason: SealReason::Budget,
979 response: "partial".to_string(),
980 iterations: 2,
981 tool_calls_count: 1,
982 };
983 assert!(sealed.is_sealed());
984 assert!(!sealed.is_success());
985 assert_eq!(sealed.seal_reason(), Some(SealReason::Budget));
986 assert_eq!(sealed.response(), Some("partial"));
987 assert!(sealed.error().is_none());
988 assert_eq!(sealed.iterations(), 2);
989 }
990
991 #[test]
992 fn test_pending_messages_ignored_when_tool_calls() {
993 let mut sm = TurnStateMachine::new(test_context(), 10);
994 sm.on_input_completed();
995
996 // Tool calls + pending messages → tool calls take priority
997 sm.on_reason_completed("Working...".to_string(), true, 2, true, None, true);
998 assert_eq!(sm.phase(), TurnPhase::PendingAct);
999 }
1000}