[BUG] LSP: empty publishDiagnostics is dropped — cleared diagnostics still reach the model, and a diagnostic that comes back is deduplicated away
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
Claude Code drops textDocument/publishDiagnostics notifications whose diagnostics array is empty (Skipping empty diagnostics from … for … in the debug log). In LSP every publish replaces the whole set for that URI, so an empty array is how a server says "this file is clean now". Dropping it has two effects:
- Diagnostics the server published and then cleared before the next model turn are still delivered as new diagnostics, so the model gets errors that no longer exist.
- The delivered-diagnostics cache of that URI is never reset, so when the same diagnostic comes back later it is removed as a duplicate (
Deduplication removed 1 duplicate diagnostic(s)) and the model never hears about the regression.
Files touched by Edit/Write are fine, because an edit purges the pending and delivered entries of the edited file. Everything a server reports on its own is affected: other files after a cross-file change, diagnostics that a build publishes and clears as it goes, and stale problems a server republishes at startup before rebuilding. With jdtls in a multi-module Gradle project we regularly see the model chasing compile errors that the server already withdrew.
What Should Happen?
An empty publish should be handled like any other publish: it replaces the set for that URI. Pending, undelivered diagnostics of the URI are dropped and its delivered/dedup entries are cleared, so a diagnostic that reappears later is delivered again.
Error Messages/Logs
[DEBUG] Received diagnostics from plugin:fake-lsp:demo: 1 diagnostic(s) for file:///…/b.demo
[DEBUG] Received diagnostics from plugin:fake-lsp:demo: 0 diagnostic(s) for file:///…/b.demo
[DEBUG] Skipping empty diagnostics from plugin:fake-lsp:demo for file:///…/b.demo
[DEBUG] LSP Diagnostics: Delivering 1 file(s) with 1 diagnostic(s) from 1 server(s)
[DEBUG] Received diagnostics from plugin:fake-lsp:demo: 1 diagnostic(s) for file:///…/b.demo
[DEBUG] LSP Diagnostics: Deduplication removed 1 duplicate diagnostic(s)
[DEBUG] LSP Diagnostics: No new diagnostics to deliver (all filtered by deduplication)
Steps to Reproduce
<details>
<summary>Minimal plugin with a fake language server (Python 3, no dependencies)</summary>
plugin/.claude-plugin/plugin.json
{ "name": "fake-lsp" }
plugin/.lsp.json
{
"demo": {
"command": "python3",
"args": ["${CLAUDE_PLUGIN_ROOT}/fake-lsp.py"],
"extensionToLanguage": { ".demo": "demo" }
}
}
plugin/fake-lsp.py — logs every notification it gets to $FAKE_LSP_LOG; FAKE_LSP_INIT_DELAY delays the initialize answer; FAKE_LSP_CLEAR=1 publishes an error for b.demo 1 s after initialized, clears it 1 s later and publishes it again 8 s after that.
import json
import os
import sys
import threading
import time
LOG = os.environ.get("FAKE_LSP_LOG", "/tmp/fake-lsp.log")
INIT_DELAY = float(os.environ.get("FAKE_LSP_INIT_DELAY", "0"))
CLEAR = os.environ.get("FAKE_LSP_CLEAR") == "1"
lock = threading.Lock()
def read():
headers = {}
while True:
line = sys.stdin.buffer.readline()
if not line:
sys.exit(0)
if not line.strip():
break
key, _, value = line.decode().partition(":")
headers[key.strip().lower()] = value.strip()
return json.loads(sys.stdin.buffer.read(int(headers["content-length"])))
def send(msg):
body = json.dumps(msg).encode()
with lock:
sys.stdout.buffer.write(b"Content-Length: %d\r\n\r\n" % len(body) + body)
sys.stdout.buffer.flush()
def log(event):
with lock, open(LOG, "a") as f:
f.write(json.dumps(event) + "\n")
def publish(uri, diagnostics):
log({"sent": "publishDiagnostics", "uri": uri, "count": len(diagnostics)})
send({"jsonrpc": "2.0", "method": "textDocument/publishDiagnostics",
"params": {"uri": uri, "diagnostics": diagnostics}})
def error_then_clear_then_error(root_uri):
uri = root_uri + "/b.demo"
error = [{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 3}},
"severity": 1, "message": "error in b.demo"}]
time.sleep(1)
publish(uri, error)
time.sleep(1)
publish(uri, [])
time.sleep(8)
publish(uri, error)
root_uri = ""
while True:
msg = read()
method = msg.get("method")
params = msg.get("params") or {}
document = params.get("textDocument") or {}
log({"received": method, "version": document.get("version"),
"text": document.get("text", (params.get("contentChanges") or [{}])[-1].get("text"))})
if method == "initialize":
root_uri = params["rootUri"]
time.sleep(INIT_DELAY)
send({"jsonrpc": "2.0", "id": msg["id"],
"result": {"capabilities": {"textDocumentSync": {"openClose": True, "change": 1, "save": True}}}})
elif method == "initialized" and CLEAR:
threading.Thread(target=error_then_clear_then_error, args=(root_uri,), daemon=True).start()
elif method == "exit":
sys.exit(0)
elif "id" in msg and method:
send({"jsonrpc": "2.0", "id": msg["id"], "result": None})
</details>
mkdir demo && cd demo && echo one > a.demo && echo two > b.demo
FAKE_LSP_LOG=$PWD/fake-lsp.log FAKE_LSP_CLEAR=1 claude -p --plugin-dir ../plugin --debug-file debug.log \
--allowedTools Read Edit "Bash(python3:*)" -- "Follow these steps exactly, one tool call per response, in order. \
1) Read a.demo. 2) Edit a.demo: replace 'one' with 'uno'. 3) Run: python3 -c 'import time; time.sleep(5)'. \
4) Run the same command again. 5) Run it once more. Then list every diagnostic you were shown, or say none."
grep -E "Skipping empty|Delivering|Deduplication" debug.log
The Edit starts the server. The server publishes an error for b.demo, clears it a second later, and publishes it again 8 s after that. The model reports error in b.demo after step 3, five seconds after the server had cleared it, and never sees the error that came back.
Claude Model
Not sure / Multiple models
Is this a regression?
I don't know
Last Working Version
_No response_
Claude Code Version
2.1.294 (Claude Code)
Platform
Anthropic API
Operating System
Ubuntu/Debian Linux
Terminal/Shell
Non-interactive/CI environment
Additional Information
Same behaviour on 2.1.284 and in interactive sessions; claude -p only makes the timing deterministic. #17979 fixed stale diagnostics of the file being edited; this is the path of diagnostics the server publishes by itself.