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}