A coding agent can write syntactically plausible code and still leave a broken project behind.
The obvious answer is to give the agent an lsp tool and let the model ask for diagnostics whenever it wants. SolonCode tried that shape first. The implementation put navigation and diagnostics in one tool, but diagnostics were effectively never requested. That result is not surprising: after a write, “check whether this introduced errors” is not an optional curiosity. It is part of the write operation’s feedback loop.
SolonCode’s current design makes that distinction explicit:
-
writeandedittrigger diagnostics automatically after a successful change. -
readwarms the language server asynchronously without delaying the read. - The
lsptool is reserved for optional navigation such as definition, references, hover, symbols, and call hierarchy.
The interesting engineering is not starting a language server. It is keeping the file, the language server, the model, and the Web UI consistent while all four observe different representations of the same change.
Diagnostics should follow a write, not a model decision
The implementation note in the repository describes the original failure plainly: ten capabilities—nine navigation operations plus diagnostics—were exposed through one tool, and diagnostics were “never called” in practice.
That led to a three-layer design:
write / edit / apply_patch
-> sync the file
-> wait for diagnostics
-> append diagnostics to the tool output
read
-> warm up the language server asynchronously
-> do not wait and do not change the read result
lsp
-> definition / references / hover / symbols / call hierarchy ...
This is a useful rule for agent design: feedback that is necessary to evaluate a mutation belongs on the mutation path. Exploratory information can remain an explicit tool.
The separation also keeps the tool schema smaller and the model’s decision burden clearer. The model does not need to remember a second call after every edit just to discover whether the edit compiled.
The asynchronous part: waiting for the right diagnosis
A file write and an LSP diagnosis do not happen at the same time. The client must synchronize the document, the server must parse it, and the server may publish one or more diagnostic notifications.
SolonCode’s implementation uses syncFile and waitForDiagnostics(uri, timeoutMs) to connect those events. The wait path tracks the write time and expected document version. A diagnostic notification with a mismatching version is treated as stale rather than as evidence about the latest edit. A 150 ms debounce gathers bursts of notifications, and the default wait budget is 2,000 ms.
That budget is deliberately finite. A language server may be cold-starting or indexing a large workspace. Blocking an agent forever is worse than returning the most recent known result and making the uncertainty visible.
The trade-off is documented in the repository: the first write for a language can exceed the two-second budget during process startup and initial parsing; later writes normally have a warm server. A wait setting can be adjusted with -Dlsp.diagnosticsWait, but increasing it also adds latency to every write.
The important property is not that every diagnosis arrives before the tool returns. It is that the agent does not silently confuse an old diagnosis with the current edit.
Keep the model’s feedback useful
Raw LSP output is a protocol payload, not good model context. SolonCode’s LspDiagnosticReporter narrows it before injection:
- only
ERRORseverity is retained; - each file is limited to 20 displayed errors;
- additional errors are summarized as
... and N more; - positions are rendered as 1-based line and column numbers;
- the file is represented by a workspace-relative path where possible;
- the model-facing block asks it to fix the errors.
The resulting shape is intentionally compact:
LSP errors detected in this file, please fix:
<diagnostics file="src/main/java/example/Service.java">
ERROR [18:13] incompatible types ... (javac)
</diagnostics>
This is not just presentation polish. Without severity filtering and a cap, a language server can flood the next model turn with warnings, hints, generated-file paths, and repeated secondary messages. Automatic feedback still needs a context budget.
One result, two consumers
The same diagnostic is useful to two different consumers:
- the model needs concise text so it can repair the code;
- the Web user needs structured data so the UI can display a reliable explanation.
SolonCode keeps those concerns separate. ToolPresentationFilter handles the TOOL_END event for write and edit. Before the filter replaces a write result with the written content for display, it extracts the diagnostic block into ToolEndPayload.lsp and removes the model-facing diagnostic prose from the user-facing result.
That ordering is a small but important correctness detail. If the filter copied args.content into result first, the diagnostics appended to the result could be lost before they were parsed.
ToolLspInfo then carries a structured contract:
private String file;
private int errorCount;
private boolean truncated;
private List<ToolLspDiagnostic> items;
private boolean pending;
Each item has a line, column, message, and optional source. The browser does not parse XML or prompt wording. It renders the contract it receives.
This makes prompt tuning safe: changing the model-facing sentence does not silently become a front-end protocol change.
Clean, pending, and uncovered are different states
A diagnostic panel often has an implicit and dangerous assumption:
no errors shown = no errors exist
That assumption is not valid for an asynchronous language server. SolonCode distinguishes three cases:
| State | Meaning | UI implication |
|---|---|---|
| Clean | The file was checked and no errors were found | show LSP ✓
|
| Pending | A check was requested, but no conclusion arrived within the wait budget | show neither success nor error badge |
| None | No language server covers the file, or LSP is disabled | do not claim the file was checked |
WebStreamBuilder builds this state without starting a process. It checks the recent file-check state first; when there is no record, it asks the manager whether any client covers the path. A covered file with no conclusion becomes PENDING, not CLEAN.
The front end applies the same rule in applyLspBadge: errors get an LSP N badge and can expand the tool card; a confirmed clean result gets LSP ✓; a pending result gets no badge. That restraint matters. A blank badge is less satisfying than a green check, but it is more honest.
A real synchronization bug: jdtls received the change twice
The most instructive bug appeared only after Java diagnostics became observable. The disk file was correct, but jdtls reported a duplicate method, a stray closing brace, and line numbers beyond the file’s actual length.
The cause was a double application of one change. For an already-open document, the client sent both:
-
workspace/didChangeWatchedFiles(Changed), which caused jdtls to reread the file from disk; and -
textDocument/didChange, whose replacement range had been calculated against the old text.
After the first event, the server already held the new text. Applying a range sized for the old text then left part of the new document behind. The server’s document became torn even though the disk file remained clean.
The fix had three parts:
- once a document is open, use
textDocument/didChangerather than also sending a watched-file event; watched-file notifications remain for files that are not open; - calculate a robust replacement end position using the later of the old and new text lengths;
- lock synchronization by URI so reading, comparing, calculating, sending, and updating the tracked version are atomic.
The lesson generalizes beyond LSP: an integration can be logically correct at the file-system boundary and still be wrong at the protocol-state boundary.
Java adds a process-level constraint
SolonCode itself can run on older JDKs, while jdtls requires JDK 21 or newer. A child process normally inherits the parent process’s JAVA_HOME, so launching jdtls from a JDK 8 process can make it exit immediately.
The implementation addresses this without rewriting the user’s settings. JdkHomeUtil scans installed JDK directories and their release files, chooses a suitable JDK, and WorkspaceManager adds JAVA_HOME to the runtime copy of the built-in Java server configuration. An explicit user setting is respected, and the persisted configuration is left untouched.
That is a good example of keeping environment repair at the process boundary. The product can adapt the child process without committing a machine-specific absolute path into a project file.
What this architecture buys an agent user
The result is more than “SolonCode supports LSP.” It gives each operation a useful responsibility:
- after a mutation, the agent receives actionable errors automatically;
- before a mutation, reads can warm the server without making file browsing feel slow;
- when the agent needs semantic exploration, navigation remains available explicitly;
- in the Web UI, diagnostics are structured, bounded, and visible;
- when the server is cold, unavailable, or unsupported, the UI avoids making a stronger claim than the evidence allows;
- when the language-server integration fails, the successful write is not turned into a failed write.
That last property is especially important. The repository’s hook design treats language-server failures as auxiliary failures: diagnostic and warmup hooks swallow Throwable so an unavailable server cannot invalidate an otherwise successful file operation.
A coding agent should not merely produce code. It should receive the feedback needed to evaluate its own changes, while exposing uncertainty to the human who reviews the result.
SolonCode’s LSP work is therefore best understood as a feedback-loop design:
change -> synchronize -> wait (within a budget) -> filter ->
model repair + structured human-facing status
The hard part is not connecting to a server. The hard part is preserving truth across asynchronous state, noisy diagnostics, process-level runtime differences, and two different audiences. That is what turns LSP from a rarely used tool into part of the agent’s normal engineering loop.
Top comments (0)