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