Skip to main content

lattice_agent/log/
modes.rs

1//! AI agent modes (AI-1b).
2//!
3//! - `ai-log-mode` (major) -- the read-only buffer backing the
4//!   per-process `*ai:<provider>:<index>*` log view. Mirrors
5//!   `lattice_lsp::modes::LspServerLogMode`: `on_activate` derives
6//!   its [`SessionKey`](super::ai_log::SessionKey) identity by
7//!   parsing the buffer's synthetic name, seeds from the
8//!   [`AiLogger`](super::ai_log::AiLogger) ring, subscribes to
9//!   [`AiLogPushed`](super::ai_log::AiLogPushed), and spawns a
10//!   drain task that appends matching records live. The returned
11//!   `Subscription` guard unsubscribes on drop.
12//!
13//! The boot wiring that registers this mode, registers the
14//! [`AiLogger`](super::ai_log::AiLogger) *service* (there is no
15//! `Editor` field -- modes reach it through the service registry),
16//! wires the `AiLogPushed` publisher onto the runtime bus, and
17//! creates/opens the buffers lives in `lattice_ai::install`. This
18//! module only builds the mode itself, unit-testable without a
19//! booted app (every missing service / unparseable name short-
20//! circuits `on_activate` to `Ok(None)`).
21
22use lattice_mode::{
23    BufferStoreHandle, CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
24    OptionOverrideSet, Subscription,
25};
26
27/// `ai-log-mode` -- major mode for the per-session
28/// `*ai:<provider>:<index>*` buffer.
29pub struct AiLogMode;
30
31impl AiLogMode {
32    pub fn mode_id() -> ModeId {
33        ModeId::new("ai-log-mode")
34    }
35}
36
37impl Mode for AiLogMode {
38    type Guard = Option<Subscription>;
39    fn id(&self) -> ModeId {
40        Self::mode_id()
41    }
42    fn kind(&self) -> ModeKind {
43        ModeKind::Major
44    }
45    fn options(&self) -> OptionOverrideSet {
46        lattice_config::overrides! {
47            lattice_config::ReadOnly = true,
48            lattice_config::NoFile = true,
49        }
50    }
51    fn required_capabilities(&self) -> CapabilitySet {
52        CapabilitySet::empty()
53    }
54
55    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
56        Box::pin(async move {
57            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
58            let Some(store) = ctx.service::<BufferStoreHandle>() else {
59                return Ok(None);
60            };
61            let Some(name) = store.name_for(buffer_id) else {
62                return Ok(None);
63            };
64            let Some(key) = super::buffer_names::parse_ai_log_name(&name) else {
65                return Ok(None);
66            };
67            let Some(handle) = store.handle_for(buffer_id) else {
68                return Ok(None);
69            };
70            let Ok(runtime) = tokio::runtime::Handle::try_current() else {
71                return Ok(None);
72            };
73
74            // Seed the buffer from the per-session ring so
75            // pre-existing records are visible immediately. No
76            // trace-filtering distinction -- one buffer per
77            // session carries every record for that session.
78            if let Some(logger) = ctx.service::<super::ai_log::AiLogger>() {
79                let snap = logger.snapshot_session(&key);
80                let mut text = String::new();
81                for record in snap.iter() {
82                    let line = super::ai_log::format_ai_log_line(
83                        record.timestamp,
84                        Some(&key),
85                        super::ai_log::level_tag(record.level),
86                        record.source.tag(),
87                        &record.message,
88                    );
89                    text.push_str(&line);
90                    text.push('\n');
91                }
92                if !text.is_empty() {
93                    let snapshot = handle.snapshot();
94                    let last_line = // CV.3: ROPE space — the append point is the very end of
95                    // the buffer, past the terminating newline.
96                    snapshot.buffer.rope_line_count().saturating_sub(1);
97                    let line_text = snapshot.buffer.line(last_line).unwrap_or_default();
98                    let pos = lattice_protocol::position::Position::new(
99                        last_line,
100                        line_text.len() as u32,
101                    );
102                    let edit = lattice_protocol::edit::Edit::insert(pos, text);
103                    let handle_seed = handle.clone();
104                    runtime.spawn(async move {
105                        let _ = handle_seed.apply_edit_batch(vec![edit]).await;
106                    });
107                }
108            }
109
110            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<super::ai_log::AiLogPushed>();
111            let sub_id = ctx
112                .events()
113                .subscribe_typed::<super::ai_log::AiLogPushed>(tx);
114            let bus_handle = ctx.events_handle();
115
116            let filter_key = key.clone();
117            runtime.spawn(async move {
118                while let Some(first) = rx.recv().await {
119                    let mut batch: Vec<super::ai_log::AiLogPushed> = vec![first];
120                    while let Ok(more) = rx.try_recv() {
121                        batch.push(more);
122                    }
123                    let mut text = String::new();
124                    for event in batch
125                        .iter()
126                        .filter(|e| e.session.as_ref() == Some(&filter_key))
127                    {
128                        let line = super::ai_log::format_ai_log_line(
129                            event.timestamp,
130                            Some(&filter_key),
131                            &event.level,
132                            &event.source,
133                            &event.message,
134                        );
135                        text.push_str(&line);
136                        text.push('\n');
137                    }
138                    if text.is_empty() {
139                        continue;
140                    }
141                    let snap = handle.snapshot();
142                    let last_line = // CV.3: ROPE space — append point, past the terminating
143                    // newline.
144                    snap.buffer.rope_line_count().saturating_sub(1);
145                    let line_text = snap.buffer.line(last_line).unwrap_or_default();
146                    let pos = lattice_protocol::position::Position::new(
147                        last_line,
148                        line_text.len() as u32,
149                    );
150                    let edit = lattice_protocol::edit::Edit::insert(pos, text);
151                    let _ = handle.apply_edit_batch(vec![edit]).await;
152                }
153            });
154
155            Ok(Some(Subscription::new(bus_handle, sub_id)))
156        })
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn mode_id_is_ai_log_mode() {
166        assert_eq!(AiLogMode::mode_id(), ModeId::new("ai-log-mode"));
167    }
168
169    #[test]
170    fn options_are_read_only_and_no_file() {
171        let overrides = AiLogMode.options();
172        let has_true = |type_id: std::any::TypeId| {
173            overrides.iter().any(|ov| {
174                ov.option_type_id == type_id && ov.downcast_value::<bool>() == Some(&true)
175            })
176        };
177        assert!(
178            has_true(std::any::TypeId::of::<lattice_config::ReadOnly>()),
179            "expected ReadOnly = true override"
180        );
181        assert!(
182            has_true(std::any::TypeId::of::<lattice_config::NoFile>()),
183            "expected NoFile = true override"
184        );
185    }
186
187    #[test]
188    fn kind_is_major_with_no_capability_requirements() {
189        assert_eq!(AiLogMode.kind(), ModeKind::Major);
190        assert_eq!(AiLogMode.required_capabilities(), CapabilitySet::empty());
191    }
192}