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