Repository navigation
[lsp] Tool calls start a server per call and never tell /diagnostics about it #114
Description
Context
The LSP tool path started its own server for every call and killed it at the end, and it never
told the package about it: lsp/tools.go ran Start(...) + defer srv.Close() and never wrote
client/lspAvailable. Two consequences, both measured with LSP_TEST=1 and gopls 0.23.0:
- A cold call failed outright. The per-call server was initialized with the file as its
workspace root (srv.Client.Initialize(fileURI)), which gopls answers with
LSP error 0: no views.main.goregisters the four LSP tools whether or notlsp.enabledis
set and only callslsp.Initwhen it is, so the shipped default reached exactly this path:
adocument_symbolscall onmain.goreturned""andLSP error 0: no views. /diagnosticsreported a configuration problem that is not one.IsAvailable()is
lspAvailable && client != nil, and the per-call server set neither, so after a call the TUI
saidLSP not available (set lsp.enabled=true in config.json)— wrong advice with LSP on and
gopls working — and every call paid another server start.
What to build
- An exported
Ensure(filePath) errorinlsp/touch.gothat starts the session's server through
the existinglazyStartwhen no client exists, under the same lock contractSyncFileuses. lsp/tools.gopromotes that server when a workspace is configured, and keeps a per-call server
only for a session with no workspace — rooted at the project directory, not at the file.
Acceptance
- With
{"lsp":{"enabled":true}}and gopls onPATH, a cold session whose first LSP activity
is anlsp_symbolstool call (no file read) answers with symbols and leavesIsAvailable()
true; a second call reuses the client (LSP: gopls startedonce in the log across two calls). - With no workspace configured, the same tool call still answers with symbols.
- A gated scenario (
LSP_TEST=1, likelsp-diagnostics) asserts the first property on screen, so
CI'slspjob keeps it true.
Correction (2026-10-05)
The evidence this issue was filed with is retracted, and the two fix attempts it justified
changed nothing for a reason worth recording.
"Measured in #111: a tool call returned LSP client not available (30 bytes in the log)" was
measured from a stub that asked for a tool named document_symbols. No code registers that name —
the real one is lsp_symbols (lsp.ToolDocumentSymbols) — so the 30-byte result was
unknown tool: document_symbols (exactly 30 characters) from agent/agent.go, and tlog's
tool exec line prints before the agent loop looks the tool up, which is what made the call
look like it had run. The lsp/tools.go:NNN references in the original body were read the same
way, from a flow that was never entered.
Every measurement in Context above was redone with the registered tool name.
Activity
ci(lsp): the TUI LSP step drives two scenarios now
docs(lsp): attach each session-state doc comment to its function
fix(lsp): keep the per-call server as the second attempt
test(tui): a gated scenario for the LSP tool route
docs(tui): record the LSP tool scenario and the wipe rule
ci(lsp): the TUI LSP step drives two scenarios now
docs(lsp): attach each session-state doc comment to its function
fix(lsp): keep the per-call server as the second attempt
yusiwen commentedon Oct 4, 2026
Landed in #116, rebase-merged onto
masteras 9a910aa..6e1a438 (6 commits). The evidence thisissue was filed with is retracted in the body above; every number below was re-measured with the
tool name the code actually registers.
What the measurement showed
Cold session, workspace configured, gopls 0.23.0, no file read before the call:
0 bytes,err=LSP error 0: no views223 bytesof symbolsIsAvailable()after the callfalsetrueLSP: gopls started for …across two callsWith no workspace configured — the shipped default, since
main.goregisters the four LSPtools unconditionally and only calls
lsp.Initwhenlsp.enabledis set — the same call alsofailed with
no views; it now returns symbols. So this was not a stale status line: all four LSPtools were non-functional by default.
What landed
lsp/touch.go:Ensure(filePath)starts the session's server through thelazyStartSyncFilealready uses, under the same lock contract.
lsp/tools.go: promote the session's server when a workspace is configured; the per-call serverstays the second attempt, now rooted at the project directory instead of the file. The change
is additive to the flow it replaces (promote →
IsAvailable()→ persistent, else per-call).tui/testdata/scenarios/lsp-tools.scenario+ anlspstub mode +make test-tui-lsprunningevery
lsp-*.scenario, with the suite skipping them by that pattern.Gates, all mutation-checked
lsp/tools_test.go:TestToolCallPromotesTheSessionServer,TestToolCallWithoutWorkspaceServesTheRequest(LSP_TEST=1).if Initialised()→if falsefails the first with "a tool call left the session without aclient" and "server started 0 times across two calls";
Initialize(dir)→Initialize(fileURI)fails the second with
LSP error 0: no views.lsp-tools.scenario: with the pre-fixlsp/code and a rebuilt binary, it seeslsp_symbolsandthen times out waiting for
No LSP diagnostics.(exit 1).Counts on the merged revision
LSP_TEST=1 go test ./lsp/ok ·go test ./... -count=113 packages ok ·go vet ./...clean ·make staticcheckexit 0 ·make test-tui-scenarios26 ok / 3 skipped (live-answer, bothlsp-*) ·make test-tui-lsptwice in a row → 4×ok: 14 steps, exit 0 both times · CI: 8 jobssuccess, annotations at the documented baseline (1 per job, 2 onbrowserandtui-visual).Handed off
Nothing open from this issue. Two limits are recorded in
docs/tui-verification.mdrather thanclaimed:
/diagnostics's listing outcome still needs real diagnostics present, and the"persistent client asked about another project's file" case was not measured.