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