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