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