1use 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
15pub 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#[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 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 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 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 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 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 pub fn routable_slots(&self) -> impl Iterator<Item = RosterSlot> + '_ {
186 (0..self.active.len()).filter(|slot| self.routable(*slot))
187 }
188
189 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 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 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 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 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 if queued.is_none() {
361 return RelayDecision::Complete;
362 }
363 self.rounds = 0;
364 }
365 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 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 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 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 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 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 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 assert!(matches!(
792 relay.begin("", 0),
793 RelayDecision::Dispatch { slot: 0, .. }
794 ));
795 relay.finish(0, false, false);
796 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 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}