Skip to content

LSP diagnostics auto-injection — close the compile-feedback loop #136

@Hmbown

Description

@Hmbown

Problem

We have a `diagnostics` tool the model must explicitly call. In practice, it almost never calls it after edits — so the agent edits `crates/tui/src/foo.rs`, breaks the build, and finds out only when the user runs `cargo check` themselves and pastes the error back.

A human reading code sees the squiggle line under their cursor automatically. The agent has no equivalent. This is the single biggest "agent quality" gap between us and opencode.

What opencode does

`packages/opencode/src/lsp/{client,diagnostic,language}.ts` — opencode boots one or more LSP servers (TypeScript, Rust, Go, Python, etc., picked from a 70+ extension map) per workspace, listens for `textDocument/publishDiagnostics`, and after every edit waits up to 5 seconds for fresh reports.

Errors-only are formatted as a compact block:

<diagnostics file=\"crates/tui/src/foo.rs\">
  ERROR [12:8] missing semicolon
  ERROR [13:1] expected `,`, found `}`
</diagnostics>

Top 20 per file. Re-injected into the next turn as a synthetic system message so the model sees what compilation broke. The agent fixes it on the next turn instead of waiting for the user.

Plan

  1. New `crates/tui/src/lsp/` (~1200 LOC):
    • `mod.rs` — `LspManager` keyed by language
    • `client.rs` — wraps `tower-lsp` or rolls a thin JSON-RPC client over stdio
    • `registry.rs` — fixed dictionary of `(language → server-binary, args)` for: `rust-analyzer`, `gopls`, `pyright`, `typescript-language-server`, `clangd`. (Plus a config-overridable map for niche languages.)
    • `diagnostics.rs` — collect + format the `` block
  2. Hook into `crates/tui/src/core/engine.rs`:
    • After every `edit_file` / `apply_patch` / `write_file` tool call, call `lsp_manager.poll_after_edit(file)` with a 5s timeout
    • Attach the formatted diagnostics block as a synthetic system message before the next API call (or as a `tool_result` continuation — pick whichever fits the engine's existing message-shape better)
  3. Config:
    [lsp]
    enabled = true
    poll_after_edit_ms = 5000
    max_diagnostics_per_file = 20
    include_warnings = false   # errors-only by default to keep tokens down
    servers = { rust = \"rust-analyzer\", python = \"pyright\" }   # override registry
  4. Lifecycle: spin up servers lazily on first edit to a file in a new language; shut down with the session.
  5. Failure mode: if the LSP server isn't installed or crashes, log a one-time warning and fall back to no-op (don't block the agent's work).

Acceptance

  • Edit `crates/tui/src/main.rs` to introduce a type error (e.g. `let x: i32 = "not a number";`)
  • The next turn's request body contains a `<diagnostics file="…">` block listing the type error at the right line/col
  • Disabling via `[lsp] enabled = false` removes it
  • A snapshot test exercises the full path with a fixture LSP server (mock the LSP transport, not the network)
  • If `rust-analyzer` isn't on PATH: one-time warning in the audit log; no agent disruption

Files

  • `crates/tui/src/lsp/` (NEW)
  • `crates/tui/src/core/engine.rs` (~30 LOC for the post-edit hook)
  • `crates/tui/src/core/turn.rs` (~20 LOC for the synthetic-message attachment)
  • `crates/config/src/lib.rs` (config schema)

Sizing

M-L — ~1200 LOC. Largest agent-quality lever in opencode.

Source

opencode `packages/opencode/src/lsp/{client,diagnostic,language}.ts`

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions