Skip to main content

zeph_core/lsp_hooks/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! LSP context injection hooks.
5//!
6//! Hooks fire after native tool execution and accumulate [`LspNote`] entries.
7//! Before the next LLM call, [`LspHookRunner::drain_notes`] formats and
8//! injects all accumulated notes as a `Role::System` message, respecting the
9//! per-turn token budget.
10//!
11//! # Pruning interaction
12//! LSP notes are injected as `Role::System` messages (consistent with graph
13//! facts, recall, and code context). The tool-pair summarizer targets only
14//! `Role::User` / `Role::Assistant` pairs, so LSP notes are **never**
15//! accidentally summarized. Stale notes are cleared via internal Agent methods
16//! before injecting fresh ones each turn.
17
18mod diagnostics;
19mod hover;
20#[cfg(test)]
21mod test_helpers;
22
23use std::sync::Arc;
24
25use tokio::sync::mpsc;
26use zeph_mcp::McpManager;
27
28pub use crate::config::LspConfig;
29
30/// A single context note produced by an LSP hook.
31pub struct LspNote {
32    /// Human-readable label ("diagnostics", "hover").
33    pub kind: &'static str,
34    /// Formatted content, ready for injection into the message history.
35    pub content: String,
36    /// Accurate token count from [`zeph_memory::TokenCounter`].
37    pub estimated_tokens: usize,
38}
39
40/// Receives background diagnostics results from a spawned fetch task.
41type DiagnosticsRx = mpsc::Receiver<Option<LspNote>>;
42
43/// Accumulates LSP notes from hook firings and drains them before each LLM call.
44pub struct LspHookRunner {
45    pub(crate) manager: Arc<McpManager>,
46    pub(crate) config: LspConfig,
47    /// Notes collected during the current tool loop iteration.
48    pending_notes: Vec<LspNote>,
49    /// Channels receiving background diagnostics fetch results.
50    /// One receiver per spawned background task (one per `write` tool call in a batch).
51    /// Collected non-blocking on the next drain.
52    diagnostics_rxs: Vec<DiagnosticsRx>,
53    /// Sessions statistics.
54    pub(crate) stats: LspStats,
55}
56
57/// Session-level statistics for the `/lsp` TUI command.
58#[derive(Debug, Default, Clone)]
59pub struct LspStats {
60    pub diagnostics_injected: u64,
61    pub hover_injected: u64,
62    pub notes_dropped_budget: u64,
63}
64
65impl LspHookRunner {
66    /// Create a new runner. Token counting uses the provided `token_counter`.
67    #[must_use]
68    pub fn new(manager: Arc<McpManager>, config: LspConfig) -> Self {
69        Self {
70            manager,
71            config,
72            pending_notes: Vec::new(),
73            diagnostics_rxs: Vec::new(),
74            stats: LspStats::default(),
75        }
76    }
77
78    /// Returns a snapshot of the session statistics.
79    #[must_use]
80    pub fn stats(&self) -> &LspStats {
81        &self.stats
82    }
83
84    /// Returns true when the configured MCP server is present in the manager.
85    ///
86    /// Used by the `/lsp` command to show connectivity status. Not called in the
87    /// hot path; individual MCP call failures are logged at `debug` level and
88    /// silently ignored.
89    pub async fn is_available(&self) -> bool {
90        tracing::debug!("lsp_hooks: checking is_available");
91        let result = if let Ok(servers) = tokio::time::timeout(
92            std::time::Duration::from_secs(2),
93            self.manager.list_servers(),
94        )
95        .await
96        {
97            servers.contains(&self.config.mcp_server_id)
98        } else {
99            tracing::warn!("lsp_hooks: is_available check timed out after 2s");
100            false
101        };
102        tracing::debug!(available = result, "lsp_hooks: is_available check complete");
103        result
104    }
105
106    /// Called after a native tool completes.
107    ///
108    /// Spawns a background diagnostics fetch when the tool is `write`.
109    /// Queues a hover fetch result synchronously when the tool is `read`
110    /// and hover is enabled.
111    ///
112    /// Returns early without any MCP call if the configured server is not connected.
113    pub async fn after_tool(
114        &mut self,
115        tool_name: &str,
116        tool_params: &serde_json::Value,
117        tool_output: &str,
118        token_counter: &Arc<zeph_memory::TokenCounter>,
119        sanitizer: &zeph_sanitizer::ContentSanitizer,
120    ) {
121        if !self.config.enabled {
122            tracing::debug!(tool = tool_name, "LSP hook: skipped (disabled)");
123            return;
124        }
125        tracing::debug!(tool = tool_name, "LSP after_tool: checking availability");
126        let avail = self.is_available().await;
127        tracing::debug!(
128            tool = tool_name,
129            available = avail,
130            "LSP after_tool: availability checked"
131        );
132        if !avail {
133            tracing::debug!(tool = tool_name, "LSP hook: skipped (server unavailable)");
134            return;
135        }
136
137        match tool_name {
138            "write" if self.config.diagnostics.enabled => {
139                self.spawn_diagnostics_fetch(tool_params, token_counter, sanitizer);
140            }
141            "read" if self.config.hover.enabled => {
142                if let Some(note) =
143                    hover::fetch_hover(self, tool_params, tool_output, token_counter, sanitizer)
144                        .await
145                {
146                    self.stats.hover_injected += 1;
147                    self.pending_notes.push(note);
148                }
149            }
150            "write" => {
151                tracing::debug!(tool = tool_name, "LSP hook: skipped (diagnostics disabled)");
152            }
153            "read" => {
154                tracing::debug!(tool = tool_name, "LSP hook: skipped (hover disabled)");
155            }
156            _ => {}
157        }
158    }
159
160    /// Spawn a background task that waits for the LSP server to re-analyse the
161    /// written file, then fetches diagnostics via MCP.
162    ///
163    /// Results are collected by [`Self::collect_background_diagnostics`] on the
164    /// next [`Self::drain_notes`] call. This avoids any synchronous sleep in
165    /// the tool loop.
166    ///
167    /// Multiple writes in a single batch each produce an independent receiver,
168    /// all collected on the next drain.
169    fn spawn_diagnostics_fetch(
170        &mut self,
171        tool_params: &serde_json::Value,
172        token_counter: &Arc<zeph_memory::TokenCounter>,
173        sanitizer: &zeph_sanitizer::ContentSanitizer,
174    ) {
175        let Some(path) = tool_params
176            .get("path")
177            .and_then(|v| v.as_str())
178            .map(ToOwned::to_owned)
179        else {
180            tracing::debug!("LSP hook: skipped diagnostics fetch (missing path)");
181            return;
182        };
183
184        tracing::debug!(tool = "write", path = %path, "LSP hook: spawning diagnostics fetch");
185
186        let manager = Arc::clone(&self.manager);
187        let config = self.config.clone();
188        let tc = Arc::clone(token_counter);
189        let sanitizer = sanitizer.clone();
190
191        let (tx, rx) = mpsc::channel(1);
192        self.diagnostics_rxs.push(rx);
193
194        tokio::spawn(async move {
195            // Give the LSP server time to start re-analysing after the write.
196            // 200 ms is a lightweight heuristic; the diagnostic cache in mcpls
197            // will serve the most-recently-published set regardless.
198            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
199
200            let note =
201                diagnostics::fetch_diagnostics(manager.as_ref(), &config, &path, &tc, &sanitizer)
202                    .await;
203            // Ignore send errors: the receiver may have been dropped if the
204            // agent loop exited before the task finished.
205            let _ = tx.send(note).await;
206        });
207    }
208
209    /// Poll all background diagnostics channels (non-blocking).
210    ///
211    /// Receivers that are ready or disconnected are removed. Pending receivers
212    /// (still waiting for the LSP) are kept for the next drain cycle.
213    fn collect_background_diagnostics(&mut self) {
214        let mut still_pending = Vec::new();
215        for mut rx in self.diagnostics_rxs.drain(..) {
216            match rx.try_recv() {
217                Ok(Some(note)) => {
218                    self.stats.diagnostics_injected += 1;
219                    self.pending_notes.push(note);
220                }
221                Ok(None) | Err(mpsc::error::TryRecvError::Disconnected) => {
222                    // No diagnostics or task exited — drop receiver.
223                }
224                Err(mpsc::error::TryRecvError::Empty) => {
225                    // Not ready yet; keep for the next drain.
226                    still_pending.push(rx);
227                }
228            }
229        }
230        self.diagnostics_rxs = still_pending;
231    }
232
233    /// Drain all accumulated notes into a single formatted string, enforcing
234    /// the per-turn token budget.
235    ///
236    /// Returns `None` when there are no notes to inject.
237    #[must_use]
238    pub fn drain_notes(
239        &mut self,
240        token_counter: &Arc<zeph_memory::TokenCounter>,
241    ) -> Option<String> {
242        use std::fmt::Write as _;
243        self.collect_background_diagnostics();
244
245        if self.pending_notes.is_empty() {
246            return None;
247        }
248
249        let mut output = String::new();
250        let mut remaining = self.config.token_budget;
251
252        for note in self.pending_notes.drain(..) {
253            if note.estimated_tokens > remaining {
254                tracing::debug!(
255                    kind = note.kind,
256                    tokens = note.estimated_tokens,
257                    remaining,
258                    "LSP note dropped: token budget exceeded"
259                );
260                self.stats.notes_dropped_budget += 1;
261                continue;
262            }
263            remaining -= note.estimated_tokens;
264            if !output.is_empty() {
265                output.push('\n');
266            }
267            let _ = write!(output, "[lsp {}]\n{}", note.kind, note.content);
268        }
269
270        // Re-measure after formatting in case the note content changed.
271        if output.is_empty() {
272            None
273        } else {
274            let _ = token_counter; // already used during note construction
275            Some(output)
276        }
277    }
278
279    /// Push a note directly, bypassing MCP. Only compiled in test builds.
280    #[cfg(test)]
281    pub(crate) fn push_note(
282        &mut self,
283        kind: &'static str,
284        content: impl Into<String>,
285        estimated_tokens: usize,
286    ) {
287        self.pending_notes.push(LspNote {
288            kind,
289            content: content.into(),
290            estimated_tokens,
291        });
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use std::sync::Arc;
298
299    use zeph_mcp::McpManager;
300    use zeph_memory::TokenCounter;
301
302    use super::*;
303    use crate::config::{DiagnosticSeverity, LspConfig};
304
305    fn make_runner(enabled: bool) -> LspHookRunner {
306        let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
307        let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
308        LspHookRunner::new(
309            manager,
310            LspConfig {
311                enabled,
312                token_budget: 500,
313                ..LspConfig::default()
314            },
315        )
316    }
317
318    #[test]
319    fn drain_notes_empty() {
320        let mut runner = make_runner(true);
321        let tc = Arc::new(TokenCounter::default());
322        assert!(runner.drain_notes(&tc).is_none());
323    }
324
325    #[test]
326    fn drain_notes_formats_correctly() {
327        let tc = Arc::new(TokenCounter::default());
328        let mut runner = make_runner(true);
329        let tokens = tc.count_tokens("hello world");
330        runner.pending_notes.push(LspNote {
331            kind: "diagnostics",
332            content: "hello world".into(),
333            estimated_tokens: tokens,
334        });
335        let result = runner.drain_notes(&tc).unwrap();
336        assert!(result.starts_with("[lsp diagnostics]\nhello world"));
337    }
338
339    #[test]
340    fn drain_notes_budget_enforcement() {
341        let tc = Arc::new(TokenCounter::default());
342        let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
343        let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
344        let mut runner = LspHookRunner::new(
345            manager,
346            LspConfig {
347                enabled: true,
348                token_budget: 1, // extremely tight budget
349                ..LspConfig::default()
350            },
351        );
352        runner.pending_notes.push(LspNote {
353            kind: "diagnostics",
354            content: "a very long diagnostic message that exceeds one token".into(),
355            estimated_tokens: 20,
356        });
357        let result = runner.drain_notes(&tc);
358        // Budget of 1 token cannot fit 20-token note → dropped, None returned
359        assert!(result.is_none());
360        assert_eq!(runner.stats.notes_dropped_budget, 1);
361    }
362
363    #[test]
364    fn lsp_config_defaults() {
365        let cfg = LspConfig::default();
366        assert!(!cfg.enabled);
367        assert_eq!(cfg.mcp_server_id, "mcpls");
368        assert_eq!(cfg.token_budget, 2000);
369        assert_eq!(cfg.call_timeout_secs, 5);
370        assert!(cfg.diagnostics.enabled);
371        assert!(!cfg.hover.enabled);
372        assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Error);
373    }
374
375    #[test]
376    fn lsp_config_toml_parse() {
377        let toml_str = r#"
378            enabled = true
379            mcp_server_id = "my-lsp"
380            token_budget = 3000
381
382            [diagnostics]
383            enabled = true
384            max_per_file = 10
385            min_severity = "warning"
386
387            [hover]
388            enabled = true
389            max_symbols = 5
390        "#;
391        let cfg: LspConfig = toml::from_str(toml_str).expect("parse LspConfig");
392        assert!(cfg.enabled);
393        assert_eq!(cfg.mcp_server_id, "my-lsp");
394        assert_eq!(cfg.token_budget, 3000);
395        assert_eq!(cfg.diagnostics.max_per_file, 10);
396        assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Warning);
397        assert!(cfg.hover.enabled);
398        assert_eq!(cfg.hover.max_symbols, 5);
399    }
400
401    #[tokio::test]
402    async fn after_tool_disabled_does_not_queue_notes() {
403        use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
404        let tc = Arc::new(TokenCounter::default());
405        let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
406        let mut runner = make_runner(false); // lsp disabled
407
408        // Even write tool should produce no notes when disabled.
409        let params = serde_json::json!({ "path": "src/main.rs" });
410        runner
411            .after_tool("write", &params, "", &tc, &sanitizer)
412            .await;
413        // No background tasks spawned.
414        assert!(runner.diagnostics_rxs.is_empty());
415        assert!(runner.pending_notes.is_empty());
416    }
417
418    #[tokio::test]
419    async fn after_tool_unavailable_skips_on_write() {
420        use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
421        let tc = Arc::new(TokenCounter::default());
422        let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
423        // Runner enabled but no MCP server configured — is_available() returns false.
424        let mut runner = make_runner(true);
425        let params = serde_json::json!({ "path": "src/main.rs" });
426        runner
427            .after_tool("write", &params, "", &tc, &sanitizer)
428            .await;
429        // No background task spawned because server is not available.
430        assert!(runner.diagnostics_rxs.is_empty());
431    }
432
433    #[test]
434    fn collect_background_diagnostics_multiple_writes() {
435        use tokio::sync::mpsc;
436        let mut runner = make_runner(true);
437        let tc = Arc::new(TokenCounter::default());
438
439        // Simulate two background tasks completing immediately.
440        for i in 0..2u64 {
441            let (tx, rx) = mpsc::channel(1);
442            runner.diagnostics_rxs.push(rx);
443            let note = LspNote {
444                kind: "diagnostics",
445                content: format!("error {i}"),
446                estimated_tokens: 5,
447            };
448            tx.try_send(Some(note)).unwrap();
449        }
450
451        runner.collect_background_diagnostics();
452        // Both notes collected.
453        assert_eq!(runner.pending_notes.len(), 2);
454        assert_eq!(runner.stats.diagnostics_injected, 2);
455        assert!(runner.diagnostics_rxs.is_empty());
456
457        let result = runner.drain_notes(&tc).unwrap();
458        assert!(result.contains("error 0"));
459        assert!(result.contains("error 1"));
460    }
461}