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