Skip to main content

ralph_workflow/reducer/state/agent_chain/
transitions.rs

1// State transition methods for AgentChainState.
2//
3// These methods implement the fallback chain progression: advancing models,
4// switching agents, and starting retry cycles with backoff.
5
6use std::sync::Arc;
7
8use super::backoff::calculate_backoff_delay_ms;
9use super::{AgentChainState, AgentDrain, AgentRole, DrainMode, RateLimitContinuationPrompt};
10
11impl AgentChainState {
12    #[must_use]
13    pub fn advance_to_next_model(&self) -> Self {
14        // When models are configured, we try each model for the current agent once.
15        // If the models list is exhausted, advance to the next agent/retry cycle
16        // instead of looping models indefinitely.
17        //
18        // Session ID handling: preserved when staying on the same agent (model advance),
19        // cleared via switch_to_next_agent when switching agents or wrapping to a new cycle.
20        match self.models_per_agent.get(self.current_agent_index) {
21            Some(models) if !models.is_empty() => {
22                if self.current_model_index + 1 < models.len() {
23                    // Simple model advance - only increment model index, preserve session
24                    Self {
25                        agents: Arc::clone(&self.agents),
26                        current_agent_index: self.current_agent_index,
27                        models_per_agent: Arc::clone(&self.models_per_agent),
28                        current_model_index: self.current_model_index + 1,
29                        retry_cycle: self.retry_cycle,
30                        max_cycles: self.max_cycles,
31                        retry_delay_ms: self.retry_delay_ms,
32                        backoff_multiplier: self.backoff_multiplier,
33                        max_backoff_ms: self.max_backoff_ms,
34                        backoff_pending_ms: self.backoff_pending_ms,
35                        current_role: self.current_role,
36                        current_drain: self.current_drain,
37                        current_mode: self.current_mode,
38                        rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
39                        last_session_id: self.last_session_id.clone(),
40                        last_failure_reason: self.last_failure_reason.clone(),
41                    }
42                } else {
43                    // Models exhausted for current agent: switch to next agent (clears session).
44                    // When at the last agent, switch_to_next_agent wraps to agent 0 and
45                    // increments the retry cycle, signaling chain exhaustion.
46                    self.switch_to_next_agent()
47                }
48            }
49            // No models configured: treat as single-model agent, switch immediately.
50            _ => self.switch_to_next_agent(),
51        }
52    }
53
54    /// Switch to the next agent in the fallback chain.
55    ///
56    /// Sessions are agent-scoped: `last_session_id` is always cleared when switching agents.
57    /// Callers do not need to call `clear_session_id()` afterward.
58    #[must_use]
59    pub fn switch_to_next_agent(&self) -> Self {
60        if self.current_agent_index + 1 < self.agents.len() {
61            // Advance to next agent. Session is agent-scoped and must not carry over.
62            Self {
63                agents: Arc::clone(&self.agents),
64                current_agent_index: self.current_agent_index + 1,
65                models_per_agent: Arc::clone(&self.models_per_agent),
66                current_model_index: 0,
67                retry_cycle: self.retry_cycle,
68                max_cycles: self.max_cycles,
69                retry_delay_ms: self.retry_delay_ms,
70                backoff_multiplier: self.backoff_multiplier,
71                max_backoff_ms: self.max_backoff_ms,
72                backoff_pending_ms: None,
73                current_role: self.current_role,
74                current_drain: self.current_drain,
75                current_mode: self.current_mode,
76                rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
77                last_session_id: None,
78                last_failure_reason: self.last_failure_reason.clone(),
79            }
80        } else {
81            // Wrap around to first agent and increment retry cycle
82            let new_retry_cycle = self.retry_cycle + 1;
83            let new_backoff_pending_ms = if new_retry_cycle >= self.max_cycles {
84                None
85            } else {
86                // Create temporary state to calculate backoff
87                let temp = Self {
88                    agents: Arc::clone(&self.agents),
89                    current_agent_index: 0,
90                    models_per_agent: Arc::clone(&self.models_per_agent),
91                    current_model_index: 0,
92                    retry_cycle: new_retry_cycle,
93                    max_cycles: self.max_cycles,
94                    retry_delay_ms: self.retry_delay_ms,
95                    backoff_multiplier: self.backoff_multiplier,
96                    max_backoff_ms: self.max_backoff_ms,
97                    backoff_pending_ms: None,
98                    current_role: self.current_role,
99                    current_drain: self.current_drain,
100                    current_mode: self.current_mode,
101                    rate_limit_continuation_prompt: None,
102                    last_session_id: None,
103                    last_failure_reason: None,
104                };
105                Some(temp.calculate_backoff_delay_ms_for_retry_cycle())
106            };
107
108            // Wrapping to a new retry cycle: session is stale and must be cleared.
109            Self {
110                agents: Arc::clone(&self.agents),
111                current_agent_index: 0,
112                models_per_agent: Arc::clone(&self.models_per_agent),
113                current_model_index: 0,
114                retry_cycle: new_retry_cycle,
115                max_cycles: self.max_cycles,
116                retry_delay_ms: self.retry_delay_ms,
117                backoff_multiplier: self.backoff_multiplier,
118                max_backoff_ms: self.max_backoff_ms,
119                backoff_pending_ms: new_backoff_pending_ms,
120                current_role: self.current_role,
121                current_drain: self.current_drain,
122                current_mode: self.current_mode,
123                rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
124                last_session_id: None,
125                last_failure_reason: self.last_failure_reason.clone(),
126            }
127        }
128    }
129
130    /// Switch to a specific agent by name.
131    ///
132    /// If `to_agent` is unknown, falls back to `switch_to_next_agent()` to keep the
133    /// reducer deterministic.
134    #[must_use]
135    pub fn switch_to_agent_named(&self, to_agent: &str) -> Self {
136        let Some(target_index) = self.agents.iter().position(|a| a == to_agent) else {
137            return self.switch_to_next_agent();
138        };
139
140        if target_index == self.current_agent_index {
141            // Same agent - just reset model index
142            return Self {
143                agents: Arc::clone(&self.agents),
144                current_agent_index: self.current_agent_index,
145                models_per_agent: Arc::clone(&self.models_per_agent),
146                current_model_index: 0,
147                retry_cycle: self.retry_cycle,
148                max_cycles: self.max_cycles,
149                retry_delay_ms: self.retry_delay_ms,
150                backoff_multiplier: self.backoff_multiplier,
151                max_backoff_ms: self.max_backoff_ms,
152                backoff_pending_ms: None,
153                current_role: self.current_role,
154                current_drain: self.current_drain,
155                current_mode: self.current_mode,
156                rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
157                last_session_id: self.last_session_id.clone(),
158                last_failure_reason: self.last_failure_reason.clone(),
159            };
160        }
161
162        if target_index <= self.current_agent_index {
163            // Treat switching to an earlier agent as starting a new retry cycle.
164            let new_retry_cycle = self.retry_cycle + 1;
165            let new_backoff_pending_ms = if new_retry_cycle >= self.max_cycles && target_index == 0
166            {
167                None
168            } else {
169                // Create temporary state to calculate backoff
170                let temp = Self {
171                    agents: Arc::clone(&self.agents),
172                    current_agent_index: target_index,
173                    models_per_agent: Arc::clone(&self.models_per_agent),
174                    current_model_index: 0,
175                    retry_cycle: new_retry_cycle,
176                    max_cycles: self.max_cycles,
177                    retry_delay_ms: self.retry_delay_ms,
178                    backoff_multiplier: self.backoff_multiplier,
179                    max_backoff_ms: self.max_backoff_ms,
180                    backoff_pending_ms: None,
181                    current_role: self.current_role,
182                    current_drain: self.current_drain,
183                    current_mode: self.current_mode,
184                    rate_limit_continuation_prompt: None,
185                    last_session_id: None,
186                    last_failure_reason: None,
187                };
188                Some(temp.calculate_backoff_delay_ms_for_retry_cycle())
189            };
190
191            Self {
192                agents: Arc::clone(&self.agents),
193                current_agent_index: target_index,
194                models_per_agent: Arc::clone(&self.models_per_agent),
195                current_model_index: 0,
196                retry_cycle: new_retry_cycle,
197                max_cycles: self.max_cycles,
198                retry_delay_ms: self.retry_delay_ms,
199                backoff_multiplier: self.backoff_multiplier,
200                max_backoff_ms: self.max_backoff_ms,
201                backoff_pending_ms: new_backoff_pending_ms,
202                current_role: self.current_role,
203                current_drain: self.current_drain,
204                current_mode: self.current_mode,
205                rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
206                // Sessions are agent-scoped. Switching to a different (earlier) agent clears it.
207                last_session_id: None,
208                last_failure_reason: self.last_failure_reason.clone(),
209            }
210        } else {
211            // Advancing to later agent. Sessions are agent-scoped; must not carry over.
212            Self {
213                agents: Arc::clone(&self.agents),
214                current_agent_index: target_index,
215                models_per_agent: Arc::clone(&self.models_per_agent),
216                current_model_index: 0,
217                retry_cycle: self.retry_cycle,
218                max_cycles: self.max_cycles,
219                retry_delay_ms: self.retry_delay_ms,
220                backoff_multiplier: self.backoff_multiplier,
221                max_backoff_ms: self.max_backoff_ms,
222                backoff_pending_ms: None,
223                current_role: self.current_role,
224                current_drain: self.current_drain,
225                current_mode: self.current_mode,
226                rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
227                // Sessions are agent-scoped. Switching to a different (later) agent clears it.
228                last_session_id: None,
229                last_failure_reason: self.last_failure_reason.clone(),
230            }
231        }
232    }
233
234    /// Switch to next agent after rate limit, preserving prompt for continuation.
235    ///
236    /// This is used when an agent hits a 429 rate limit error. Instead of
237    /// retrying with the same agent (which would likely hit rate limits again),
238    /// we switch to the next agent and preserve the prompt so the new agent
239    /// can continue the same work.
240    #[must_use]
241    pub fn switch_to_next_agent_with_prompt(&self, prompt: Option<String>) -> Self {
242        let base = self.switch_to_next_agent();
243        // Back-compat: older callers didn't track role. Preserve prompt only.
244        Self {
245            agents: base.agents,
246            current_agent_index: base.current_agent_index,
247            models_per_agent: base.models_per_agent,
248            current_model_index: base.current_model_index,
249            retry_cycle: base.retry_cycle,
250            max_cycles: base.max_cycles,
251            retry_delay_ms: base.retry_delay_ms,
252            backoff_multiplier: base.backoff_multiplier,
253            max_backoff_ms: base.max_backoff_ms,
254            backoff_pending_ms: base.backoff_pending_ms,
255            current_role: base.current_role,
256            current_drain: base.current_drain,
257            current_mode: base.current_mode,
258            rate_limit_continuation_prompt: prompt.map(|p| RateLimitContinuationPrompt {
259                drain: base.current_drain,
260                role: base.current_role,
261                prompt: p,
262            }),
263            last_session_id: base.last_session_id,
264            last_failure_reason: base.last_failure_reason.clone(),
265        }
266    }
267
268    /// Switch to next agent after rate limit, preserving prompt for continuation (role-scoped).
269    #[must_use]
270    pub fn switch_to_next_agent_with_prompt_for_role(
271        &self,
272        role: AgentRole,
273        prompt: Option<String>,
274    ) -> Self {
275        let base = self.switch_to_next_agent();
276        Self {
277            agents: base.agents,
278            current_agent_index: base.current_agent_index,
279            models_per_agent: base.models_per_agent,
280            current_model_index: base.current_model_index,
281            retry_cycle: base.retry_cycle,
282            max_cycles: base.max_cycles,
283            retry_delay_ms: base.retry_delay_ms,
284            backoff_multiplier: base.backoff_multiplier,
285            max_backoff_ms: base.max_backoff_ms,
286            backoff_pending_ms: base.backoff_pending_ms,
287            current_role: base.current_role,
288            current_drain: base.current_drain,
289            current_mode: base.current_mode,
290            rate_limit_continuation_prompt: prompt.map(|p| RateLimitContinuationPrompt {
291                drain: base.current_drain,
292                role,
293                prompt: p,
294            }),
295            last_session_id: base.last_session_id,
296            last_failure_reason: base.last_failure_reason.clone(),
297        }
298    }
299
300    /// Clear continuation prompt after successful execution.
301    ///
302    /// Called when an agent successfully completes its task, clearing any
303    /// saved prompt context from previous rate-limited agents.
304    #[must_use]
305    pub fn clear_continuation_prompt(&self) -> Self {
306        Self {
307            agents: Arc::clone(&self.agents),
308            current_agent_index: self.current_agent_index,
309            models_per_agent: Arc::clone(&self.models_per_agent),
310            current_model_index: self.current_model_index,
311            retry_cycle: self.retry_cycle,
312            max_cycles: self.max_cycles,
313            retry_delay_ms: self.retry_delay_ms,
314            backoff_multiplier: self.backoff_multiplier,
315            max_backoff_ms: self.max_backoff_ms,
316            backoff_pending_ms: self.backoff_pending_ms,
317            current_role: self.current_role,
318            current_drain: self.current_drain,
319            current_mode: self.current_mode,
320            rate_limit_continuation_prompt: None,
321            last_session_id: self.last_session_id.clone(),
322            last_failure_reason: None,
323        }
324    }
325
326    #[must_use]
327    pub fn reset_for_drain(&self, drain: AgentDrain) -> Self {
328        Self {
329            agents: Arc::clone(&self.agents),
330            current_agent_index: 0,
331            models_per_agent: Arc::clone(&self.models_per_agent),
332            current_model_index: 0,
333            retry_cycle: 0,
334            max_cycles: self.max_cycles,
335            retry_delay_ms: self.retry_delay_ms,
336            backoff_multiplier: self.backoff_multiplier,
337            max_backoff_ms: self.max_backoff_ms,
338            backoff_pending_ms: None,
339            current_role: drain.role(),
340            current_drain: drain,
341            current_mode: DrainMode::Normal,
342            rate_limit_continuation_prompt: None,
343            last_session_id: None,
344            last_failure_reason: None,
345        }
346    }
347
348    #[must_use]
349    pub fn reset_for_role(&self, role: AgentRole) -> Self {
350        self.reset_for_drain(match role {
351            AgentRole::Developer => AgentDrain::Development,
352            AgentRole::Reviewer => AgentDrain::Review,
353            AgentRole::Commit => AgentDrain::Commit,
354            AgentRole::Analysis => AgentDrain::Analysis,
355        })
356    }
357
358    #[must_use]
359    pub fn reset(&self) -> Self {
360        Self {
361            agents: Arc::clone(&self.agents),
362            current_agent_index: 0,
363            models_per_agent: Arc::clone(&self.models_per_agent),
364            current_model_index: 0,
365            retry_cycle: self.retry_cycle,
366            max_cycles: self.max_cycles,
367            retry_delay_ms: self.retry_delay_ms,
368            backoff_multiplier: self.backoff_multiplier,
369            max_backoff_ms: self.max_backoff_ms,
370            backoff_pending_ms: None,
371            current_role: self.current_role,
372            current_drain: self.current_drain,
373            current_mode: DrainMode::Normal,
374            rate_limit_continuation_prompt: None,
375            last_session_id: None,
376            last_failure_reason: None,
377        }
378    }
379
380    /// Store session ID from agent response for potential reuse.
381    #[must_use]
382    pub fn with_session_id(&self, session_id: Option<String>) -> Self {
383        Self {
384            agents: Arc::clone(&self.agents),
385            current_agent_index: self.current_agent_index,
386            models_per_agent: Arc::clone(&self.models_per_agent),
387            current_model_index: self.current_model_index,
388            retry_cycle: self.retry_cycle,
389            max_cycles: self.max_cycles,
390            retry_delay_ms: self.retry_delay_ms,
391            backoff_multiplier: self.backoff_multiplier,
392            max_backoff_ms: self.max_backoff_ms,
393            backoff_pending_ms: self.backoff_pending_ms,
394            current_role: self.current_role,
395            current_drain: self.current_drain,
396            current_mode: self.current_mode,
397            rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
398            last_session_id: session_id,
399            last_failure_reason: self.last_failure_reason.clone(),
400        }
401    }
402
403    /// Store last failure reason for CLI output context.
404    #[must_use]
405    pub fn with_failure_reason(&self, reason: Option<String>) -> Self {
406        Self {
407            agents: Arc::clone(&self.agents),
408            current_agent_index: self.current_agent_index,
409            models_per_agent: Arc::clone(&self.models_per_agent),
410            current_model_index: self.current_model_index,
411            retry_cycle: self.retry_cycle,
412            max_cycles: self.max_cycles,
413            retry_delay_ms: self.retry_delay_ms,
414            backoff_multiplier: self.backoff_multiplier,
415            max_backoff_ms: self.max_backoff_ms,
416            backoff_pending_ms: self.backoff_pending_ms,
417            current_role: self.current_role,
418            current_drain: self.current_drain,
419            current_mode: self.current_mode,
420            rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
421            last_session_id: self.last_session_id.clone(),
422            last_failure_reason: reason,
423        }
424    }
425
426    /// Clear session ID (e.g., when switching agents or starting new work).
427    #[must_use]
428    pub fn clear_session_id(&self) -> Self {
429        Self {
430            agents: Arc::clone(&self.agents),
431            current_agent_index: self.current_agent_index,
432            models_per_agent: Arc::clone(&self.models_per_agent),
433            current_model_index: self.current_model_index,
434            retry_cycle: self.retry_cycle,
435            max_cycles: self.max_cycles,
436            retry_delay_ms: self.retry_delay_ms,
437            backoff_multiplier: self.backoff_multiplier,
438            max_backoff_ms: self.max_backoff_ms,
439            backoff_pending_ms: self.backoff_pending_ms,
440            current_role: self.current_role,
441            current_drain: self.current_drain,
442            current_mode: self.current_mode,
443            rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
444            last_session_id: None,
445            last_failure_reason: self.last_failure_reason.clone(),
446        }
447    }
448
449    #[must_use]
450    pub fn start_retry_cycle(&self) -> Self {
451        let new_retry_cycle = self.retry_cycle + 1;
452        let new_backoff_pending_ms = if new_retry_cycle >= self.max_cycles {
453            None
454        } else {
455            // Create temporary state to calculate backoff
456            let temp = Self {
457                agents: Arc::clone(&self.agents),
458                current_agent_index: 0,
459                models_per_agent: Arc::clone(&self.models_per_agent),
460                current_model_index: 0,
461                retry_cycle: new_retry_cycle,
462                max_cycles: self.max_cycles,
463                retry_delay_ms: self.retry_delay_ms,
464                backoff_multiplier: self.backoff_multiplier,
465                max_backoff_ms: self.max_backoff_ms,
466                backoff_pending_ms: None,
467                current_role: self.current_role,
468                current_drain: self.current_drain,
469                current_mode: self.current_mode,
470                rate_limit_continuation_prompt: None,
471                last_session_id: None,
472                last_failure_reason: None,
473            };
474            Some(temp.calculate_backoff_delay_ms_for_retry_cycle())
475        };
476
477        Self {
478            agents: Arc::clone(&self.agents),
479            current_agent_index: 0,
480            models_per_agent: Arc::clone(&self.models_per_agent),
481            current_model_index: 0,
482            retry_cycle: new_retry_cycle,
483            max_cycles: self.max_cycles,
484            retry_delay_ms: self.retry_delay_ms,
485            backoff_multiplier: self.backoff_multiplier,
486            max_backoff_ms: self.max_backoff_ms,
487            backoff_pending_ms: new_backoff_pending_ms,
488            current_role: self.current_role,
489            current_drain: self.current_drain,
490            current_mode: self.current_mode,
491            rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
492            // Session IDs are agent-scoped. Starting a new retry cycle means all agents
493            // were exhausted; any session from a previous cycle is stale.
494            last_session_id: None,
495            last_failure_reason: self.last_failure_reason.clone(),
496        }
497    }
498
499    #[must_use]
500    pub fn clear_backoff_pending(&self) -> Self {
501        Self {
502            agents: Arc::clone(&self.agents),
503            current_agent_index: self.current_agent_index,
504            models_per_agent: Arc::clone(&self.models_per_agent),
505            current_model_index: self.current_model_index,
506            retry_cycle: self.retry_cycle,
507            max_cycles: self.max_cycles,
508            retry_delay_ms: self.retry_delay_ms,
509            backoff_multiplier: self.backoff_multiplier,
510            max_backoff_ms: self.max_backoff_ms,
511            backoff_pending_ms: None,
512            current_role: self.current_role,
513            current_drain: self.current_drain,
514            current_mode: self.current_mode,
515            rate_limit_continuation_prompt: self.rate_limit_continuation_prompt.clone(),
516            last_session_id: self.last_session_id.clone(),
517            last_failure_reason: self.last_failure_reason.clone(),
518        }
519    }
520
521    pub(super) fn calculate_backoff_delay_ms_for_retry_cycle(&self) -> u64 {
522        // The first retry cycle should use the base delay.
523        let cycle_index = self.retry_cycle.saturating_sub(1);
524        calculate_backoff_delay_ms(
525            self.retry_delay_ms,
526            self.backoff_multiplier,
527            self.max_backoff_ms,
528            cycle_index,
529        )
530    }
531}
532
533#[cfg(test)]
534mod advance_to_next_model_tests {
535    use super::*;
536
537    #[test]
538    fn test_advance_to_next_model_increments_model_index_within_agent() {
539        // When models remain for the current agent, model index advances and session is preserved.
540        let state = AgentChainState::initial()
541            .with_agents(
542                vec!["claude".to_string()],
543                vec![vec!["m1".to_string(), "m2".to_string()]],
544                AgentRole::Developer,
545            )
546            .with_session_id(Some("sess".to_string()));
547
548        let next = state.advance_to_next_model();
549
550        assert_eq!(next.current_model_index, 1);
551        assert_eq!(next.current_agent_index, 0);
552        assert_eq!(
553            next.last_session_id,
554            Some("sess".to_string()),
555            "session must be preserved when staying on the same agent"
556        );
557    }
558
559    #[test]
560    fn test_advance_to_next_model_switches_agent_when_models_exhausted() {
561        // When the current agent has no remaining models, advance switches to next agent
562        // and clears the session ID.
563        let state = AgentChainState::initial()
564            .with_agents(
565                vec!["claude".to_string(), "codex".to_string()],
566                vec![vec!["m1".to_string()], vec!["m2".to_string()]],
567                AgentRole::Developer,
568            )
569            .with_session_id(Some("sess".to_string()));
570
571        let next = state.advance_to_next_model();
572
573        assert_eq!(next.current_agent_index, 1);
574        assert_eq!(next.current_model_index, 0);
575        assert_eq!(
576            next.last_session_id, None,
577            "session must be cleared when switching to a different agent"
578        );
579    }
580
581    #[test]
582    fn test_advance_to_next_model_wraps_to_retry_cycle_when_all_agents_exhausted() {
583        // When the last agent's last model is exhausted, the chain wraps to agent 0
584        // and increments the retry cycle with a backoff delay.
585        // Session is cleared because switch_to_next_agent is called, which is agent-scoped.
586        let state = AgentChainState::initial()
587            .with_agents(
588                vec!["claude".to_string()],
589                vec![vec!["m1".to_string()]],
590                AgentRole::Developer,
591            )
592            .with_session_id(Some("sess".to_string()));
593
594        let next = state.advance_to_next_model();
595
596        assert_eq!(
597            next.retry_cycle, 1,
598            "retry cycle must increment when all agents wrap around"
599        );
600        assert_eq!(next.current_agent_index, 0);
601        assert_eq!(next.current_model_index, 0);
602        assert!(
603            next.backoff_pending_ms.is_some(),
604            "backoff must be set when a retry cycle begins"
605        );
606        // Session is cleared: switch_to_next_agent clears session at the transition level.
607        assert_eq!(
608            next.last_session_id, None,
609            "session must be cleared when the chain wraps to a new retry cycle"
610        );
611    }
612}
613
614#[cfg(test)]
615mod session_id_lifecycle_tests {
616    use super::*;
617
618    fn state_with_session() -> AgentChainState {
619        AgentChainState::initial()
620            .with_agents(
621                vec!["claude".to_string(), "codex".to_string()],
622                vec![vec![], vec![]],
623                AgentRole::Developer,
624            )
625            .with_session_id(Some("test-session".to_string()))
626    }
627
628    #[test]
629    fn test_with_session_id_sets_session() {
630        let state = AgentChainState::initial().with_agents(
631            vec!["claude".to_string()],
632            vec![vec![]],
633            AgentRole::Developer,
634        );
635        assert_eq!(state.last_session_id, None);
636
637        let state = state.with_session_id(Some("new-session".to_string()));
638        assert_eq!(state.last_session_id, Some("new-session".to_string()));
639    }
640
641    #[test]
642    fn test_with_session_id_can_clear_session() {
643        let state = state_with_session();
644        assert_eq!(state.last_session_id, Some("test-session".to_string()));
645
646        let state = state.with_session_id(None);
647        assert_eq!(state.last_session_id, None);
648    }
649
650    #[test]
651    fn test_clear_continuation_prompt_preserves_session_id() {
652        // clear_continuation_prompt must not affect last_session_id.
653        let state = state_with_session();
654
655        let next = state.clear_continuation_prompt();
656
657        assert_eq!(
658            next.last_session_id,
659            Some("test-session".to_string()),
660            "clear_continuation_prompt must preserve last_session_id"
661        );
662    }
663
664    #[test]
665    fn test_switch_to_next_agent_clears_session_at_transition_level() {
666        // Sessions are agent-scoped. switch_to_next_agent always clears last_session_id
667        // at the transition level — callers do not need to call clear_session_id() afterward.
668        let state = state_with_session();
669
670        let next = state.switch_to_next_agent();
671
672        assert_eq!(next.current_agent_index, 1);
673        assert_eq!(
674            next.last_session_id, None,
675            "switch_to_next_agent must clear last_session_id: sessions are agent-scoped"
676        );
677    }
678
679    #[test]
680    fn test_switch_to_next_agent_with_prompt_clears_session_at_transition_level() {
681        // switch_to_next_agent_with_prompt_for_role delegates to switch_to_next_agent,
682        // which clears the session. The session must be None after the transition.
683        let state = state_with_session();
684
685        let next = state.switch_to_next_agent_with_prompt_for_role(
686            AgentRole::Developer,
687            Some("continue here".to_string()),
688        );
689
690        assert_eq!(next.current_agent_index, 1);
691        assert_eq!(
692            next.last_session_id,
693            None,
694            "switch_to_next_agent_with_prompt clears session via the underlying switch_to_next_agent"
695        );
696    }
697
698    #[test]
699    fn test_start_retry_cycle_clears_session_id() {
700        // start_retry_cycle signals that ALL agents were exhausted. The session from any
701        // previous cycle is stale and must not be reused in the new cycle.
702        let state = state_with_session();
703
704        let next = state.start_retry_cycle();
705
706        assert_eq!(next.current_agent_index, 0);
707        assert_eq!(next.retry_cycle, 1);
708        assert_eq!(
709            next.last_session_id, None,
710            "start_retry_cycle must clear last_session_id: sessions are agent-scoped \
711             and any session from a previous cycle is stale"
712        );
713    }
714
715    #[test]
716    fn test_reset_for_drain_clears_session_id() {
717        // reset_for_drain is a full drain reset; last_session_id must be cleared.
718        let state = state_with_session();
719
720        let next = state.reset_for_drain(AgentDrain::Review);
721
722        assert_eq!(
723            next.last_session_id, None,
724            "reset_for_drain must clear last_session_id"
725        );
726        assert_eq!(next.current_drain, AgentDrain::Review);
727    }
728
729    #[test]
730    fn test_reset_clears_session_id() {
731        // reset() resets indices but preserves drain; session must still be cleared.
732        let state = state_with_session();
733
734        let next = state.reset();
735
736        assert_eq!(
737            next.last_session_id, None,
738            "reset() must clear last_session_id"
739        );
740    }
741
742    #[test]
743    fn test_switch_to_agent_named_backward_clears_session() {
744        // Jumping backward to an earlier agent — session must be cleared (agent-scoped).
745        let chain = AgentChainState::initial()
746            .with_agents(
747                vec!["agent0".to_string(), "agent1".to_string()],
748                vec![vec![], vec![]],
749                AgentRole::Developer,
750            )
751            .with_current_agent_index(1)
752            .with_session_id(Some("session-abc".to_string()));
753
754        let next = chain.switch_to_agent_named("agent0");
755        assert_eq!(next.current_agent_index, 0, "should switch to agent0");
756        assert_eq!(
757            next.last_session_id, None,
758            "session must be cleared when switching to a different (earlier) agent"
759        );
760    }
761
762    #[test]
763    fn test_switch_to_agent_named_forward_clears_session() {
764        // Jumping forward to a later agent — session must be cleared (agent-scoped).
765        let chain = AgentChainState::initial()
766            .with_agents(
767                vec![
768                    "agent0".to_string(),
769                    "agent1".to_string(),
770                    "agent2".to_string(),
771                ],
772                vec![vec![], vec![], vec![]],
773                AgentRole::Developer,
774            )
775            .with_session_id(Some("session-xyz".to_string()));
776
777        let next = chain.switch_to_agent_named("agent2");
778        assert_eq!(next.current_agent_index, 2, "should switch to agent2");
779        assert_eq!(
780            next.last_session_id, None,
781            "session must be cleared when switching to a different (later) agent"
782        );
783    }
784
785    #[test]
786    fn test_switch_to_agent_named_same_agent_preserves_session() {
787        // Switching to the same agent (no-op) — session must be preserved.
788        let chain = AgentChainState::initial()
789            .with_agents(
790                vec!["agent0".to_string(), "agent1".to_string()],
791                vec![vec![], vec![]],
792                AgentRole::Developer,
793            )
794            .with_session_id(Some("session-keep".to_string()));
795
796        let next = chain.switch_to_agent_named("agent0");
797        assert_eq!(next.current_agent_index, 0);
798        assert_eq!(
799            next.last_session_id,
800            Some("session-keep".to_string()),
801            "session must be preserved when switching to the same agent"
802        );
803    }
804
805    #[test]
806    fn test_switch_to_agent_named_same_agent_resets_model_index_and_clears_backoff() {
807        // switch_to_agent_named on the current agent must reset current_model_index to 0
808        // and clear backoff_pending_ms, while preserving last_session_id.
809        //
810        // Setup: build a state where model_index > 0 and backoff_pending_ms is Some.
811        // - start_retry_cycle() produces backoff_pending_ms = Some(1000) at model 0.
812        // - advance_to_next_model() advances model_index to 1, preserving backoff and session.
813        let chain = AgentChainState::initial()
814            .with_agents(
815                vec!["agent0".to_string()],
816                vec![vec!["m1".to_string(), "m2".to_string()]],
817                AgentRole::Developer,
818            )
819            .with_max_cycles(5)
820            .with_backoff_policy(1000, 2.0, 60_000);
821
822        // start_retry_cycle sets backoff_pending_ms = Some(1000) and clears session.
823        let chain = chain.start_retry_cycle();
824        // Restore session to verify it is preserved across the same-agent switch.
825        let chain = chain.with_session_id(Some("session-keep".to_string()));
826        // advance_to_next_model: model 0 → 1; backoff and session both preserved.
827        let chain = chain.advance_to_next_model();
828
829        // Verify the test setup is correct before calling switch_to_agent_named.
830        assert_eq!(chain.current_agent_index, 0, "setup: must be on agent 0");
831        assert_eq!(chain.current_model_index, 1, "setup: model index must be 1");
832        assert!(
833            chain.backoff_pending_ms.is_some(),
834            "setup: backoff_pending_ms must be Some"
835        );
836        assert_eq!(
837            chain.last_session_id,
838            Some("session-keep".to_string()),
839            "setup: session must be set"
840        );
841
842        // Act: switch to the same agent.
843        let next = chain.switch_to_agent_named("agent0");
844
845        assert_eq!(next.current_agent_index, 0, "must stay on agent 0");
846        assert_eq!(
847            next.current_model_index, 0,
848            "model index must reset to 0 on same-agent switch"
849        );
850        assert_eq!(
851            next.backoff_pending_ms, None,
852            "backoff_pending_ms must be cleared on same-agent switch"
853        );
854        assert_eq!(
855            next.last_session_id,
856            Some("session-keep".to_string()),
857            "session must be preserved when switching to the same agent"
858        );
859    }
860}
861
862#[cfg(test)]
863#[path = "transitions_model_fallback_cycling_tests.rs"]
864mod model_fallback_cycling_tests;
865
866#[cfg(test)]
867mod backoff_semantics_tests {
868    use super::*;
869
870    #[test]
871    fn test_switch_to_agent_named_preserves_backoff_when_retry_cycle_hits_max_but_state_is_not_exhausted(
872    ) {
873        let state = AgentChainState::initial()
874            .with_agents(
875                vec!["a".to_string(), "b".to_string(), "c".to_string()],
876                vec![vec![], vec![], vec![]],
877                AgentRole::Developer,
878            )
879            .with_max_cycles(2)
880            .with_retry_cycle(1)
881            .with_current_agent_index(2);
882
883        let next = state.switch_to_agent_named("b");
884
885        assert_eq!(next.current_agent_index, 1);
886        assert_eq!(next.retry_cycle, 2);
887        assert!(
888            next.backoff_pending_ms.is_some(),
889            "backoff should remain pending unless the state is fully exhausted"
890        );
891    }
892}