Skip to main content

codeswarm_adapters/
lib.rs

1//! Reusable CodeSwarm agent contracts and protocol adapters.
2//!
3//! Applications can use the normalized event vocabulary, deterministic relay,
4//! and ACP/native adapters without depending on CodeSwarm's terminal UI.
5
6use std::collections::VecDeque;
7use std::fs::{File, OpenOptions};
8use std::io::{BufRead, BufReader, BufWriter, Write};
9use std::path::{Path, PathBuf};
10use std::sync::mpsc::{self, Receiver, Sender};
11
12use serde::{Deserialize, Serialize};
13
14pub mod adapters;
15pub mod agents;
16pub mod collaboration;
17pub mod contract;
18pub mod details;
19pub mod goal;
20pub mod history;
21pub mod launcher;
22pub mod persistence;
23pub mod policy;
24pub mod relay;
25pub mod resources;
26pub mod settings;
27pub mod trace;
28pub use adapters::*;
29pub use relay::{Relay, RelayDecision};
30
31/// Stable roster position in the coordinator-managed agent list.
32pub type RosterSlot = usize;
33
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub struct Mode {
36    pub id: String,
37    pub label: String,
38}
39
40#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
41pub struct AgentCapabilities {
42    pub supports_cancel: bool,
43    pub supports_modes: bool,
44    pub supports_permissions: bool,
45    pub supports_terminals: bool,
46    pub supports_session_load: bool,
47    #[serde(default)]
48    pub supports_models: bool,
49}
50
51#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52pub struct ToolUpdate {
53    pub id: String,
54    pub title: String,
55    pub status: ToolStatus,
56    pub detail: Option<String>,
57}
58
59/// A slash command advertised by an ACP session.  The renderer intentionally
60/// keeps only the command name at the shared event boundary; descriptions and
61/// input hints are provider-specific presentation data and are not needed to
62/// dispatch or complete a command.
63#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
64pub struct AgentCommand {
65    pub name: String,
66}
67
68/// The latest context-window counters reported by an ACP session.
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
70pub struct UsageUpdate {
71    pub used: u64,
72    pub size: u64,
73}
74
75#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
76pub enum ToolStatus {
77    Pending,
78    Running,
79    Completed,
80    Failed,
81}
82
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct PermissionRequest {
85    pub id: String,
86    pub title: String,
87    pub options: Vec<String>,
88    /// Protocol identities aligned by index with `options`. Empty entries
89    /// fall back to the visible option label for legacy/native adapters.
90    #[serde(default)]
91    pub option_ids: Vec<String>,
92}
93
94/// The normalized answer to an adapter permission request. Native adapters
95/// may reject this explicitly when their protocol has no permission control.
96#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
97pub enum PermissionAnswer {
98    Selected { option_id: String },
99    Cancelled,
100}
101
102#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103pub enum TerminalEvent {
104    Created { id: String, command: String },
105    Output { id: String, text: String },
106    Exited { id: String, code: i32 },
107    Released { id: String },
108}
109
110#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
111pub enum RosterUpdate {
112    Added {
113        slot: RosterSlot,
114        name: String,
115        identity: String,
116    },
117    Reloaded {
118        slot: RosterSlot,
119    },
120    Dropped {
121        slot: RosterSlot,
122    },
123    Swapped {
124        first: RosterSlot,
125        second: RosterSlot,
126    },
127    Rejected {
128        action: String,
129        detail: String,
130    },
131}
132
133#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
134pub enum AgentEvent {
135    GoalUpdated {
136        goal: Option<goal::Goal>,
137    },
138    RosterUpdated {
139        update: RosterUpdate,
140    },
141    Ready {
142        slot: RosterSlot,
143        capabilities: AgentCapabilities,
144    },
145    TurnStarted {
146        slot: RosterSlot,
147    },
148    ModesReplaced {
149        slot: RosterSlot,
150        modes: Vec<Mode>,
151        current_mode: Option<String>,
152    },
153    ModeUpdated {
154        slot: RosterSlot,
155        current_mode: String,
156    },
157    ModelsReplaced {
158        slot: RosterSlot,
159        config_id: String,
160        models: Vec<Mode>,
161        current_model: Option<String>,
162    },
163    ModelUpdated {
164        slot: RosterSlot,
165        current_model: String,
166    },
167    UserText {
168        slot: RosterSlot,
169        text: String,
170    },
171    CommandsReplaced {
172        slot: RosterSlot,
173        commands: Vec<AgentCommand>,
174    },
175    UsageUpdated {
176        slot: RosterSlot,
177        usage: UsageUpdate,
178    },
179    Text {
180        slot: RosterSlot,
181        text: String,
182    },
183    Thought {
184        slot: RosterSlot,
185        text: String,
186    },
187    Tool {
188        slot: RosterSlot,
189        update: ToolUpdate,
190    },
191    Permission {
192        slot: RosterSlot,
193        request: PermissionRequest,
194    },
195    Terminal {
196        slot: RosterSlot,
197        event: TerminalEvent,
198    },
199    TurnComplete {
200        slot: RosterSlot,
201    },
202    /// A provider plan is exhausted for this slot. The relay routes around
203    /// the agent until it is recharged or reloaded.
204    UsageLimitReached {
205        slot: RosterSlot,
206        detail: String,
207    },
208    Failed {
209        slot: RosterSlot,
210        started: bool,
211        detail: String,
212    },
213}
214
215#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
216pub enum Effect {
217    Render,
218    DispatchPrompt { slot: RosterSlot, prompt: String },
219    OfferReload { slot: RosterSlot, crashed: bool },
220}
221
222#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
223pub struct AgentSlot {
224    pub active: bool,
225    pub capabilities: AgentCapabilities,
226    pub modes: Vec<Mode>,
227    pub current_mode: Option<String>,
228    #[serde(default)]
229    pub models: Vec<Mode>,
230    #[serde(default)]
231    pub current_model: Option<String>,
232    #[serde(default)]
233    pub commands: Vec<AgentCommand>,
234    #[serde(default)]
235    pub usage: Option<UsageUpdate>,
236}
237
238impl Default for AgentSlot {
239    fn default() -> Self {
240        Self {
241            active: true,
242            capabilities: AgentCapabilities::default(),
243            modes: Vec::new(),
244            current_mode: None,
245            models: Vec::new(),
246            current_model: None,
247            commands: Vec::new(),
248            usage: None,
249        }
250    }
251}
252
253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
254pub struct SessionState {
255    #[serde(default)]
256    pub goal: Option<goal::Goal>,
257    pub slots: Vec<AgentSlot>,
258    pub active_slot: Option<RosterSlot>,
259    pub queued_prompts: VecDeque<(RosterSlot, String)>,
260    pub public_text: Vec<(RosterSlot, String)>,
261}
262
263impl SessionState {
264    pub fn new(roster_size: usize) -> Self {
265        Self {
266            slots: (0..roster_size).map(|_| AgentSlot::default()).collect(),
267            active_slot: None,
268            queued_prompts: VecDeque::new(),
269            public_text: Vec::new(),
270            goal: None,
271        }
272    }
273}
274
275/// Apply one normalized event. I/O and rendering are represented by effects,
276/// never performed in the reducer.
277pub fn reduce(state: &mut SessionState, event: AgentEvent) -> Vec<Effect> {
278    match event {
279        AgentEvent::GoalUpdated { goal } => {
280            state.goal = goal;
281            vec![Effect::Render]
282        }
283        AgentEvent::RosterUpdated { .. } => vec![Effect::Render],
284        AgentEvent::Ready { slot, capabilities } => {
285            if let Some(agent) = state.slots.get_mut(slot) {
286                agent.capabilities = capabilities;
287            }
288            vec![Effect::Render]
289        }
290        AgentEvent::TurnStarted { slot } => {
291            state.active_slot = Some(slot);
292            vec![Effect::Render]
293        }
294        AgentEvent::ModesReplaced {
295            slot,
296            modes,
297            current_mode,
298        } => {
299            if let Some(agent) = state.slots.get_mut(slot) {
300                agent.modes = modes;
301                agent.current_mode =
302                    current_mode.filter(|id| agent.modes.iter().any(|mode| mode.id == *id));
303            }
304            vec![Effect::Render]
305        }
306        AgentEvent::CommandsReplaced { slot, commands } => {
307            if let Some(agent) = state.slots.get_mut(slot) {
308                agent.commands = commands;
309            }
310            vec![Effect::Render]
311        }
312        AgentEvent::ModeUpdated { slot, current_mode } => {
313            if let Some(agent) = state.slots.get_mut(slot) {
314                agent.current_mode = Some(current_mode);
315            }
316            vec![Effect::Render]
317        }
318        AgentEvent::ModelsReplaced {
319            slot,
320            models,
321            current_model,
322            ..
323        } => {
324            if let Some(agent) = state.slots.get_mut(slot) {
325                agent.models = models;
326                agent.current_model =
327                    current_model.filter(|id| agent.models.iter().any(|model| model.id == *id));
328            }
329            vec![Effect::Render]
330        }
331        AgentEvent::ModelUpdated {
332            slot,
333            current_model,
334        } => {
335            if let Some(agent) = state.slots.get_mut(slot) {
336                agent.current_model = Some(current_model);
337            }
338            vec![Effect::Render]
339        }
340        AgentEvent::UsageUpdated { slot, usage } => {
341            if let Some(agent) = state.slots.get_mut(slot) {
342                agent.usage = Some(usage);
343            }
344            vec![Effect::Render]
345        }
346        AgentEvent::Text { slot, text } => {
347            state.active_slot = Some(slot);
348            state.public_text.push((slot, text));
349            vec![Effect::Render]
350        }
351        AgentEvent::UserText { slot, .. } => {
352            state.active_slot = Some(slot);
353            vec![Effect::Render]
354        }
355        AgentEvent::Thought { slot, .. }
356        | AgentEvent::Tool { slot, .. }
357        | AgentEvent::Permission { slot, .. }
358        | AgentEvent::Terminal { slot, .. } => {
359            state.active_slot = Some(slot);
360            vec![Effect::Render]
361        }
362        AgentEvent::TurnComplete { .. } => {
363            state.active_slot = None;
364            let next =
365                state
366                    .queued_prompts
367                    .pop_front()
368                    .map(|(target, prompt)| Effect::DispatchPrompt {
369                        slot: target,
370                        prompt,
371                    });
372            let mut effects = vec![Effect::Render];
373            if let Some(effect) = next {
374                effects.push(effect);
375            }
376            effects
377        }
378        AgentEvent::UsageLimitReached { slot, .. } => {
379            if state.active_slot == Some(slot) {
380                state.active_slot = None;
381            }
382            vec![Effect::Render]
383        }
384        AgentEvent::Failed {
385            slot,
386            started,
387            detail: _,
388        } => {
389            if let Some(agent) = state.slots.get_mut(slot) {
390                agent.active = false;
391            }
392            if state.active_slot == Some(slot) {
393                state.active_slot = None;
394            }
395            vec![
396                Effect::Render,
397                Effect::OfferReload {
398                    slot,
399                    crashed: started,
400                },
401            ]
402        }
403    }
404}
405
406/// A newline-delimited event log. It deliberately records normalized events
407/// rather than UI operations, so sessions can be replayed by a future renderer
408/// or adapter host. Each append closes its file handle but does not force a
409/// storage sync; the terminal input/render loop must never block on fsync.
410#[derive(Clone, Debug)]
411pub struct EventLog {
412    path: PathBuf,
413}
414
415impl EventLog {
416    pub fn open(path: impl Into<PathBuf>) -> Self {
417        Self { path: path.into() }
418    }
419
420    pub fn path(&self) -> &Path {
421        &self.path
422    }
423
424    pub fn append(&self, event: &AgentEvent) -> std::io::Result<()> {
425        let encoded = serde_json::to_string(event)
426            .map_err(|error| std::io::Error::other(error.to_string()))?;
427        let mut file = OpenOptions::new()
428            .create(true)
429            .append(true)
430            .open(&self.path)?;
431        file.write_all(encoded.as_bytes())?;
432        file.write_all(b"\n")
433    }
434
435    /// Append an event and force it to stable storage.
436    ///
437    /// The regular [`append`](Self::append) path is deliberately lightweight
438    /// because it is called from the terminal event loop. Call this only at a
439    /// durability boundary such as a completed turn or explicit shutdown.
440    pub fn append_durable(&self, event: &AgentEvent) -> std::io::Result<()> {
441        let encoded = serde_json::to_string(event)
442            .map_err(|error| std::io::Error::other(error.to_string()))?;
443        let mut file = OpenOptions::new()
444            .create(true)
445            .append(true)
446            .open(&self.path)?;
447        file.write_all(encoded.as_bytes())?;
448        file.write_all(b"\n")?;
449        file.sync_data()
450    }
451
452    /// Force already-appended records to stable storage without writing a
453    /// duplicate event. This is useful for callers that batch lightweight
454    /// appends and checkpoint at turn boundaries.
455    pub fn sync(&self) -> std::io::Result<()> {
456        let file = OpenOptions::new().read(true).open(&self.path)?;
457        file.sync_data()
458    }
459
460    /// Start a background writer for event-loop use. The returned handle
461    /// queues records in memory and performs all file I/O on its worker
462    /// thread. Use [`BufferedEventLog::flush`] at explicit durability
463    /// boundaries such as completed turns.
464    pub fn buffered(&self) -> std::io::Result<BufferedEventLog> {
465        BufferedEventLog::open(self.path.clone())
466    }
467
468    pub fn read(&self) -> std::io::Result<Vec<AgentEvent>> {
469        let file = match File::open(&self.path) {
470            Ok(file) => file,
471            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
472            Err(error) => return Err(error),
473        };
474        BufReader::new(file)
475            .lines()
476            .enumerate()
477            .filter_map(|(line_number, result)| match result {
478                Ok(line) if line.trim().is_empty() => None,
479                Ok(line) => Some(serde_json::from_str(&line).map_err(|error| {
480                    std::io::Error::new(
481                        std::io::ErrorKind::InvalidData,
482                        format!("event log line {}: {error}", line_number + 1),
483                    )
484                })),
485                Err(error) => Some(Err(error)),
486            })
487            .collect()
488    }
489
490    pub fn replay(&self, roster_size: usize) -> std::io::Result<SessionState> {
491        let mut state = SessionState::new(roster_size);
492        for event in self.read()? {
493            reduce(&mut state, event);
494        }
495        Ok(state)
496    }
497}
498
499enum BufferedLogCommand {
500    Append(String),
501    Flush(Sender<std::io::Result<()>>),
502    Shutdown(Sender<std::io::Result<()>>),
503}
504
505/// Background event-log writer used to keep terminal input/render handling
506/// independent from filesystem latency. The channel is intentionally
507/// unbounded: dropping normalized events during a streamed turn would make
508/// replay and recovery incomplete, while the writer drains ordinary event
509/// rates faster than adapters produce them.
510pub struct BufferedEventLog {
511    sender: Sender<BufferedLogCommand>,
512    worker: Option<std::thread::JoinHandle<std::io::Result<()>>>,
513}
514
515impl std::fmt::Debug for BufferedEventLog {
516    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517        formatter
518            .debug_struct("BufferedEventLog")
519            .field("worker_running", &self.worker.is_some())
520            .finish_non_exhaustive()
521    }
522}
523
524impl BufferedEventLog {
525    fn open(path: PathBuf) -> std::io::Result<Self> {
526        let (sender, receiver) = mpsc::channel();
527        let worker = std::thread::Builder::new()
528            .name("codeswarm-event-log".into())
529            .spawn(move || buffered_log_worker(path, receiver))?;
530        Ok(Self {
531            sender,
532            worker: Some(worker),
533        })
534    }
535
536    /// Serialize and queue a normalized event without opening a file or
537    /// waiting on a filesystem operation in the caller.
538    pub fn append(&self, event: &AgentEvent) -> std::io::Result<()> {
539        let encoded = serde_json::to_string(event)
540            .map_err(|error| std::io::Error::other(error.to_string()))?;
541        self.sender
542            .send(BufferedLogCommand::Append(format!("{encoded}\n")))
543            .map_err(|_| {
544                std::io::Error::new(
545                    std::io::ErrorKind::BrokenPipe,
546                    "event log background writer stopped",
547                )
548            })
549    }
550
551    /// Drain queued records and force them to stable storage.
552    pub fn flush(&self) -> std::io::Result<()> {
553        let (reply, result) = mpsc::channel();
554        self.sender
555            .send(BufferedLogCommand::Flush(reply))
556            .map_err(|_| {
557                std::io::Error::new(
558                    std::io::ErrorKind::BrokenPipe,
559                    "event log background writer stopped",
560                )
561            })?;
562        result.recv().map_err(|_| {
563            std::io::Error::new(
564                std::io::ErrorKind::BrokenPipe,
565                "event log background writer stopped",
566            )
567        })?
568    }
569}
570
571impl Drop for BufferedEventLog {
572    fn drop(&mut self) {
573        let (reply, result) = mpsc::channel();
574        if self
575            .sender
576            .send(BufferedLogCommand::Shutdown(reply))
577            .is_ok()
578        {
579            let _ = result.recv();
580        }
581        if let Some(worker) = self.worker.take() {
582            let _ = worker.join();
583        }
584    }
585}
586
587fn buffered_log_worker(
588    path: PathBuf,
589    receiver: Receiver<BufferedLogCommand>,
590) -> std::io::Result<()> {
591    let file = OpenOptions::new().create(true).append(true).open(path)?;
592    let mut writer = BufWriter::new(file);
593    while let Ok(command) = receiver.recv() {
594        match command {
595            BufferedLogCommand::Append(line) => writer.write_all(line.as_bytes())?,
596            BufferedLogCommand::Flush(reply) => {
597                let result = writer.flush().and_then(|()| writer.get_ref().sync_data());
598                let _ = reply.send(result);
599            }
600            BufferedLogCommand::Shutdown(reply) => {
601                let result = writer.flush().and_then(|()| writer.get_ref().sync_data());
602                let worker_result = match result {
603                    Ok(()) => {
604                        let _ = reply.send(Ok(()));
605                        Ok(())
606                    }
607                    Err(error) => {
608                        let kind = error.kind();
609                        let detail = error.to_string();
610                        let _ = reply.send(Err(std::io::Error::new(kind, detail.clone())));
611                        Err(std::io::Error::new(kind, detail))
612                    }
613                };
614                return worker_result;
615            }
616        }
617    }
618    writer.flush()
619}
620
621#[cfg(test)]
622mod tests {
623    use std::time::{SystemTime, UNIX_EPOCH};
624
625    use super::{AgentCapabilities, AgentEvent, Effect, Mode, SessionState, reduce};
626
627    #[test]
628    fn replacement_catalog_invalidates_stale_mode() {
629        let mut state = SessionState::new(1);
630        reduce(
631            &mut state,
632            AgentEvent::ModesReplaced {
633                slot: 0,
634                modes: vec![Mode {
635                    id: "read".into(),
636                    label: "Read only".into(),
637                }],
638                current_mode: Some("write".into()),
639            },
640        );
641        assert_eq!(state.slots[0].current_mode, None);
642    }
643
644    #[test]
645    fn crash_tombstones_slot_and_uses_crash_copy() {
646        let mut state = SessionState::new(2);
647        reduce(
648            &mut state,
649            AgentEvent::Ready {
650                slot: 1,
651                capabilities: AgentCapabilities::default(),
652            },
653        );
654        let effects = reduce(
655            &mut state,
656            AgentEvent::Failed {
657                slot: 1,
658                started: true,
659                detail: "process exited".into(),
660            },
661        );
662        assert!(!state.slots[1].active);
663        assert!(effects.contains(&Effect::OfferReload {
664            slot: 1,
665            crashed: true,
666        }));
667    }
668
669    #[test]
670    fn event_log_replays_into_the_same_state() {
671        let unique = SystemTime::now()
672            .duration_since(UNIX_EPOCH)
673            .expect("clock")
674            .as_nanos();
675        let path = std::env::temp_dir().join(format!("codeswarm-core-{unique}.jsonl"));
676        let log = super::EventLog::open(&path);
677        let events = [
678            AgentEvent::Text {
679                slot: 0,
680                text: "first".into(),
681            },
682            AgentEvent::Failed {
683                slot: 1,
684                started: true,
685                detail: "crashed".into(),
686            },
687        ];
688        for event in &events {
689            log.append(event).expect("append");
690        }
691        let replayed = log.replay(2).expect("replay");
692        let mut expected = SessionState::new(2);
693        for event in events {
694            reduce(&mut expected, event);
695        }
696        assert_eq!(replayed, expected);
697        std::fs::remove_file(path).expect("cleanup");
698    }
699
700    #[test]
701    fn event_log_can_checkpoint_batched_appends() {
702        let unique = SystemTime::now()
703            .duration_since(UNIX_EPOCH)
704            .expect("clock")
705            .as_nanos();
706        let path = std::env::temp_dir().join(format!("codeswarm-core-checkpoint-{unique}.jsonl"));
707        let log = super::EventLog::open(&path);
708        log.append(&AgentEvent::Text {
709            slot: 0,
710            text: "batched".into(),
711        })
712        .expect("append");
713        // `sync` is an explicit checkpoint; it is separate from the hot-path
714        // append so render/input latency cannot inherit a storage flush.
715        log.sync().expect("checkpoint");
716        assert_eq!(log.read().expect("read").len(), 1);
717        std::fs::remove_file(path).expect("cleanup");
718    }
719
720    #[test]
721    fn event_log_durable_append_is_replayable() {
722        let unique = SystemTime::now()
723            .duration_since(UNIX_EPOCH)
724            .expect("clock")
725            .as_nanos();
726        let path = std::env::temp_dir().join(format!("codeswarm-core-durable-{unique}.jsonl"));
727        let log = super::EventLog::open(&path);
728        log.append_durable(&AgentEvent::Text {
729            slot: 1,
730            text: "durable".into(),
731        })
732        .expect("durable append");
733        assert_eq!(
734            log.read().expect("read")[0].clone(),
735            AgentEvent::Text {
736                slot: 1,
737                text: "durable".into(),
738            }
739        );
740        std::fs::remove_file(path).expect("cleanup");
741    }
742
743    #[test]
744    fn buffered_event_log_drains_and_checkpoints_in_order() {
745        let unique = SystemTime::now()
746            .duration_since(UNIX_EPOCH)
747            .expect("clock")
748            .as_nanos();
749        let path = std::env::temp_dir().join(format!("codeswarm-core-buffered-{unique}.jsonl"));
750        let log = super::EventLog::open(&path);
751        let buffered = log.buffered().expect("background writer");
752        for text in ["one", "two", "three"] {
753            buffered
754                .append(&AgentEvent::Text {
755                    slot: 0,
756                    text: text.into(),
757                })
758                .expect("queue event");
759        }
760        buffered.flush().expect("checkpoint");
761        assert_eq!(
762            log.read().expect("read").into_iter().collect::<Vec<_>>(),
763            [
764                AgentEvent::Text {
765                    slot: 0,
766                    text: "one".into()
767                },
768                AgentEvent::Text {
769                    slot: 0,
770                    text: "two".into()
771                },
772                AgentEvent::Text {
773                    slot: 0,
774                    text: "three".into()
775                }
776            ]
777        );
778        drop(buffered);
779        std::fs::remove_file(path).expect("cleanup");
780    }
781
782    #[test]
783    fn activity_marks_turn_active_until_completion() {
784        let mut state = SessionState::new(1);
785        reduce(
786            &mut state,
787            AgentEvent::Text {
788                slot: 0,
789                text: "stream".into(),
790            },
791        );
792        assert_eq!(state.active_slot, Some(0));
793        reduce(&mut state, AgentEvent::TurnComplete { slot: 0 });
794        assert_eq!(state.active_slot, None);
795    }
796}