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, count, result.success, result.error, result.finish_reason, 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        /// Structured reason the final generation stopped.
317        stop_reason: TurnStopReason,
318    },
319
320    /// Turn failed due to an error
321    Failed {
322        /// Error message
323        error: String,
324        /// Iterations completed before failure
325        iterations: usize,
326        /// Structured reason the turn failed.
327        stop_reason: TurnStopReason,
328    },
329
330    /// Turn stopped due to max iterations limit
331    MaxIterationsReached {
332        /// Final response at time of limit
333        response: String,
334        /// Number of iterations (equals max_iterations)
335        iterations: usize,
336        /// Total tool calls made
337        tool_calls_count: usize,
338    },
339
340    /// Turn was deliberately sealed to prevent further waste (EVE-534).
341    ///
342    /// Distinct from `Success` (work finished) and `Failed` (an error ended the
343    /// turn): `Sealed` means the engine chose to stop a turn that would
344    /// otherwise keep consuming resources without progressing. Sealed turns are
345    /// terminal and **non-retryable** — the durable task is routed to the DLQ
346    /// rather than requeued, and a `turn.sealed` event is emitted.
347    Sealed {
348        /// Why the turn was sealed.
349        reason: SealReason,
350        /// Final response at time of sealing (may be empty).
351        response: String,
352        /// Iterations completed before sealing.
353        iterations: usize,
354        /// Total tool calls made before sealing.
355        tool_calls_count: usize,
356    },
357}
358
359/// Stable reason a turn stopped, independent of provider-specific strings.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361#[serde(rename_all = "snake_case")]
362pub enum TurnStopReason {
363    EndTurn,
364    MaxTokens,
365    MaxTurnRequests,
366    Refusal,
367    Error,
368    Cancelled,
369}
370
371impl TurnStopReason {
372    /// Normalize a provider finish reason at the runtime boundary.
373    pub fn from_provider_finish_reason(reason: Option<&str>) -> Self {
374        match reason.map(str::to_ascii_lowercase).as_deref() {
375            Some("length" | "max_tokens" | "max_output_tokens") => Self::MaxTokens,
376            Some("refusal" | "content_filter" | "safety") => Self::Refusal,
377            Some("error") => Self::Error,
378            Some("cancelled" | "canceled") => Self::Cancelled,
379            _ => Self::EndTurn,
380        }
381    }
382}
383
384impl TurnOutcome {
385    /// Check if the turn completed successfully
386    pub fn is_success(&self) -> bool {
387        matches!(self, TurnOutcome::Success { .. })
388    }
389
390    /// Check if the turn was deliberately sealed (EVE-534).
391    pub fn is_sealed(&self) -> bool {
392        matches!(self, TurnOutcome::Sealed { .. })
393    }
394
395    /// Get the seal reason, if the turn was sealed.
396    pub fn seal_reason(&self) -> Option<SealReason> {
397        match self {
398            TurnOutcome::Sealed { reason, .. } => Some(*reason),
399            _ => None,
400        }
401    }
402
403    /// Return the stable reason this outcome stopped.
404    pub fn stop_reason(&self) -> TurnStopReason {
405        match self {
406            TurnOutcome::Success { stop_reason, .. } | TurnOutcome::Failed { stop_reason, .. } => {
407                *stop_reason
408            }
409            TurnOutcome::MaxIterationsReached { .. } => TurnStopReason::MaxTurnRequests,
410            TurnOutcome::Sealed { .. } => TurnStopReason::Error,
411        }
412    }
413
414    /// Get the final response, if any
415    pub fn response(&self) -> Option<&str> {
416        match self {
417            TurnOutcome::Success { response, .. } => Some(response),
418            TurnOutcome::MaxIterationsReached { response, .. } => Some(response),
419            TurnOutcome::Sealed { response, .. } => Some(response),
420            TurnOutcome::Failed { .. } => None,
421        }
422    }
423
424    /// Get the error message, if any
425    pub fn error(&self) -> Option<&str> {
426        match self {
427            TurnOutcome::Failed { error, .. } => Some(error),
428            _ => None,
429        }
430    }
431
432    /// Get the number of iterations
433    pub fn iterations(&self) -> usize {
434        match self {
435            TurnOutcome::Success { iterations, .. } => *iterations,
436            TurnOutcome::Failed { iterations, .. } => *iterations,
437            TurnOutcome::MaxIterationsReached { iterations, .. } => *iterations,
438            TurnOutcome::Sealed { iterations, .. } => *iterations,
439        }
440    }
441}
442
443/// State machine for turn orchestration.
444///
445/// This is the core abstraction that unifies turn logic between in-memory
446/// and durable execution. It tracks the current phase, determines the next
447/// action, and handles state transitions.
448///
449/// # Thread Safety
450///
451/// The state machine is not thread-safe. For durable execution, each task
452/// execution creates its own state machine from serialized state.
453#[derive(Debug)]
454pub struct TurnStateMachine {
455    /// Turn context with IDs
456    context: TurnContext,
457
458    /// Current phase
459    phase: TurnPhase,
460
461    /// Maximum allowed iterations (Reason → Act cycles)
462    max_iterations: usize,
463
464    /// Current iteration count
465    current_iteration: usize,
466
467    /// Total tool calls made across all iterations
468    total_tool_calls: usize,
469
470    /// Last text response from Reason
471    last_response: String,
472
473    /// Pending error from Reason (set when success=false)
474    pending_error: Option<String>,
475
476    /// Provider-derived reason for the terminal generation.
477    pending_stop_reason: TurnStopReason,
478
479    /// Whether the last Reason had tool calls
480    has_pending_tool_calls: bool,
481
482    /// Pending seal reason (set by `seal()` when the engine decides to stop the
483    /// turn deliberately, e.g. work-budget exhausted). Takes precedence over a
484    /// normal completion so the turn resolves to `TurnOutcome::Sealed`.
485    pending_seal: Option<SealReason>,
486}
487
488impl TurnStateMachine {
489    /// Create a new state machine for a turn.
490    ///
491    /// # Arguments
492    ///
493    /// * `context` - Turn context with session, turn, and agent IDs
494    /// * `max_iterations` - Maximum Reason → Act cycles before stopping
495    pub fn new(context: TurnContext, max_iterations: usize) -> Self {
496        Self {
497            context,
498            phase: TurnPhase::PendingInput,
499            max_iterations,
500            current_iteration: 0,
501            total_tool_calls: 0,
502            last_response: String::new(),
503            pending_error: None,
504            pending_stop_reason: TurnStopReason::EndTurn,
505            has_pending_tool_calls: false,
506            pending_seal: None,
507        }
508    }
509
510    /// Get the turn context.
511    pub fn context(&self) -> &TurnContext {
512        &self.context
513    }
514
515    /// Get the current phase.
516    pub fn phase(&self) -> TurnPhase {
517        self.phase
518    }
519
520    /// Get the current iteration count.
521    pub fn current_iteration(&self) -> usize {
522        self.current_iteration
523    }
524
525    /// Get the total tool calls made so far.
526    pub fn total_tool_calls(&self) -> usize {
527        self.total_tool_calls
528    }
529
530    /// Determine the next action to take.
531    ///
532    /// This is the core dispatch method. Call this in a loop and execute
533    /// the returned action until `TurnAction::Complete` is returned.
534    pub fn next_action(&self) -> TurnAction {
535        match self.phase {
536            TurnPhase::PendingInput => TurnAction::ExecuteInput,
537            TurnPhase::PendingReason => TurnAction::ExecuteReason,
538            TurnPhase::PendingAct => TurnAction::ExecuteAct,
539            TurnPhase::Completed => {
540                // A deliberate seal takes precedence over any other terminal:
541                // budget exhaustion must resolve to `Sealed { budget }` rather
542                // than leaving the turn reclaimable or surfacing as a failure.
543                if let Some(reason) = self.pending_seal {
544                    return TurnAction::Complete(TurnOutcome::Sealed {
545                        reason,
546                        response: self.last_response.clone(),
547                        iterations: self.current_iteration,
548                        tool_calls_count: self.total_tool_calls,
549                    });
550                }
551                // Build outcome based on state
552                if let Some(error) = &self.pending_error {
553                    TurnAction::Complete(TurnOutcome::Failed {
554                        error: error.clone(),
555                        iterations: self.current_iteration,
556                        stop_reason: self.pending_stop_reason,
557                    })
558                } else if self.current_iteration >= self.max_iterations {
559                    TurnAction::Complete(TurnOutcome::MaxIterationsReached {
560                        response: self.last_response.clone(),
561                        iterations: self.current_iteration,
562                        tool_calls_count: self.total_tool_calls,
563                    })
564                } else {
565                    TurnAction::Complete(TurnOutcome::Success {
566                        response: self.last_response.clone(),
567                        iterations: self.current_iteration,
568                        tool_calls_count: self.total_tool_calls,
569                        stop_reason: self.pending_stop_reason,
570                    })
571                }
572            }
573        }
574    }
575
576    /// Record that input processing completed.
577    ///
578    /// Call this after successfully executing the input atom.
579    pub fn on_input_completed(&mut self) {
580        debug_assert_eq!(self.phase, TurnPhase::PendingInput);
581        self.phase = TurnPhase::PendingReason;
582    }
583
584    /// Record that reasoning completed.
585    ///
586    /// # Arguments
587    ///
588    /// * `response` - The text response from the LLM (may be empty)
589    /// * `tool_call_count` - Number of tool calls (0 if none)
590    /// * `success` - Whether the LLM call succeeded
591    /// * `error` - Error message if success is false
592    /// * `finish_reason` - Raw provider finish reason, when available
593    /// * `has_pending_user_messages` - Whether new user messages arrived during
594    ///   this turn (steering signals). When true and reason would otherwise
595    ///   complete (no tool calls, success), the turn stays in PendingReason so
596    ///   the next iteration picks up the new messages from the conversation
597    ///   history. This is "in-turn steering" — matching Claude Code behavior.
598    pub fn on_reason_completed(
599        &mut self,
600        response: String,
601        tool_call_count: usize,
602        success: bool,
603        error: Option<String>,
604        finish_reason: Option<String>,
605        has_pending_user_messages: bool,
606    ) {
607        debug_assert_eq!(self.phase, TurnPhase::PendingReason);
608
609        self.current_iteration += 1;
610
611        // Store response
612        if !response.is_empty() {
613            self.last_response = response;
614        }
615
616        // Handle failure
617        if !success {
618            self.pending_stop_reason =
619                match TurnStopReason::from_provider_finish_reason(finish_reason.as_deref()) {
620                    TurnStopReason::Refusal => TurnStopReason::Refusal,
621                    _ => TurnStopReason::Error,
622                };
623            self.pending_error = error;
624            self.phase = TurnPhase::Completed;
625            return;
626        }
627
628        self.pending_stop_reason =
629            TurnStopReason::from_provider_finish_reason(finish_reason.as_deref());
630
631        // Handle tool calls
632        if tool_call_count > 0 {
633            // Check max iterations before proceeding to Act
634            if self.current_iteration >= self.max_iterations {
635                self.phase = TurnPhase::Completed;
636                return;
637            }
638
639            self.has_pending_tool_calls = true;
640            self.total_tool_calls += tool_call_count;
641            self.phase = TurnPhase::PendingAct;
642        } else if has_pending_user_messages {
643            // No tool calls but user sent messages during this turn.
644            // Enforce max_iterations before continuing — prevents unbounded
645            // reason loops from a steady stream of user messages.
646            if self.current_iteration >= self.max_iterations {
647                self.phase = TurnPhase::Completed;
648            } else {
649                self.phase = TurnPhase::PendingReason;
650            }
651        } else {
652            // No tool calls, no pending messages — turn is complete
653            self.phase = TurnPhase::Completed;
654        }
655    }
656
657    /// Record that action (tool execution) completed.
658    ///
659    /// Call this after successfully executing the act atom.
660    /// The turn then loops back to Reason for another iteration.
661    pub fn on_act_completed(&mut self) {
662        debug_assert_eq!(self.phase, TurnPhase::PendingAct);
663        self.has_pending_tool_calls = false;
664        // Loop back to reason for next iteration
665        self.phase = TurnPhase::PendingReason;
666    }
667
668    /// Deliberately seal the turn, stopping further scheduling (EVE-534).
669    ///
670    /// Call this between atoms when the engine decides to stop a turn to prevent
671    /// waste — e.g. the work budget is exhausted (`SealReason::Budget`). The
672    /// turn transitions to `Completed` and `next_action` resolves to
673    /// `TurnOutcome::Sealed`. Sealing is idempotent and the first reason wins.
674    pub fn seal(&mut self, reason: SealReason) {
675        if self.pending_seal.is_none() {
676            self.pending_seal = Some(reason);
677        }
678        self.phase = TurnPhase::Completed;
679    }
680
681    /// Check if the turn has completed.
682    pub fn is_completed(&self) -> bool {
683        self.phase == TurnPhase::Completed
684    }
685}
686
687// ============================================================================
688// Tests
689// ============================================================================
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    fn test_context() -> TurnContext {
696        TurnContext::new(SessionId::new(), MessageId::new(), AgentId::new(), 0)
697    }
698
699    #[test]
700    fn test_simple_turn_no_tools() {
701        let mut sm = TurnStateMachine::new(test_context(), 10);
702
703        // Start with input
704        assert!(matches!(sm.next_action(), TurnAction::ExecuteInput));
705        sm.on_input_completed();
706
707        // Then reason
708        assert!(matches!(sm.next_action(), TurnAction::ExecuteReason));
709        sm.on_reason_completed("Hello!".to_string(), 0, true, None, None, false);
710
711        // Complete
712        match sm.next_action() {
713            TurnAction::Complete(TurnOutcome::Success {
714                response,
715                iterations,
716                tool_calls_count,
717                stop_reason,
718            }) => {
719                assert_eq!(response, "Hello!");
720                assert_eq!(iterations, 1);
721                assert_eq!(tool_calls_count, 0);
722                assert_eq!(stop_reason, TurnStopReason::EndTurn);
723            }
724            other => panic!("Expected Success, got {:?}", other),
725        }
726    }
727
728    #[test]
729    fn test_turn_with_one_tool_call() {
730        let mut sm = TurnStateMachine::new(test_context(), 10);
731
732        // Input
733        assert!(matches!(sm.next_action(), TurnAction::ExecuteInput));
734        sm.on_input_completed();
735
736        // First reason - requests tool call
737        assert!(matches!(sm.next_action(), TurnAction::ExecuteReason));
738        sm.on_reason_completed("Let me check...".to_string(), 1, true, None, None, false);
739
740        // Act
741        assert!(matches!(sm.next_action(), TurnAction::ExecuteAct));
742        sm.on_act_completed();
743
744        // Second reason - no more tool calls
745        assert!(matches!(sm.next_action(), TurnAction::ExecuteReason));
746        sm.on_reason_completed("Here's the result.".to_string(), 0, true, None, None, false);
747
748        // Complete
749        match sm.next_action() {
750            TurnAction::Complete(TurnOutcome::Success {
751                response,
752                iterations,
753                tool_calls_count,
754                ..
755            }) => {
756                assert_eq!(response, "Here's the result.");
757                assert_eq!(iterations, 2);
758                assert_eq!(tool_calls_count, 1);
759            }
760            other => panic!("Expected Success, got {:?}", other),
761        }
762    }
763
764    #[test]
765    fn test_max_iterations() {
766        let mut sm = TurnStateMachine::new(test_context(), 2);
767
768        // Input
769        sm.on_input_completed();
770
771        // First reason - requests tool
772        sm.on_reason_completed("Trying...".to_string(), 1, true, None, None, false);
773        sm.on_act_completed();
774
775        // Second reason - requests another tool (hits max)
776        sm.on_reason_completed("Still trying...".to_string(), 1, true, None, None, false);
777
778        // Should complete with max iterations
779        match sm.next_action() {
780            TurnAction::Complete(outcome @ TurnOutcome::MaxIterationsReached { .. }) => {
781                assert_eq!(outcome.iterations(), 2);
782                assert_eq!(outcome.stop_reason(), TurnStopReason::MaxTurnRequests);
783            }
784            other => panic!("Expected MaxIterationsReached, got {:?}", other),
785        }
786    }
787
788    #[test]
789    fn test_provider_length_finish_reason_surfaces_as_max_tokens() {
790        let mut sm = TurnStateMachine::new(test_context(), 2);
791        sm.on_input_completed();
792        sm.on_reason_completed(
793            "Truncated response".to_string(),
794            0,
795            true,
796            None,
797            Some("length".to_string()),
798            false,
799        );
800
801        match sm.next_action() {
802            TurnAction::Complete(outcome) => {
803                assert_eq!(outcome.stop_reason(), TurnStopReason::MaxTokens);
804            }
805            other => panic!("Expected completed turn, got {other:?}"),
806        }
807    }
808
809    #[test]
810    fn test_provider_refusal_finish_reason_surfaces_as_refusal() {
811        let mut sm = TurnStateMachine::new(test_context(), 2);
812        sm.on_input_completed();
813        sm.on_reason_completed(
814            String::new(),
815            0,
816            false,
817            Some("Model refused".to_string()),
818            Some("refusal".to_string()),
819            false,
820        );
821
822        match sm.next_action() {
823            TurnAction::Complete(outcome) => {
824                assert_eq!(outcome.stop_reason(), TurnStopReason::Refusal);
825            }
826            other => panic!("Expected completed turn, got {other:?}"),
827        }
828    }
829
830    #[test]
831    fn test_stop_reason_wire_values_cover_terminal_contract() {
832        let values = [
833            (TurnStopReason::EndTurn, "\"end_turn\""),
834            (TurnStopReason::MaxTokens, "\"max_tokens\""),
835            (TurnStopReason::MaxTurnRequests, "\"max_turn_requests\""),
836            (TurnStopReason::Refusal, "\"refusal\""),
837            (TurnStopReason::Error, "\"error\""),
838            (TurnStopReason::Cancelled, "\"cancelled\""),
839        ];
840
841        for (reason, expected) in values {
842            assert_eq!(serde_json::to_string(&reason).unwrap(), expected);
843        }
844
845        assert_eq!(
846            TurnStopReason::from_provider_finish_reason(Some("content_filter")),
847            TurnStopReason::Refusal
848        );
849        assert_eq!(
850            TurnStopReason::from_provider_finish_reason(Some("cancelled")),
851            TurnStopReason::Cancelled
852        );
853    }
854
855    #[test]
856    fn test_reason_failure() {
857        let mut sm = TurnStateMachine::new(test_context(), 10);
858
859        // Input
860        sm.on_input_completed();
861
862        // Reason fails
863        sm.on_reason_completed(
864            String::new(),
865            0,
866            false,
867            Some("LLM error".to_string()),
868            None,
869            false,
870        );
871
872        // Should complete with failure
873        match sm.next_action() {
874            TurnAction::Complete(outcome @ TurnOutcome::Failed { .. }) => {
875                assert_eq!(outcome.error(), Some("LLM error"));
876                assert_eq!(outcome.stop_reason(), TurnStopReason::Error);
877            }
878            other => panic!("Expected Failed, got {:?}", other),
879        }
880    }
881
882    #[test]
883    fn test_context_preserved() {
884        let context = TurnContext::new(SessionId::new(), MessageId::new(), AgentId::new(), 42);
885        let turn_id = context.turn_id;
886
887        let sm = TurnStateMachine::new(context, 10);
888
889        // Context should be accessible and unchanged
890        assert_eq!(sm.context().turn_id, turn_id);
891        assert_eq!(sm.context().org_id, 42);
892    }
893
894    #[test]
895    fn test_outcome_helpers() {
896        let success = TurnOutcome::Success {
897            response: "test".to_string(),
898            iterations: 1,
899            tool_calls_count: 0,
900            stop_reason: TurnStopReason::EndTurn,
901        };
902        assert!(success.is_success());
903        assert_eq!(success.response(), Some("test"));
904        assert!(success.error().is_none());
905
906        let failed = TurnOutcome::Failed {
907            error: "oops".to_string(),
908            iterations: 0,
909            stop_reason: TurnStopReason::Error,
910        };
911        assert!(!failed.is_success());
912        assert!(failed.response().is_none());
913        assert_eq!(failed.error(), Some("oops"));
914    }
915
916    #[test]
917    fn test_pending_user_message_continues_turn() {
918        let mut sm = TurnStateMachine::new(test_context(), 10);
919        sm.on_input_completed();
920
921        // Reason completes with no tools, BUT there are pending user messages
922        sm.on_reason_completed("Hello!".to_string(), 0, true, None, None, true);
923
924        // Should NOT be completed — stays in PendingReason
925        assert!(!sm.is_completed());
926        assert_eq!(sm.phase(), TurnPhase::PendingReason);
927        assert!(matches!(sm.next_action(), TurnAction::ExecuteReason));
928
929        // Second reason picks up the new message and completes normally
930        sm.on_reason_completed("Got your message!".to_string(), 0, true, None, None, false);
931        match sm.next_action() {
932            TurnAction::Complete(TurnOutcome::Success {
933                response,
934                iterations,
935                ..
936            }) => {
937                assert_eq!(response, "Got your message!");
938                assert_eq!(iterations, 2);
939            }
940            other => panic!("Expected Success, got {:?}", other),
941        }
942    }
943
944    #[test]
945    fn test_pending_messages_ignored_on_failure() {
946        let mut sm = TurnStateMachine::new(test_context(), 10);
947        sm.on_input_completed();
948
949        // Failure + pending messages → still fails
950        sm.on_reason_completed(
951            String::new(),
952            0,
953            false,
954            Some("LLM error".to_string()),
955            None,
956            true,
957        );
958        assert!(sm.is_completed());
959        assert!(matches!(
960            sm.next_action(),
961            TurnAction::Complete(TurnOutcome::Failed { .. })
962        ));
963    }
964
965    #[test]
966    fn test_progress_token_monotonicity() {
967        // Higher event sequence => strictly higher token.
968        let t0 = ProgressToken::from_event_sequence(-1); // no events yet
969        let t1 = ProgressToken::from_event_sequence(0);
970        let t2 = ProgressToken::from_event_sequence(5);
971        assert_eq!(t0, ProgressToken::ZERO);
972        assert!(t1.advanced_from(t0));
973        assert!(t2.advanced_from(t1));
974        // Same sequence => no advance (a non-advancing retry can't game it).
975        let t2_again = ProgressToken::from_event_sequence(5);
976        assert!(!t2_again.advanced_from(t2));
977        assert!(!t2.advanced_from(t2_again));
978        // Ordering matches numeric ordering.
979        assert!(t2 > t1 && t1 > t0);
980    }
981
982    #[test]
983    fn test_no_progress_counter_logic() {
984        // Mirrors the guard the store applies on each reclaim: the counter only
985        // increments when the token is unchanged across attempts, and resets to
986        // zero on any advance. Sealing fires when the counter reaches N.
987        let threshold = 3u32;
988        let mut recorded = ProgressToken::ZERO;
989        let mut no_progress = 0u32;
990
991        let step =
992            |recorded: &mut ProgressToken, no_progress: &mut u32, observed: ProgressToken| {
993                if observed.advanced_from(*recorded) {
994                    *recorded = observed;
995                    *no_progress = 0;
996                } else {
997                    *no_progress += 1;
998                }
999                *no_progress
1000            };
1001
1002        // Crash without recording any event => token unchanged => increments.
1003        assert_eq!(
1004            step(&mut recorded, &mut no_progress, ProgressToken::ZERO),
1005            1
1006        );
1007        assert_eq!(
1008            step(&mut recorded, &mut no_progress, ProgressToken::ZERO),
1009            2
1010        );
1011        // An advance resets the counter.
1012        assert_eq!(
1013            step(
1014                &mut recorded,
1015                &mut no_progress,
1016                ProgressToken::from_event_sequence(2)
1017            ),
1018            0
1019        );
1020        // Then stall again until we hit the seal threshold.
1021        let stuck = ProgressToken::from_event_sequence(2);
1022        assert_eq!(step(&mut recorded, &mut no_progress, stuck), 1);
1023        assert_eq!(step(&mut recorded, &mut no_progress, stuck), 2);
1024        let count = step(&mut recorded, &mut no_progress, stuck);
1025        assert_eq!(count, 3);
1026        assert!(count >= threshold, "should seal once threshold reached");
1027    }
1028
1029    #[test]
1030    fn test_seal_threshold_env_never_zero() {
1031        // Default applies when unset; a 0 must coerce to at least 1.
1032        // (We only assert the floor invariant without touching process env.)
1033        assert_eq!(DEFAULT_NO_PROGRESS_SEAL_THRESHOLD, 3);
1034    }
1035
1036    #[test]
1037    fn test_budget_seal_outcome() {
1038        let mut sm = TurnStateMachine::new(test_context(), 10);
1039        sm.on_input_completed();
1040        // A reason completes with tool calls (turn would normally continue)...
1041        sm.on_reason_completed("Working...".to_string(), 1, true, None, None, false);
1042        sm.on_act_completed();
1043        // ...but the engine seals it because the work budget is exhausted.
1044        sm.seal(SealReason::Budget);
1045        assert!(sm.is_completed());
1046        match sm.next_action() {
1047            TurnAction::Complete(TurnOutcome::Sealed {
1048                reason, iterations, ..
1049            }) => {
1050                assert_eq!(reason, SealReason::Budget);
1051                assert_eq!(iterations, 1);
1052            }
1053            other => panic!("Expected Sealed, got {:?}", other),
1054        }
1055    }
1056
1057    #[test]
1058    fn test_seal_takes_precedence_over_error() {
1059        // If a turn both errored and was sealed, the deliberate seal wins so the
1060        // turn is non-retryable rather than surfacing as a transient failure.
1061        let mut sm = TurnStateMachine::new(test_context(), 10);
1062        sm.on_input_completed();
1063        sm.on_reason_completed(
1064            String::new(),
1065            0,
1066            false,
1067            Some("LLM error".to_string()),
1068            None,
1069            false,
1070        );
1071        sm.seal(SealReason::NoProgress);
1072        match sm.next_action() {
1073            TurnAction::Complete(TurnOutcome::Sealed { reason, .. }) => {
1074                assert_eq!(reason, SealReason::NoProgress);
1075            }
1076            other => panic!("Expected Sealed, got {:?}", other),
1077        }
1078    }
1079
1080    #[test]
1081    fn test_seal_reason_wire_roundtrip() {
1082        assert_eq!(SealReason::NoProgress.as_str(), "no_progress");
1083        assert_eq!(SealReason::Budget.as_str(), "budget");
1084        assert_eq!(SealReason::from_str_lossy("budget"), SealReason::Budget);
1085        assert_eq!(
1086            SealReason::from_str_lossy("no_progress"),
1087            SealReason::NoProgress
1088        );
1089        // Unknown reasons stay forward-compatible.
1090        assert_eq!(
1091            SealReason::from_str_lossy("future_reason"),
1092            SealReason::NoProgress
1093        );
1094    }
1095
1096    #[test]
1097    fn test_outcome_sealed_helpers() {
1098        let sealed = TurnOutcome::Sealed {
1099            reason: SealReason::Budget,
1100            response: "partial".to_string(),
1101            iterations: 2,
1102            tool_calls_count: 1,
1103        };
1104        assert!(sealed.is_sealed());
1105        assert!(!sealed.is_success());
1106        assert_eq!(sealed.seal_reason(), Some(SealReason::Budget));
1107        assert_eq!(sealed.response(), Some("partial"));
1108        assert!(sealed.error().is_none());
1109        assert_eq!(sealed.iterations(), 2);
1110    }
1111
1112    #[test]
1113    fn test_pending_messages_ignored_when_tool_calls() {
1114        let mut sm = TurnStateMachine::new(test_context(), 10);
1115        sm.on_input_completed();
1116
1117        // Tool calls + pending messages → tool calls take priority
1118        sm.on_reason_completed("Working...".to_string(), 2, true, None, None, true);
1119        assert_eq!(sm.phase(), TurnPhase::PendingAct);
1120    }
1121}