Skip to main content

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}