1use 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 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 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 self.switch_to_next_agent()
47 }
48 }
49 _ => self.switch_to_next_agent(),
51 }
52 }
53
54 #[must_use]
59 pub fn switch_to_next_agent(&self) -> Self {
60 if self.current_agent_index + 1 < self.agents.len() {
61 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 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 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 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 #[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 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 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 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 last_session_id: None,
208 last_failure_reason: self.last_failure_reason.clone(),
209 }
210 } else {
211 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 last_session_id: None,
229 last_failure_reason: self.last_failure_reason.clone(),
230 }
231 }
232 }
233
234 #[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 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 #[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 #[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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let chain = chain.start_retry_cycle();
824 let chain = chain.with_session_id(Some("session-keep".to_string()));
826 let chain = chain.advance_to_next_model();
828
829 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 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}