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