M5: LSP pool and diagnostics wiring

Adds the DiagnosticsSource seam trait in harness-core::lsp (kept out of harness-tools so tools never link the LSP crate directly), and implements it in harness-lsp as a pool that lazily spawns one language server per file extension (rust-analyzer, typescript-language-server, gopls, pyright-langserver) only when the binary is on PATH. Adds harness-tools::diagnostics and wires diagnostics into edit/write/bash/glob/grep output.
This commit is contained in:
2026-07-09 07:25:14 +02:00
parent e32f61e4f8
commit f71a347061
19 changed files with 762 additions and 2 deletions
Generated
+5
View File
@@ -711,7 +711,12 @@ dependencies = [
name = "harness-lsp"
version = "0.1.0"
dependencies = [
"async-trait",
"harness-core",
"serde_json",
"tempfile",
"tokio",
"tracing",
]
[[package]]
+21
View File
@@ -16,6 +16,7 @@ use harness_core::config::{self, Config, ConfigError};
use harness_core::engine::jobs::{JobBoard, JobState, LaunchSpec};
use harness_core::engine::{run_session, RunConfig, StepContext};
use harness_core::event::{AppEvent, EventBus, RunOutcome};
use harness_core::lsp::DiagnosticsSource;
use harness_core::permission::{PermissionReply, PermissionService, Rule, Ruleset};
use harness_core::store::{Store, StoreError};
use harness_core::tool::{
@@ -90,6 +91,9 @@ struct EngineInner {
providers: ProviderRegistry,
catalog: ModelCatalog,
agents: AgentRegistry,
/// LSP diagnostics pool (edit/write surface errors). Shared across sessions; servers spawn
/// lazily on first touch.
diagnostics: Option<Arc<dyn DiagnosticsSource>>,
cwd: PathBuf,
data_dir: PathBuf,
runs: Mutex<HashMap<SessionId, RunHandle>>,
@@ -196,6 +200,20 @@ impl EngineHandle {
// `init` warms the cache in the background for the next launch.
let catalog = ModelCatalog::load_cached_or_baked(&ModelCatalog::default_cache_path());
// LSP pool: built-ins present on PATH, plus any config-declared servers.
let lsp_servers: Vec<harness_lsp::ServerConfig> = config
.lsp
.iter()
.map(|(name, c)| harness_lsp::ServerConfig {
name: name.clone(),
command: c.command.clone(),
args: c.args.clone(),
extensions: c.extensions.clone(),
})
.collect();
let diagnostics: Option<Arc<dyn DiagnosticsSource>> =
Some(Arc::new(harness_lsp::LspPool::new(cwd.clone(), lsp_servers)));
let inner = Arc::new(EngineInner {
config,
store,
@@ -205,6 +223,7 @@ impl EngineHandle {
providers,
catalog,
agents,
diagnostics,
cwd,
data_dir,
runs: Mutex::new(HashMap::new()),
@@ -328,6 +347,7 @@ impl EngineHandle {
spawner: Some(self.inner.clone()),
job_board: Some(board),
context_reporter: None, // root session has no parent board to report to
diagnostics: self.inner.diagnostics.clone(),
};
let run_config = RunConfig {
agent_name: agent.name.clone(),
@@ -633,6 +653,7 @@ impl EngineInner {
spawner: Some(self.arc()),
job_board: Some(board),
context_reporter: reporter,
diagnostics: self.diagnostics.clone(),
}
}
}
+1
View File
@@ -434,6 +434,7 @@ mod tests {
spawner: None,
job_board: None,
context_reporter: None,
diagnostics: None,
}
}
@@ -72,6 +72,8 @@ pub struct StepContext {
/// Present in subagent sessions: reports read files to this session's job on the parent
/// board. `None` for root sessions (nothing to report to).
pub context_reporter: Option<Arc<dyn ContextReporter>>,
/// Language-server diagnostics source shared by the session's edit/write tool calls.
pub diagnostics: Option<Arc<dyn crate::lsp::DiagnosticsSource>>,
}
struct FlushTracker {
@@ -522,6 +524,7 @@ impl<'a> Run<'a> {
metadata: metadata_sink,
spawner: self.ctx.spawner.clone(),
context_reporter: self.ctx.context_reporter.clone(),
diagnostics: self.ctx.diagnostics.clone(),
};
let result = tokio::select! {
+1
View File
@@ -3,6 +3,7 @@ pub mod config;
pub mod engine;
pub mod event;
pub mod llm;
pub mod lsp;
pub mod permission;
pub mod store;
pub mod tool;
+46
View File
@@ -0,0 +1,46 @@
//! Seam trait for language-server diagnostics, implemented by `harness-lsp` and consumed by
//! the edit/write tools. Kept in core so `harness-tools` never links the LSP crate directly.
//! See `docs/09-integrations.md`.
use std::path::Path;
use std::time::Duration;
use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
Hint,
}
/// One diagnostic reported by a language server. Line/character are 1-based for display.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
pub line: u32,
pub character: u32,
pub severity: Severity,
pub message: String,
pub source: Option<String>,
}
impl Diagnostic {
/// `{file}: L{line}: {message}` — the one-line form appended to tool output.
pub fn display_line(&self, file: &str) -> String {
format!("{file}: L{}: {}", self.line, self.message)
}
}
/// A source of file diagnostics (an LSP pool). All methods are best-effort: failures are
/// swallowed (logged) so a broken language server never fails a tool call.
#[async_trait]
pub trait DiagnosticsSource: Send + Sync {
/// Ensure a server for `path`'s language is running and told about the file's current
/// contents (spawn-if-needed + didOpen/didChange).
async fn touch(&self, path: &Path);
/// Diagnostics for `path`, waiting up to `wait` for the server to (re)publish after a
/// change. Returns whatever is known on timeout.
async fn diagnostics(&self, path: &Path, wait: Duration) -> Vec<Diagnostic>;
}
+3
View File
@@ -195,6 +195,9 @@ pub struct ToolCtx {
pub spawner: Option<Arc<dyn SubagentSpawner>>,
/// Present in subagent sessions: lets the read tool report files to the job board.
pub context_reporter: Option<Arc<dyn ContextReporter>>,
/// Language-server diagnostics source (edit/write surface errors after a change). `None`
/// disables LSP integration.
pub diagnostics: Option<Arc<dyn crate::lsp::DiagnosticsSource>>,
}
#[derive(Debug)]
+7
View File
@@ -6,6 +6,13 @@ license.workspace = true
[dependencies]
harness-core = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
[lints]
workspace = true
+325
View File
@@ -0,0 +1,325 @@
//! Minimal JSON-RPC client over an LSP server's child stdio: `Content-Length` framing, a
//! request-id → oneshot map, and a diagnostics store keyed by document URI. Only the handful
//! of methods the edit/write flow needs are implemented (docs/09-integrations.md).
use std::collections::HashMap;
use std::path::Path;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use harness_core::lsp::{Diagnostic, Severity};
use serde_json::{json, Value};
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{oneshot, Notify};
type Pending = Arc<Mutex<HashMap<i64, oneshot::Sender<Value>>>>;
type DiagStore = Arc<Mutex<HashMap<String, Vec<Diagnostic>>>>;
/// A running language server plus the state needed to talk to it.
pub struct LspClient {
outgoing: tokio::sync::mpsc::UnboundedSender<String>,
next_id: AtomicI64,
pending: Pending,
diagnostics: DiagStore,
diag_notify: Arc<Notify>,
// Kept alive so the child is killed on drop (`kill_on_drop`).
_child: Child,
}
impl LspClient {
/// Spawns `command args`, performs the `initialize`/`initialized` handshake rooted at
/// `root`, and returns a ready client.
pub async fn spawn(command: &str, args: &[String], root: &Path) -> std::io::Result<Self> {
let mut child = Command::new(command)
.args(args)
.current_dir(root)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()?;
let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
let (outgoing, mut out_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let pending: Pending = Arc::default();
let diagnostics: DiagStore = Arc::default();
let diag_notify = Arc::new(Notify::new());
// Writer task: frame and forward outgoing payloads.
tokio::spawn(async move {
let mut stdin = stdin;
while let Some(payload) = out_rx.recv().await {
let frame = format!("Content-Length: {}\r\n\r\n{}", payload.len(), payload);
if stdin.write_all(frame.as_bytes()).await.is_err() {
break;
}
let _ = stdin.flush().await;
}
});
// Reader task: parse frames, route responses, collect diagnostics, ack server requests.
{
let pending = pending.clone();
let diagnostics = diagnostics.clone();
let diag_notify = diag_notify.clone();
let outgoing_ack = outgoing.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout);
while let Some(msg) = read_message(&mut reader).await {
dispatch(msg, &pending, &diagnostics, &diag_notify, &outgoing_ack);
}
});
}
let client = Self {
outgoing,
next_id: AtomicI64::new(1),
pending,
diagnostics,
diag_notify,
_child: child,
};
client.initialize(root).await?;
Ok(client)
}
async fn initialize(&self, root: &Path) -> std::io::Result<()> {
let root_uri = path_to_uri(root);
let params = json!({
"processId": std::process::id(),
"rootUri": root_uri,
"workspaceFolders": [{ "uri": root_uri, "name": "root" }],
"capabilities": {
"textDocument": {
"publishDiagnostics": { "relatedInformation": false }
}
},
"clientInfo": { "name": "ai-harness" }
});
// A slow server (rust-analyzer indexing) can take a while to answer initialize.
let _ = self
.request("initialize", params, Duration::from_secs(30))
.await;
self.notify("initialized", json!({}));
Ok(())
}
fn send(&self, payload: Value) {
if let Ok(text) = serde_json::to_string(&payload) {
let _ = self.outgoing.send(text);
}
}
pub fn notify(&self, method: &str, params: Value) {
self.send(json!({ "jsonrpc": "2.0", "method": method, "params": params }));
}
async fn request(&self, method: &str, params: Value, timeout: Duration) -> Option<Value> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
self.pending.lock().unwrap().insert(id, tx);
self.send(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }));
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(value)) => Some(value),
_ => {
self.pending.lock().unwrap().remove(&id);
None
}
}
}
pub fn did_open(&self, path: &Path, language_id: &str, version: i32, text: &str) {
self.notify(
"textDocument/didOpen",
json!({
"textDocument": {
"uri": path_to_uri(path),
"languageId": language_id,
"version": version,
"text": text,
}
}),
);
}
pub fn did_change(&self, path: &Path, version: i32, text: &str) {
self.notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": path_to_uri(path), "version": version },
"contentChanges": [{ "text": text }],
}),
);
}
/// Clears the stored diagnostics for `path` so the next `wait_diagnostics` observes a fresh
/// publish rather than a stale one.
pub fn clear(&self, path: &Path) {
self.diagnostics.lock().unwrap().remove(&path_to_uri(path));
}
/// Waits up to `wait` for the server to publish diagnostics for `path`, returning whatever
/// is stored on timeout (possibly empty).
pub async fn wait_diagnostics(&self, path: &Path, wait: Duration) -> Vec<Diagnostic> {
let uri = path_to_uri(path);
let deadline = tokio::time::Instant::now() + wait;
loop {
if let Some(diags) = self.diagnostics.lock().unwrap().get(&uri) {
return diags.clone();
}
let notified = self.diag_notify.notified();
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
break;
}
if tokio::time::timeout(remaining, notified).await.is_err() {
break;
}
}
self.diagnostics
.lock()
.unwrap()
.get(&uri)
.cloned()
.unwrap_or_default()
}
}
fn dispatch(
msg: Value,
pending: &Pending,
diagnostics: &DiagStore,
diag_notify: &Arc<Notify>,
outgoing: &tokio::sync::mpsc::UnboundedSender<String>,
) {
let method = msg.get("method").and_then(|m| m.as_str());
let id = msg.get("id");
match (method, id) {
// Server → client request: ack with a null result so the server can proceed
// (e.g. client/registerCapability, window/workDoneProgress/create).
(Some(_), Some(id)) => {
let reply = json!({ "jsonrpc": "2.0", "id": id, "result": Value::Null });
if let Ok(text) = serde_json::to_string(&reply) {
let _ = outgoing.send(text);
}
}
// Notification from the server.
(Some("textDocument/publishDiagnostics"), None) => {
if let Some(params) = msg.get("params") {
if let Some((uri, diags)) = parse_diagnostics(params) {
diagnostics.lock().unwrap().insert(uri, diags);
diag_notify.notify_waiters();
}
}
}
(Some(_), None) => {}
// Response to one of our requests.
(None, Some(id)) => {
if let Some(id) = id.as_i64() {
if let Some(tx) = pending.lock().unwrap().remove(&id) {
let result = msg.get("result").cloned().unwrap_or(Value::Null);
let _ = tx.send(result);
}
}
}
(None, None) => {}
}
}
fn parse_diagnostics(params: &Value) -> Option<(String, Vec<Diagnostic>)> {
let uri = params.get("uri")?.as_str()?.to_string();
let items = params.get("diagnostics")?.as_array()?;
let diags = items
.iter()
.filter_map(|d| {
let start = d.get("range")?.get("start")?;
Some(Diagnostic {
line: start.get("line")?.as_u64().unwrap_or(0) as u32 + 1,
character: start.get("character")?.as_u64().unwrap_or(0) as u32 + 1,
severity: match d.get("severity").and_then(|s| s.as_u64()) {
Some(1) => Severity::Error,
Some(2) => Severity::Warning,
Some(3) => Severity::Info,
_ => Severity::Hint,
},
message: d.get("message")?.as_str().unwrap_or("").to_string(),
source: d
.get("source")
.and_then(|s| s.as_str())
.map(|s| s.to_string()),
})
})
.collect();
Some((uri, diags))
}
/// Reads one `Content-Length`-framed JSON-RPC message, or `None` at EOF.
async fn read_message<R: tokio::io::AsyncBufRead + Unpin>(reader: &mut R) -> Option<Value> {
use tokio::io::AsyncBufReadExt;
let mut content_length: Option<usize> = None;
loop {
let mut line = String::new();
let n = reader.read_line(&mut line).await.ok()?;
if n == 0 {
return None; // EOF
}
let trimmed = line.trim_end();
if trimmed.is_empty() {
break; // end of headers
}
if let Some(value) = trimmed.strip_prefix("Content-Length:") {
content_length = value.trim().parse().ok();
}
}
let len = content_length?;
let mut buf = vec![0u8; len];
reader.read_exact(&mut buf).await.ok()?;
serde_json::from_slice(&buf).ok()
}
/// `file://` URI for an absolute path (best-effort; assumes UTF-8, no percent-encoding needed
/// for the local paths we handle).
fn path_to_uri(path: &Path) -> String {
let s = path.to_string_lossy();
if s.starts_with('/') {
format!("file://{s}")
} else {
format!("file:///{s}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_publish_diagnostics_into_one_based_positions() {
let params = json!({
"uri": "file:///tmp/a.rs",
"diagnostics": [{
"range": {"start": {"line": 4, "character": 8}, "end": {"line": 4, "character": 12}},
"severity": 1,
"message": "cannot find value `x`",
"source": "rustc"
}]
});
let (uri, diags) = parse_diagnostics(&params).unwrap();
assert_eq!(uri, "file:///tmp/a.rs");
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].line, 5); // 0-based 4 → 1-based 5
assert_eq!(diags[0].character, 9);
assert_eq!(diags[0].severity, Severity::Error);
assert_eq!(diags[0].source.as_deref(), Some("rustc"));
}
#[test]
fn path_to_uri_prefixes_file_scheme() {
assert_eq!(path_to_uri(Path::new("/tmp/a.rs")), "file:///tmp/a.rs");
}
}
+238 -1
View File
@@ -1 +1,238 @@
// LSP client pool + diagnostics service land here in M5.
//! LSP diagnostics pool: maps a file extension to a language server, lazily spawns one server
//! per language, and answers the core `DiagnosticsSource` seam. Built-in servers are only
//! offered when their binary is on `PATH`; config can add or override them.
//! See `docs/09-integrations.md`.
mod client;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use harness_core::lsp::{Diagnostic, DiagnosticsSource};
use client::LspClient;
/// One language server: which binary to run and which extensions it handles.
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub extensions: Vec<String>,
}
/// Built-in servers, tried only when their binary exists on `PATH`.
fn builtin_servers() -> Vec<ServerConfig> {
vec![
ServerConfig {
name: "rust".into(),
command: "rust-analyzer".into(),
args: vec![],
extensions: vec!["rs".into()],
},
ServerConfig {
name: "typescript".into(),
command: "typescript-language-server".into(),
args: vec!["--stdio".into()],
extensions: vec!["ts".into(), "tsx".into(), "js".into(), "jsx".into()],
},
ServerConfig {
name: "go".into(),
command: "gopls".into(),
args: vec![],
extensions: vec!["go".into()],
},
ServerConfig {
name: "python".into(),
command: "pyright-langserver".into(),
args: vec!["--stdio".into()],
extensions: vec!["py".into()],
},
]
}
/// LSP `languageId` for a file extension (falls back to the extension itself).
fn language_id_for_ext(ext: &str) -> &str {
match ext {
"rs" => "rust",
"ts" => "typescript",
"tsx" => "typescriptreact",
"js" => "javascript",
"jsx" => "javascriptreact",
"go" => "go",
"py" => "python",
other => other,
}
}
/// Returns true if `command` is an absolute existing file or resolvable on `PATH`.
fn binary_exists(command: &str) -> bool {
let p = Path::new(command);
if p.is_absolute() {
return p.is_file();
}
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| dir.join(command).is_file())
}
enum Slot {
Ready(Arc<LspClient>),
Failed,
}
pub struct LspPool {
root: PathBuf,
servers: Vec<ServerConfig>,
/// One slot per server name; absent until first spawn attempt.
clients: tokio::sync::Mutex<HashMap<String, Slot>>,
/// Open documents → last-sent version, so `didChange` bumps monotonically.
open: Mutex<HashMap<PathBuf, i32>>,
}
impl LspPool {
/// Builds a pool: available built-ins plus any `config` servers (which override built-ins
/// by name; an empty command disables a built-in).
pub fn new(root: PathBuf, config: Vec<ServerConfig>) -> Self {
let mut by_name: HashMap<String, ServerConfig> = HashMap::new();
for server in builtin_servers() {
if binary_exists(&server.command) {
by_name.insert(server.name.clone(), server);
}
}
for server in config {
if server.command.is_empty() {
by_name.remove(&server.name);
} else {
by_name.insert(server.name.clone(), server);
}
}
Self {
root,
servers: by_name.into_values().collect(),
clients: tokio::sync::Mutex::new(HashMap::new()),
open: Mutex::new(HashMap::new()),
}
}
fn server_for(&self, path: &Path) -> Option<&ServerConfig> {
let ext = path.extension().and_then(|e| e.to_str())?;
self.servers
.iter()
.find(|s| s.extensions.iter().any(|e| e == ext))
}
/// Gets or lazily spawns the client for `server`. Caches a failure so we don't respawn a
/// broken server on every edit.
async fn client_for(&self, server: &ServerConfig) -> Option<Arc<LspClient>> {
let mut clients = self.clients.lock().await;
match clients.get(&server.name) {
Some(Slot::Ready(c)) => return Some(c.clone()),
Some(Slot::Failed) => return None,
None => {}
}
match LspClient::spawn(&server.command, &server.args, &self.root).await {
Ok(client) => {
let client = Arc::new(client);
clients.insert(server.name.clone(), Slot::Ready(client.clone()));
Some(client)
}
Err(e) => {
tracing::warn!(server = %server.name, error = %e, "LSP server failed to start");
clients.insert(server.name.clone(), Slot::Failed);
None
}
}
}
}
#[async_trait]
impl DiagnosticsSource for LspPool {
async fn touch(&self, path: &Path) {
let Some(server) = self.server_for(path).cloned() else {
return;
};
let Ok(text) = tokio::fs::read_to_string(path).await else {
return;
};
let Some(client) = self.client_for(&server).await else {
return;
};
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
let language_id = language_id_for_ext(ext);
let existing_version = {
let mut open = self.open.lock().unwrap();
match open.get_mut(path) {
Some(version) => {
*version += 1;
Some(*version)
}
None => {
open.insert(path.to_path_buf(), 1);
None
}
}
};
// Fresh diagnostics only reflect the current contents — drop any stale set first.
client.clear(path);
match existing_version {
None => client.did_open(path, language_id, 1, &text),
Some(version) => client.did_change(path, version, &text),
}
}
async fn diagnostics(&self, path: &Path, wait: Duration) -> Vec<Diagnostic> {
let Some(server) = self.server_for(path).cloned() else {
return Vec::new();
};
let Some(client) = self.client_for(&server).await else {
return Vec::new();
};
client.wait_diagnostics(path, wait).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_ids_map_common_extensions() {
assert_eq!(language_id_for_ext("rs"), "rust");
assert_eq!(language_id_for_ext("tsx"), "typescriptreact");
assert_eq!(language_id_for_ext("unknown"), "unknown");
}
#[test]
fn config_server_overrides_builtin_by_name() {
let pool = LspPool::new(
std::env::temp_dir(),
vec![ServerConfig {
name: "rust".into(),
command: "my-custom-ra".into(),
args: vec!["--flag".into()],
extensions: vec!["rs".into()],
}],
);
let server = pool.server_for(Path::new("/x/a.rs")).unwrap();
assert_eq!(server.command, "my-custom-ra");
}
#[test]
fn no_server_for_unknown_extension() {
let pool = LspPool::new(std::env::temp_dir(), vec![]);
assert!(pool.server_for(Path::new("/x/a.zzz")).is_none());
}
#[test]
fn binary_exists_finds_a_known_tool() {
assert!(binary_exists("sh"));
assert!(!binary_exists("definitely-not-a-real-binary-xyz"));
}
}
+34
View File
@@ -0,0 +1,34 @@
//! End-to-end LSP test against a real `rust-analyzer`. Ignored by default (requires the binary
//! on PATH and is slow — RA indexes the project). Run with:
//! cargo test -p harness-lsp --test rust_analyzer -- --ignored --nocapture
use std::time::Duration;
use harness_core::lsp::{DiagnosticsSource, Severity};
use harness_lsp::LspPool;
#[tokio::test]
#[ignore = "requires rust-analyzer on PATH; slow"]
async fn rust_analyzer_reports_a_type_error() {
// Minimal cargo project with a deliberate type error.
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
let main_rs = dir.path().join("src/main.rs");
std::fs::write(&main_rs, "fn main() {\n let x: i32 = \"not an integer\";\n let _ = x;\n}\n").unwrap();
let pool = LspPool::new(dir.path().to_path_buf(), vec![]);
pool.touch(&main_rs).await;
// Generous wait: rust-analyzer must index the workspace before it reports anything.
let diags = pool.diagnostics(&main_rs, Duration::from_secs(60)).await;
println!("diagnostics: {diags:#?}");
assert!(
diags.iter().any(|d| d.severity == Severity::Error),
"expected at least one error diagnostic, got: {diags:?}"
);
}
+1
View File
@@ -151,6 +151,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+68
View File
@@ -0,0 +1,68 @@
//! Shared LSP-diagnostics reporting for the edit/write tools. After a file is written we ask
//! the (optional) diagnostics source to re-analyze it and append any error-severity items to
//! the tool output so the model sees mistakes it just introduced. Best-effort: no source, a
//! slow server, or a timeout all just mean "no diagnostics" — never a tool failure.
use std::path::Path;
use std::time::Duration;
use harness_core::lsp::Severity;
use harness_core::tool::{ToolCtx, ToolOutput};
/// docs/09-integrations.md: wait up to 1.5s for the server to (re)publish after the change.
const DIAGNOSTICS_WAIT: Duration = Duration::from_millis(1500);
/// Touches `path` in the language server and appends error-severity diagnostics to `output`
/// (both as a human-readable block in the text and the full set in metadata under `diagnostics`).
pub async fn append_diagnostics(ctx: &ToolCtx, path: &Path, display_name: &str, output: &mut ToolOutput) {
let Some(source) = &ctx.diagnostics else {
return;
};
source.touch(path).await;
let diagnostics = source.diagnostics(path, DIAGNOSTICS_WAIT).await;
if diagnostics.is_empty() {
return;
}
let errors: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.collect();
if !errors.is_empty() {
output
.output
.push_str("\n\nLSP errors detected in this file, please fix:");
for diag in &errors {
output.output.push('\n');
output.output.push_str(&diag.display_line(display_name));
}
}
// Full set (all severities) into metadata for the TUI.
let items: Vec<serde_json::Value> = diagnostics
.iter()
.map(|d| {
serde_json::json!({
"line": d.line,
"character": d.character,
"severity": severity_str(d.severity),
"message": d.message,
"source": d.source,
})
})
.collect();
if let serde_json::Value::Object(map) = &mut output.metadata {
map.insert("diagnostics".into(), serde_json::Value::Array(items));
} else {
output.metadata = serde_json::json!({ "diagnostics": items });
}
}
fn severity_str(severity: Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
Severity::Hint => "hint",
}
}
+3 -1
View File
@@ -196,8 +196,9 @@ impl Tool for EditTool {
.map_err(|e| ToolError::Other(format!("{}: {e}", path.display())))?;
let (added, removed) = diff_stats(&content_old, &content_new);
let mut output = ToolOutput::new(pattern, "Edit applied successfully.".to_string());
let mut output = ToolOutput::new(pattern.clone(), "Edit applied successfully.".to_string());
output.metadata = serde_json::json!({"diff": diff, "added": added, "removed": removed});
crate::diagnostics::append_diagnostics(&ctx, &path, &pattern, &mut output).await;
Ok(output)
}
}
@@ -234,6 +235,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+1
View File
@@ -132,6 +132,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+1
View File
@@ -161,6 +161,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+1
View File
@@ -1,4 +1,5 @@
mod bash;
mod diagnostics;
mod edit;
mod glob;
mod grep;
+1
View File
@@ -148,6 +148,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}
+2
View File
@@ -80,6 +80,7 @@ impl Tool for WriteTool {
format!("wrote {} bytes", params.content.len()),
);
output.metadata = serde_json::json!({"diff": diff, "added": added, "removed": removed});
crate::diagnostics::append_diagnostics(&ctx, &path, &params.file_path, &mut output).await;
Ok(output)
}
}
@@ -116,6 +117,7 @@ mod tests {
metadata,
spawner: None,
context_reporter: None,
diagnostics: None,
}
}