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}