[BUG] LSP: first Edit of a file sends didOpen without didSave
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?
When Edit or Write touches a file the language server hasn't seen yet, Claude Code sends didOpen with the new text but no didSave, even though the edit is already on disk and the server announced save support in textDocumentSync. Every later edit of the same file gets didChange followed by didSave.
Servers that react to saves miss the first edit. jdtls, for example, refreshes the file and runs the incremental build on didSave; without it the first edit is only reconciled in memory, so errors it causes in other files show up only after a second edit of the same file.
What Should Happen?
Send didSave after the didOpen of the first edit as well, exactly as after later edits.
Error Messages/Logs
[DEBUG] LSP: Sent didOpen for /…/a.demo (languageId: demo)
[DEBUG] LSP: Sent didChange for /…/a.demo (v2)
[DEBUG] LSP: Sent didSave for /…/a.demo
What the server received (fake-lsp.log):
{"received": "textDocument/didOpen", "version": 1, "text": "uno\n"}
{"received": "textDocument/didChange", "version": 2, "text": "eins\n"}
{"received": "textDocument/didSave", "version": null, "text": null}
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
FAKE_LSP_LOG=$PWD/fake-lsp.log 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(2)'. \
4) Edit a.demo: replace 'uno' with 'eins'. 5) Run: python3 -c 'import time; time.sleep(2)'. Then stop."
cat fake-lsp.log
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.