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