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
280#[cfg(test)]
281mod tests {
282    use std::sync::Arc;
283
284    use zeph_mcp::McpManager;
285    use zeph_memory::TokenCounter;
286
287    use super::*;
288    use crate::config::{DiagnosticSeverity, LspConfig};
289
290    fn make_runner(enabled: bool) -> LspHookRunner {
291        let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
292        let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
293        LspHookRunner::new(
294            manager,
295            LspConfig {
296                enabled,
297                token_budget: 500,
298                ..LspConfig::default()
299            },
300        )
301    }
302
303    #[test]
304    fn drain_notes_empty() {
305        let mut runner = make_runner(true);
306        let tc = Arc::new(TokenCounter::default());
307        assert!(runner.drain_notes(&tc).is_none());
308    }
309
310    #[test]
311    fn drain_notes_formats_correctly() {
312        let tc = Arc::new(TokenCounter::default());
313        let mut runner = make_runner(true);
314        let tokens = tc.count_tokens("hello world");
315        runner.pending_notes.push(LspNote {
316            kind: "diagnostics",
317            content: "hello world".into(),
318            estimated_tokens: tokens,
319        });
320        let result = runner.drain_notes(&tc).unwrap();
321        assert!(result.starts_with("[lsp diagnostics]\nhello world"));
322    }
323
324    #[test]
325    fn drain_notes_budget_enforcement() {
326        let tc = Arc::new(TokenCounter::default());
327        let enforcer = zeph_mcp::PolicyEnforcer::new(vec![]);
328        let manager = Arc::new(McpManager::new(vec![], vec![], enforcer));
329        let mut runner = LspHookRunner::new(
330            manager,
331            LspConfig {
332                enabled: true,
333                token_budget: 1, // extremely tight budget
334                ..LspConfig::default()
335            },
336        );
337        runner.pending_notes.push(LspNote {
338            kind: "diagnostics",
339            content: "a very long diagnostic message that exceeds one token".into(),
340            estimated_tokens: 20,
341        });
342        let result = runner.drain_notes(&tc);
343        // Budget of 1 token cannot fit 20-token note → dropped, None returned
344        assert!(result.is_none());
345        assert_eq!(runner.stats.notes_dropped_budget, 1);
346    }
347
348    #[test]
349    fn lsp_config_defaults() {
350        let cfg = LspConfig::default();
351        assert!(!cfg.enabled);
352        assert_eq!(cfg.mcp_server_id, "mcpls");
353        assert_eq!(cfg.token_budget, 2000);
354        assert_eq!(cfg.call_timeout_secs, 5);
355        assert!(cfg.diagnostics.enabled);
356        assert!(!cfg.hover.enabled);
357        assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Error);
358    }
359
360    #[test]
361    fn lsp_config_toml_parse() {
362        let toml_str = r#"
363            enabled = true
364            mcp_server_id = "my-lsp"
365            token_budget = 3000
366
367            [diagnostics]
368            enabled = true
369            max_per_file = 10
370            min_severity = "warning"
371
372            [hover]
373            enabled = true
374            max_symbols = 5
375        "#;
376        let cfg: LspConfig = toml::from_str(toml_str).expect("parse LspConfig");
377        assert!(cfg.enabled);
378        assert_eq!(cfg.mcp_server_id, "my-lsp");
379        assert_eq!(cfg.token_budget, 3000);
380        assert_eq!(cfg.diagnostics.max_per_file, 10);
381        assert_eq!(cfg.diagnostics.min_severity, DiagnosticSeverity::Warning);
382        assert!(cfg.hover.enabled);
383        assert_eq!(cfg.hover.max_symbols, 5);
384    }
385
386    #[tokio::test]
387    async fn after_tool_disabled_does_not_queue_notes() {
388        use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
389        let tc = Arc::new(TokenCounter::default());
390        let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
391        let mut runner = make_runner(false); // lsp disabled
392
393        // Even write tool should produce no notes when disabled.
394        let params = serde_json::json!({ "path": "src/main.rs" });
395        runner
396            .after_tool("write", &params, "", &tc, &sanitizer)
397            .await;
398        // No background tasks spawned.
399        assert!(runner.diagnostics_rxs.is_empty());
400        assert!(runner.pending_notes.is_empty());
401    }
402
403    #[tokio::test]
404    async fn after_tool_unavailable_skips_on_write() {
405        use zeph_sanitizer::{ContentIsolationConfig, ContentSanitizer};
406        let tc = Arc::new(TokenCounter::default());
407        let sanitizer = ContentSanitizer::new(&ContentIsolationConfig::default());
408        // Runner enabled but no MCP server configured — is_available() returns false.
409        let mut runner = make_runner(true);
410        let params = serde_json::json!({ "path": "src/main.rs" });
411        runner
412            .after_tool("write", &params, "", &tc, &sanitizer)
413            .await;
414        // No background task spawned because server is not available.
415        assert!(runner.diagnostics_rxs.is_empty());
416    }
417
418    #[test]
419    fn collect_background_diagnostics_multiple_writes() {
420        use tokio::sync::mpsc;
421        let mut runner = make_runner(true);
422        let tc = Arc::new(TokenCounter::default());
423
424        // Simulate two background tasks completing immediately.
425        for i in 0..2u64 {
426            let (tx, rx) = mpsc::channel(1);
427            runner.diagnostics_rxs.push(rx);
428            let note = LspNote {
429                kind: "diagnostics",
430                content: format!("error {i}"),
431                estimated_tokens: 5,
432            };
433            tx.try_send(Some(note)).unwrap();
434        }
435
436        runner.collect_background_diagnostics();
437        // Both notes collected.
438        assert_eq!(runner.pending_notes.len(), 2);
439        assert_eq!(runner.stats.diagnostics_injected, 2);
440        assert!(runner.diagnostics_rxs.is_empty());
441
442        let result = runner.drain_notes(&tc).unwrap();
443        assert!(result.contains("error 0"));
444        assert!(result.contains("error 1"));
445    }
446}