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