Skip to main content

codeswarm_adapters/
relay.rs

1//! Deterministic sequential roster scheduling.
2//!
3//! This owns turn selection only. Prompt construction and adapter I/O remain
4//! outside the scheduler, making the relay safe to replay and test.
5
6use std::collections::VecDeque;
7
8use crate::RosterSlot;
9use crate::collaboration::CollaborationContext;
10
11pub const MAX_QUEUED_PROMPTS: usize = 100;
12pub const STOP_TOKEN: &str = "[CODESWARM:STOP]";
13pub const DEFAULT_STOP_ACKNOWLEDGMENT: &str = "👍";
14
15/// Recognize a provider usage-limit reply (for example an exhausted Codex
16/// plan). Kept deliberately narrow so ordinary conversation mentioning
17/// "limits" is never misclassified.
18pub fn is_usage_limit_response(text: &str) -> bool {
19    let haystack = text.to_lowercase();
20    let exhausted_usage = haystack.contains("usage limit")
21        && ["hit", "reached", "exceeded"]
22            .iter()
23            .any(|marker| haystack.contains(marker));
24    exhausted_usage
25        || haystack.contains("insufficient_quota")
26        || haystack.contains("quota exceeded")
27        || haystack.contains("insufficient credits")
28}
29
30pub fn strip_stop_token(response: &str) -> (String, bool) {
31    let trimmed = response.trim_end();
32    let requested = trimmed.ends_with(STOP_TOKEN);
33    let visible = if requested {
34        trimmed[..trimmed.len() - STOP_TOKEN.len()]
35            .trim_end()
36            .to_owned()
37    } else {
38        response.to_owned()
39    };
40    (visible, requested)
41}
42
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum QueuedKind {
45    Steering,
46    Direct,
47}
48
49/// How a multi-agent session chooses its next non-direct recipient.
50///
51/// `Roster` is the normal sequential ring. `Pair` keeps the first two active
52/// agents in a tight review loop, which is useful when a
53/// larger saved roster is available but the user wants focused two-agent
54/// collaboration. `Manual` never advances on its own after the first turn;
55/// every subsequent turn must be explicitly targeted or queued by the user.
56#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
57pub enum CollaborationStrategy {
58    #[default]
59    Roster,
60    Manual,
61    Pair,
62}
63
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct QueuedPrompt {
66    pub slot: RosterSlot,
67    pub prompt: String,
68    pub kind: QueuedKind,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
72pub enum RelayDecision {
73    Dispatch {
74        slot: RosterSlot,
75        prompt: String,
76        direct: bool,
77        can_stop: bool,
78    },
79    Paused,
80    Collapsed,
81    Complete,
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct Relay {
86    active: Vec<bool>,
87    /// Slots whose provider plan is exhausted. Unlike a tombstone this is
88    /// expected to clear (recharge or reload), so queued prompts targeting a
89    /// limited slot are preserved instead of discarded.
90    limited: Vec<bool>,
91    max_rounds: usize,
92    rounds: usize,
93    stopped: bool,
94    paused: bool,
95    last_active: RosterSlot,
96    next: Option<RosterSlot>,
97    steering: VecDeque<QueuedPrompt>,
98    direct: VecDeque<QueuedPrompt>,
99    previous_slot: Option<RosterSlot>,
100    participated: Vec<bool>,
101    context: CollaborationContext,
102    strategy: CollaborationStrategy,
103    pair_partner: Option<RosterSlot>,
104}
105
106impl Relay {
107    pub fn new(roster_size: usize, max_rounds: usize) -> Self {
108        assert!(roster_size >= 1);
109        assert!(max_rounds >= 1);
110        Self {
111            active: vec![true; roster_size],
112            limited: vec![false; roster_size],
113            max_rounds,
114            rounds: 0,
115            stopped: false,
116            paused: false,
117            last_active: 0,
118            next: None,
119            steering: VecDeque::new(),
120            direct: VecDeque::new(),
121            previous_slot: None,
122            participated: vec![false; roster_size],
123            context: CollaborationContext::new(roster_size),
124            strategy: CollaborationStrategy::Roster,
125            pair_partner: None,
126        }
127    }
128
129    pub fn strategy(&self) -> CollaborationStrategy {
130        self.strategy
131    }
132
133    /// Change routing for future turns. This does not discard queued work or
134    /// alter the public context journal; it only affects the next automatic
135    /// recipient. Pair selection is re-derived from the next `first` value.
136    pub fn set_strategy(&mut self, strategy: CollaborationStrategy) {
137        if self.strategy != strategy {
138            self.strategy = strategy;
139            self.pair_partner = None;
140        }
141    }
142
143    pub fn active_slots(&self) -> impl Iterator<Item = RosterSlot> + '_ {
144        self.active
145            .iter()
146            .enumerate()
147            .filter_map(|(slot, active)| active.then_some(slot))
148    }
149
150    pub fn pause(&mut self) {
151        self.paused = true;
152    }
153
154    pub fn resume(&mut self) {
155        self.paused = false;
156    }
157
158    pub fn tombstone(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
159        let active = self.active.get_mut(slot).ok_or("slot out of range")?;
160        *active = false;
161        Ok(())
162    }
163
164    /// Flag a slot whose provider plan is exhausted. Routing skips it but its
165    /// queued prompts and roster identity are preserved for a later recharge.
166    pub fn mark_limited(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
167        let limited = self.limited.get_mut(slot).ok_or("slot out of range")?;
168        *limited = true;
169        Ok(())
170    }
171
172    /// Clear a usage-limit flag after a recharge or adapter reload.
173    pub fn clear_limited(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
174        let limited = self.limited.get_mut(slot).ok_or("slot out of range")?;
175        *limited = false;
176        Ok(())
177    }
178
179    pub fn is_limited(&self, slot: RosterSlot) -> bool {
180        self.limited.get(slot).copied().unwrap_or(false)
181    }
182
183    /// A slot is routable when it is active and not usage-limited.
184    fn routable(&self, slot: RosterSlot) -> bool {
185        self.active.get(slot).copied().unwrap_or(false)
186            && !self.limited.get(slot).copied().unwrap_or(false)
187    }
188
189    /// Slots that can currently receive a turn.
190    pub fn routable_slots(&self) -> impl Iterator<Item = RosterSlot> + '_ {
191        (0..self.active.len()).filter(|slot| self.routable(*slot))
192    }
193
194    /// Whether any slot other than `excluded` can still receive a turn.
195    fn any_routable_except(&self, excluded: RosterSlot) -> bool {
196        self.routable_slots().any(|slot| slot != excluded)
197    }
198
199    pub fn reactivate(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
200        let active = self.active.get_mut(slot).ok_or("slot out of range")?;
201        *active = true;
202        self.participated[slot] = false;
203        self.context.rewind(slot);
204        Ok(())
205    }
206
207    pub fn drop_agent(&mut self, slot: RosterSlot) -> Result<(), &'static str> {
208        if !self.active.get(slot).copied().ok_or("slot out of range")? {
209            return Ok(());
210        }
211        if self.active_slots().count() == 1 {
212            return Err("last active agent cannot be dropped");
213        }
214        self.tombstone(slot)?;
215        self.direct.retain(|queued| queued.slot != slot);
216        self.steering.retain(|queued| queued.slot != slot);
217        Ok(())
218    }
219
220    /// Exchange two live roster slots while preserving adapter identity in
221    /// queued work, routing cursors, and per-agent context watermarks.
222    pub fn swap_agents(
223        &mut self,
224        first: RosterSlot,
225        second: RosterSlot,
226    ) -> Result<(), &'static str> {
227        if first == second {
228            return Ok(());
229        }
230        if first >= self.active.len() || second >= self.active.len() {
231            return Err("roster slot out of range");
232        }
233        if !self.active[first] || !self.active[second] {
234            return Err("both roster slots must be active");
235        }
236        fn swap_targets(queue: &mut VecDeque<QueuedPrompt>, first: usize, second: usize) {
237            for queued in queue {
238                if queued.slot == first {
239                    queued.slot = second;
240                } else if queued.slot == second {
241                    queued.slot = first;
242                }
243            }
244        }
245        swap_targets(&mut self.direct, first, second);
246        swap_targets(&mut self.steering, first, second);
247        if self.last_active == first {
248            self.last_active = second;
249        } else if self.last_active == second {
250            self.last_active = first;
251        }
252        fn swap_option(cursor: &mut Option<usize>, first: usize, second: usize) {
253            if *cursor == Some(first) {
254                *cursor = Some(second);
255            } else if *cursor == Some(second) {
256                *cursor = Some(first);
257            }
258        }
259        swap_option(&mut self.next, first, second);
260        swap_option(&mut self.previous_slot, first, second);
261        swap_option(&mut self.pair_partner, first, second);
262        self.active.swap(first, second);
263        self.limited.swap(first, second);
264        self.participated.swap(first, second);
265        self.context.swap_agents(first, second);
266        Ok(())
267    }
268
269    pub fn enqueue_human(
270        &mut self,
271        prompt: impl Into<String>,
272        selected: Option<RosterSlot>,
273    ) -> bool {
274        let prompt = prompt.into();
275        if prompt.trim().is_empty() || self.queued_count() >= MAX_QUEUED_PROMPTS {
276            return false;
277        }
278        let slot = selected.unwrap_or(self.last_active);
279        if !self.active.get(slot).copied().unwrap_or(false) {
280            return false;
281        }
282        self.steering.push_back(QueuedPrompt {
283            slot,
284            prompt,
285            kind: QueuedKind::Steering,
286        });
287        true
288    }
289
290    pub fn enqueue_direct(
291        &mut self,
292        slot: RosterSlot,
293        prompt: impl Into<String>,
294    ) -> Result<bool, &'static str> {
295        let prompt = prompt.into();
296        if !self.active.get(slot).copied().unwrap_or(false) {
297            return Err("direct target is not active");
298        }
299        if prompt.trim().is_empty() || self.queued_count() >= MAX_QUEUED_PROMPTS {
300            return Ok(false);
301        }
302        self.direct.push_back(QueuedPrompt {
303            slot,
304            prompt,
305            kind: QueuedKind::Direct,
306        });
307        Ok(true)
308    }
309
310    pub fn queued_count(&self) -> usize {
311        self.direct.len() + self.steering.len()
312    }
313
314    pub fn set_shared_task(&mut self, task: impl Into<String>) {
315        self.context.set_shared_task(task);
316    }
317
318    pub fn shared_task(&self) -> Option<&str> {
319        self.context.shared_task()
320    }
321
322    pub fn record_public(&mut self, speaker: impl Into<String>, text: impl Into<String>) {
323        self.context.record(speaker, text, &self.active);
324    }
325
326    pub fn mark_context_seen(&mut self, slot: RosterSlot) {
327        self.context.mark_seen(slot);
328    }
329
330    pub fn unseen_context(&mut self, slot: RosterSlot) -> String {
331        self.context.unseen(slot)
332    }
333
334    pub fn add_agent(&mut self) {
335        self.active.push(true);
336        self.limited.push(false);
337        self.participated.push(false);
338        self.context.add_agent();
339    }
340
341    /// Select the next causal turn. Direct work always precedes steering work.
342    pub fn begin(&mut self, initial_prompt: impl Into<String>, first: RosterSlot) -> RelayDecision {
343        if self.paused {
344            return RelayDecision::Paused;
345        }
346        if self.active_slots().next().is_none() {
347            return RelayDecision::Collapsed;
348        }
349        // Every live slot may be usage-limited; never spin the batch against
350        // an exhausted plan. The queued work survives for the next begin().
351        if !self.any_routable_except(usize::MAX) {
352            self.stopped = true;
353            return RelayDecision::Paused;
354        }
355        let queued = Self::pop_routable(&self.active, &self.limited, &mut self.direct)
356            .or_else(|| Self::pop_routable(&self.active, &self.limited, &mut self.steering));
357        // A reviewer stop ends only the current automatic batch. A later
358        // queued/user prompt starts a fresh batch without rebuilding the
359        // relay, while an unprompted handoff remains complete.
360        if self.stopped {
361            if queued.is_none() {
362                return RelayDecision::Complete;
363            }
364            self.stopped = false;
365            self.rounds = 0;
366        }
367        if self.rounds >= self.max_rounds {
368            // A queued human/direct prompt is a new batch; do not strand it
369            // behind the safety limit reached by the previous batch.
370            if queued.is_none() {
371                return RelayDecision::Complete;
372            }
373            self.rounds = 0;
374        }
375        // Manual mode is deliberately input-driven. A queued prompt (which
376        // includes a newly submitted human prompt) is still dispatched, but
377        // an unprompted call after a completed turn must not silently hand the
378        // conversation to another agent.
379        if self.strategy == CollaborationStrategy::Manual
380            && queued.is_none()
381            && self.previous_slot.is_some()
382        {
383            return RelayDecision::Complete;
384        }
385        let (slot, prompt, direct, human_prompt) = match queued {
386            Some(queued) => (
387                queued.slot,
388                queued.prompt,
389                queued.kind == QueuedKind::Direct,
390                queued.kind == QueuedKind::Steering,
391            ),
392            None => {
393                let slot = self.next_automatic_slot(first);
394                (slot, initial_prompt.into(), false, false)
395            }
396        };
397        // A human steering prompt starts a fresh review batch. Even when it
398        // targets a different slot from the preceding relay turn, that first
399        // responder must not be allowed to terminate the batch with the safe
400        // word. Only an automatic handoff after another agent's response is
401        // eligible to review-stop.
402        if human_prompt {
403            self.participated.fill(false);
404        }
405        let roster_reviewed = self.strategy != CollaborationStrategy::Roster
406            || self.active_slots().all(|candidate| {
407                candidate == slot || !self.routable(candidate) || self.participated[candidate]
408            });
409        let can_stop = !direct
410            && !human_prompt
411            && roster_reviewed
412            && self.previous_slot.is_some_and(|previous| previous != slot);
413        self.last_active = slot;
414        self.rounds += 1;
415        RelayDecision::Dispatch {
416            slot,
417            prompt,
418            direct,
419            can_stop,
420        }
421    }
422
423    /// Finalize a dispatched turn and choose the next ring position. Direct
424    /// turns never become shared relay context.
425    pub fn finish(&mut self, slot: RosterSlot, direct: bool, accepted_stop: bool) {
426        self.next = Some(self.next_active(slot));
427        if !direct {
428            self.previous_slot = Some(slot);
429            self.participated[slot] = true;
430        }
431        if accepted_stop && self.direct.is_empty() && self.steering.is_empty() {
432            self.stopped = true;
433        }
434    }
435
436    /// Pop the first queued prompt whose target is routable. Prompts aimed at
437    /// a limited slot stay queued until the slot recovers.
438    fn pop_routable(
439        active: &[bool],
440        limited: &[bool],
441        queue: &mut VecDeque<QueuedPrompt>,
442    ) -> Option<QueuedPrompt> {
443        let position = queue.iter().position(|queued| {
444            active.get(queued.slot).copied().unwrap_or(false)
445                && !limited.get(queued.slot).copied().unwrap_or(false)
446        })?;
447        queue.remove(position)
448    }
449
450    fn first_active_from(&self, start: RosterSlot) -> RosterSlot {
451        (0..self.active.len())
452            .map(|offset| (start + offset) % self.active.len())
453            .find(|slot| self.routable(*slot))
454            .expect("callers require a routable roster")
455    }
456
457    fn next_active(&self, slot: RosterSlot) -> RosterSlot {
458        (1..=self.active.len())
459            .map(|offset| (slot + offset) % self.active.len())
460            .find(|candidate| self.routable(*candidate))
461            .expect("callers require a routable roster")
462    }
463
464    fn next_automatic_slot(&mut self, first: RosterSlot) -> RosterSlot {
465        match self.strategy {
466            CollaborationStrategy::Roster | CollaborationStrategy::Manual => self
467                .next
468                .filter(|slot| self.routable(*slot))
469                .unwrap_or_else(|| self.first_active_from(first)),
470            CollaborationStrategy::Pair => {
471                let primary = self.first_active_from(0);
472                let partner = if let Some(partner) = self.pair_partner {
473                    if self.routable(partner) && partner != primary {
474                        partner
475                    } else {
476                        let partner = self.next_active(primary);
477                        self.pair_partner = Some(partner);
478                        partner
479                    }
480                } else {
481                    let partner = if first != primary && self.routable(first) {
482                        first
483                    } else {
484                        self.next_active(primary)
485                    };
486                    self.pair_partner = Some(partner);
487                    partner
488                };
489                match self.previous_slot {
490                    Some(previous) if previous == primary && self.routable(partner) => partner,
491                    Some(previous) if previous == partner && self.routable(primary) => primary,
492                    Some(previous) if previous == primary || previous == partner => primary,
493                    _ => self.first_active_from(first),
494                }
495            }
496        }
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::{CollaborationStrategy, Relay, RelayDecision, STOP_TOKEN, strip_stop_token};
503
504    #[test]
505    fn relay_moves_around_the_ring_without_self_review() {
506        let mut relay = Relay::new(3, 10);
507        let first = relay.begin("task", 0);
508        assert!(matches!(
509            first,
510            RelayDecision::Dispatch {
511                slot: 0,
512                can_stop: false,
513                ..
514            }
515        ));
516        relay.finish(0, false, false);
517        let second = relay.begin("response", 0);
518        assert!(matches!(
519            second,
520            RelayDecision::Dispatch {
521                slot: 1,
522                can_stop: false,
523                ..
524            }
525        ));
526    }
527
528    #[test]
529    fn roster_stop_tracks_replacement_missing_and_reordered_agents() {
530        let mut relay = Relay::new(4, 100);
531        relay.begin("task", 0);
532        relay.finish(0, false, false);
533        relay.swap_agents(0, 2).unwrap();
534        assert_eq!(relay.participated, [false, false, true, false]);
535        assert!(relay.reactivate(99).is_err());
536        relay.tombstone(3).unwrap();
537        relay.mark_limited(0).unwrap();
538        assert!(matches!(
539            relay.begin("", 0),
540            RelayDecision::Dispatch {
541                slot: 1,
542                can_stop: true,
543                ..
544            }
545        ));
546        relay.finish(1, false, false);
547        relay.reactivate(3).unwrap();
548        relay.clear_limited(0).unwrap();
549        assert!(matches!(
550            relay.begin("", 0),
551            RelayDecision::Dispatch {
552                slot: 2,
553                can_stop: false,
554                ..
555            }
556        ));
557        relay.finish(2, false, false);
558        assert!(relay.enqueue_human("new task", Some(1)));
559        relay.begin("", 0);
560        assert!(relay.participated.iter().all(|seen| !seen));
561    }
562
563    #[test]
564    fn explicit_human_target_beats_ring_order() {
565        let mut relay = Relay::new(3, 10);
566        relay.begin("task", 0);
567        assert!(relay.enqueue_human("correction", Some(2)));
568        relay.finish(0, false, false);
569        assert!(matches!(
570            relay.begin("response", 0),
571            RelayDecision::Dispatch { slot: 2, prompt, direct: false, can_stop: false } if prompt == "correction"
572        ));
573    }
574
575    #[test]
576    fn human_prompt_cannot_stop_even_after_a_previous_relay_batch() {
577        let mut relay = Relay::new(2, 10);
578        assert!(matches!(
579            relay.begin("first task", 0),
580            RelayDecision::Dispatch {
581                slot: 0,
582                can_stop: false,
583                ..
584            }
585        ));
586        relay.finish(0, false, false);
587        assert!(matches!(
588            relay.begin("review", 0),
589            RelayDecision::Dispatch {
590                slot: 1,
591                can_stop: true,
592                ..
593            }
594        ));
595        relay.finish(1, false, false);
596
597        // The next user prompt targets the first agent, which differs from the
598        // previous reviewer. It is still the first response to a human turn,
599        // so it must not receive reviewer stop permission.
600        assert!(relay.enqueue_human("new task", Some(0)));
601        assert!(matches!(
602            relay.begin("", 0),
603            RelayDecision::Dispatch {
604                slot: 0,
605                can_stop: false,
606                ..
607            }
608        ));
609    }
610
611    #[test]
612    fn direct_work_has_priority_and_any_agent_except_the_last_can_be_dropped() {
613        let mut relay = Relay::new(3, 10);
614        relay.enqueue_human("ordinary", Some(1));
615        assert_eq!(relay.enqueue_direct(2, "private"), Ok(true));
616        assert!(matches!(
617            relay.begin("task", 0),
618            RelayDecision::Dispatch {
619                slot: 2,
620                direct: true,
621                ..
622            }
623        ));
624        assert_eq!(relay.drop_agent(0), Ok(()));
625        assert_eq!(relay.active_slots().collect::<Vec<_>>(), vec![1, 2]);
626        assert_eq!(relay.drop_agent(1), Ok(()));
627        assert_eq!(
628            relay.drop_agent(2),
629            Err("last active agent cannot be dropped")
630        );
631    }
632
633    #[test]
634    fn relay_context_tracks_public_updates_per_slot() {
635        let mut relay = Relay::new(2, 10);
636        relay.set_shared_task("refactor");
637        relay.record_public("Agent 0", "first answer");
638        relay.mark_context_seen(0);
639        assert_eq!(relay.unseen_context(0), "");
640        assert_eq!(relay.unseen_context(1), "Agent 0:\nfirst answer");
641        assert_eq!(relay.shared_task(), Some("refactor"));
642        relay.add_agent();
643        assert_eq!(relay.active_slots().count(), 3);
644    }
645
646    #[test]
647    fn stop_token_is_stripped_only_from_the_response_suffix() {
648        let (visible, requested) = strip_stop_token(&format!("looks good\n{STOP_TOKEN}"));
649        assert_eq!(visible, "looks good");
650        assert!(requested);
651        let (visible, requested) = strip_stop_token("ordinary response");
652        assert_eq!(visible, "ordinary response");
653        assert!(!requested);
654    }
655
656    #[test]
657    fn accepted_stop_ends_the_batch_but_a_new_prompt_can_start_one() {
658        let mut relay = Relay::new(2, 10);
659        assert!(matches!(
660            relay.begin("task", 0),
661            RelayDecision::Dispatch { slot: 0, .. }
662        ));
663        relay.finish(0, false, false);
664        assert!(matches!(
665            relay.begin("review", 0),
666            RelayDecision::Dispatch {
667                slot: 1,
668                can_stop: true,
669                ..
670            }
671        ));
672        relay.finish(1, false, true);
673        assert_eq!(relay.begin("", 0), RelayDecision::Complete);
674
675        assert!(relay.enqueue_human("new task", Some(0)));
676        assert!(matches!(
677            relay.begin("", 0),
678            RelayDecision::Dispatch { slot: 0, prompt, .. } if prompt == "new task"
679        ));
680    }
681
682    #[test]
683    fn live_slot_swap_follows_queued_targets_and_runtime_cursors() {
684        let mut relay = Relay::new(3, 10);
685        relay.record_public("Agent 0", "first work");
686        relay.mark_context_seen(0);
687        assert!(matches!(
688            relay.begin("task", 0),
689            RelayDecision::Dispatch { slot: 0, .. }
690        ));
691        relay.finish(0, false, false);
692        relay.enqueue_human("to first", Some(0));
693        relay.enqueue_direct(2, "to third").expect("queue direct");
694
695        relay.swap_agents(0, 2).expect("swap live slots");
696        assert_eq!(relay.active_slots().collect::<Vec<_>>(), vec![0, 1, 2]);
697        assert!(matches!(
698            relay.begin("", 0),
699            RelayDecision::Dispatch { slot: 0, direct: true, prompt, .. }
700                if prompt == "to third"
701        ));
702        relay.finish(0, true, false);
703        assert!(matches!(
704            relay.begin("", 0),
705            RelayDecision::Dispatch { slot: 2, direct: false, prompt, .. }
706                if prompt == "to first"
707        ));
708        assert_eq!(relay.unseen_context(0), "Agent 0:\nfirst work");
709    }
710
711    #[test]
712    fn stop_token_is_not_allowed_on_the_first_response() {
713        let mut relay = Relay::new(2, 10);
714        assert!(matches!(
715            relay.begin("task", 0),
716            RelayDecision::Dispatch {
717                slot: 0,
718                can_stop: false,
719                ..
720            }
721        ));
722        // RelayHost validates the token against `can_stop`; the first turn
723        // therefore finalizes as a normal response even if the agent tried
724        // to include the token.
725        relay.finish(0, false, false);
726        assert!(matches!(
727            relay.begin("", 0),
728            RelayDecision::Dispatch { slot: 1, .. }
729        ));
730    }
731
732    #[test]
733    fn a_healthy_peer_continues_after_the_other_slot_is_tombstoned() {
734        let mut relay = Relay::new(2, 10);
735        relay.tombstone(0).expect("first agent failure");
736        assert!(matches!(
737            relay.begin("continue", 0),
738            RelayDecision::Dispatch {
739                slot: 1,
740                can_stop: false,
741                ..
742            }
743        ));
744    }
745
746    #[test]
747    fn manual_strategy_requires_an_explicit_follow_up_prompt() {
748        let mut relay = Relay::new(3, 10);
749        relay.set_strategy(CollaborationStrategy::Manual);
750        assert!(matches!(
751            relay.begin("task", 0),
752            RelayDecision::Dispatch { slot: 0, .. }
753        ));
754        relay.finish(0, false, false);
755        assert_eq!(
756            relay.begin("would auto advance", 0),
757            RelayDecision::Complete
758        );
759        assert!(relay.enqueue_human("review", Some(2)));
760        assert!(
761            matches!(relay.begin("", 0), RelayDecision::Dispatch { slot: 2, prompt, .. } if prompt == "review")
762        );
763    }
764
765    #[test]
766    fn pair_strategy_alternates_the_first_two_active_agents() {
767        let mut relay = Relay::new(4, 10);
768        relay.set_strategy(CollaborationStrategy::Pair);
769        assert!(matches!(
770            relay.begin("task", 2),
771            RelayDecision::Dispatch { slot: 2, .. }
772        ));
773        relay.finish(2, false, false);
774        assert!(matches!(
775            relay.begin("review", 2),
776            RelayDecision::Dispatch { slot: 0, .. }
777        ));
778        relay.finish(0, false, false);
779        assert!(matches!(
780            relay.begin("next", 2),
781            RelayDecision::Dispatch { slot: 2, .. }
782        ));
783
784        relay.drop_agent(0).expect("remove first agent");
785        relay.finish(2, false, false);
786        assert!(matches!(
787            relay.begin("after removal", 2),
788            RelayDecision::Dispatch { slot: 1, .. }
789        ));
790    }
791
792    #[test]
793    fn usage_limit_detection_matches_provider_copy() {
794        assert!(super::is_usage_limit_response(
795            "You've hit your usage limit. Visit chatgpt.com to purchase more credits."
796        ));
797        assert!(super::is_usage_limit_response("Error: insufficient_quota"));
798        assert!(super::is_usage_limit_response(
799            "Monthly quota exceeded for this plan"
800        ));
801        assert!(!super::is_usage_limit_response(
802            "The rate limit on the build job slowed things down."
803        ));
804        assert!(!super::is_usage_limit_response(
805            "I updated the usage-limit documentation and billing upgrade flow."
806        ));
807        assert!(!super::is_usage_limit_response("Ready to review the diff."));
808    }
809
810    #[test]
811    fn hot_added_and_swapped_slots_keep_limit_state_with_the_agent() {
812        let mut relay = Relay::new(2, 10);
813        relay.add_agent();
814        relay.mark_limited(2).expect("mark hot-added slot limited");
815        assert!(relay.is_limited(2));
816
817        relay.swap_agents(0, 2).expect("swap limited agent");
818        assert!(relay.is_limited(0));
819        assert!(!relay.is_limited(2));
820        assert!(matches!(
821            relay.begin("task", 0),
822            RelayDecision::Dispatch { slot: 1, .. }
823        ));
824    }
825
826    #[test]
827    fn a_limited_slot_is_routed_around_until_cleared() {
828        let mut relay = Relay::new(2, 10);
829        relay.mark_limited(0).expect("mark limited");
830        assert!(relay.is_limited(0));
831        assert!(matches!(
832            relay.begin("task", 0),
833            RelayDecision::Dispatch {
834                slot: 1,
835                can_stop: false,
836                ..
837            }
838        ));
839        relay.finish(1, false, false);
840        // The ring still skips the limited slot after a full loop.
841        assert!(matches!(
842            relay.begin("again", 1),
843            RelayDecision::Dispatch { slot: 1, .. }
844        ));
845        relay.clear_limited(0).expect("clear limited");
846        assert!(!relay.is_limited(0));
847        relay.finish(1, false, false);
848        assert!(matches!(
849            relay.begin("next", 1),
850            RelayDecision::Dispatch { slot: 0, .. }
851        ));
852    }
853
854    #[test]
855    fn prompts_targeting_a_limited_slot_wait_for_recovery() {
856        let mut relay = Relay::new(2, 10);
857        relay.mark_limited(1).expect("mark limited");
858        assert_eq!(relay.enqueue_direct(1, "private work"), Ok(true));
859        assert!(matches!(
860            relay.begin("task", 0),
861            RelayDecision::Dispatch { slot: 0, .. }
862        ));
863        relay.finish(0, false, false);
864        // The direct prompt is not dropped while slot 1 is limited...
865        assert!(matches!(
866            relay.begin("", 0),
867            RelayDecision::Dispatch { slot: 0, .. }
868        ));
869        relay.finish(0, false, false);
870        // ...and dispatches once the slot recovers.
871        relay.clear_limited(1).expect("clear limited");
872        assert!(matches!(
873            relay.begin("", 0),
874            RelayDecision::Dispatch { slot: 1, direct: true, prompt, .. } if prompt == "private work"
875        ));
876    }
877
878    #[test]
879    fn an_all_limited_roster_pauses_instead_of_spinning() {
880        let mut relay = Relay::new(2, 10);
881        relay.mark_limited(0).expect("mark limited");
882        relay.mark_limited(1).expect("mark limited");
883        assert_eq!(relay.begin("task", 0), RelayDecision::Paused);
884        // Queued work is preserved for the next begin().
885        assert!(relay.enqueue_human("queued", Some(1)));
886        relay.clear_limited(1).expect("recharge one agent");
887        assert!(matches!(
888            relay.begin("", 0),
889            RelayDecision::Dispatch { slot: 1, prompt, .. } if prompt == "queued"
890        ));
891    }
892}