From f327584e0ad13cbd39bc47753fec68b4b58cdc46 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:00:46 +1000 Subject: [PATCH 01/14] fix windows e2e backend not stopping on sigterm waiting 10s for no reason (#21781) --- packages/app/e2e/backend.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/backend.ts b/packages/app/e2e/backend.ts index 9febc4b3ff4d..a03d1d437504 100644 --- a/packages/app/e2e/backend.ts +++ b/packages/app/e2e/backend.ts @@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") { throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) } +function done(proc: ReturnType) { + return proc.exitCode !== null || proc.signalCode !== null +} + async function waitExit(proc: ReturnType, timeout = 10_000) { - if (proc.exitCode !== null) return + if (done(proc)) return await Promise.race([ new Promise((resolve) => proc.once("exit", () => resolve())), new Promise((resolve) => setTimeout(resolve, timeout)), @@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }): return { url, async stop() { - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGTERM") await waitExit(proc) } - if (proc.exitCode === null) { + if (!done(proc)) { proc.kill("SIGKILL") await waitExit(proc) } From da2e91cf60ab4ae49774531e81876ce67eb601b8 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:01:10 +1000 Subject: [PATCH 02/14] ci use node 24 in test workflow fixing random ECONNRESET (#21782) --- .github/workflows/test.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 70a8477fb51f..510f682549ef 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,9 @@ permissions: contents: read checks: write +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: unit: name: unit (${{ matrix.settings.name }}) @@ -38,6 +41,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun @@ -102,6 +110,11 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "24" + - name: Setup Bun uses: ./.github/actions/setup-bun From c92aaf8b8096f5cb58bdd22989d2cd44c9cf6ce7 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 15 Apr 2026 15:03:00 +0800 Subject: [PATCH 03/14] fix(ui): disable accordion items for binary files and improve disabled state styling (#22577) --- packages/ui/src/components/accordion.css | 4 ++-- packages/ui/src/components/session-review.tsx | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/accordion.css b/packages/ui/src/components/accordion.css index b4d6323d0dee..cebb887f1f62 100644 --- a/packages/ui/src/components/accordion.css +++ b/packages/ui/src/components/accordion.css @@ -51,10 +51,10 @@ line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); - &:hover { + &:hover:not([data-disabled]) { background-color: var(--surface-base-hover); } - &:active { + &:active:not([data-disabled]) { background-color: var(--surface-base-active); } &:focus-visible { diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 4a7205a5dc56..3223f5d08dd6 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -388,6 +388,9 @@ export const SessionReview = (props: SessionReviewProps) => { let wrapper: HTMLDivElement | undefined const file = diff.file + // binary files have empty diffs that we can't render + const diffCanRender = () => diff.additions !== 0 && diff.deletions !== 0 + const expanded = createMemo(() => open().includes(file)) const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file))) const force = () => !!store.force[file] @@ -496,14 +499,14 @@ export const SessionReview = (props: SessionReviewProps) => { return ( - +
@@ -512,7 +515,7 @@ export const SessionReview = (props: SessionReviewProps) => { {`\u202A${getDirectory(file)}\u202C`} {getFilename(file)} - +
From 587e2b09ccedc25b72b9ee8a37b0ebda97f6a6d3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:22:04 +1000 Subject: [PATCH 04/14] fix: support pull diagnostics in the LSP client --- packages/opencode/src/cli/cmd/debug/lsp.ts | 3 +- packages/opencode/src/lsp/client.ts | 473 ++++++++++++++++-- packages/opencode/src/lsp/lsp.ts | 14 +- packages/opencode/src/tool/apply_patch.ts | 2 +- packages/opencode/src/tool/edit.ts | 2 +- packages/opencode/src/tool/lsp.ts | 2 +- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/write.ts | 2 +- .../test/fixture/lsp/fake-lsp-server.js | 172 ++++++- packages/opencode/test/lsp/client.test.ts | 220 +++++++- 10 files changed, 794 insertions(+), 98 deletions(-) diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 185cab9c7587..47db6358b6e7 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -23,8 +23,7 @@ const DiagnosticsCommand = cmd({ const out = await AppRuntime.runPromise( LSP.Service.use((lsp) => Effect.gen(function* () { - yield* lsp.touchFile(args.file, true) - yield* Effect.sleep(1000) + yield* lsp.touchFile(args.file, "full") return yield* lsp.diagnostics() }), ), diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index b20e8ae7f00c..259efbdba4e5 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -14,6 +14,9 @@ import { withTimeout } from "../util/timeout" import { Filesystem } from "../util" const DIAGNOSTICS_DEBOUNCE_MS = 150 +const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 +const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000 +const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000 const log = Log.create({ service: "lsp.client" }) @@ -38,6 +41,82 @@ export const Event = { ), } +type DocumentDiagnosticReport = { + kind?: "full" | "unchanged" + resultId?: string + items?: Diagnostic[] + relatedDocuments?: Record +} + +type WorkspaceDiagnosticReport = { + items?: { + uri?: string + kind?: "full" | "unchanged" + items?: Diagnostic[] + }[] +} + +type DiagnosticRequestResult = { + handled: boolean + matched: boolean + byFile: Map +} + +type CapabilityRegistration = { + id: string + method: string + registerOptions?: { + identifier?: string + workspaceDiagnostics?: boolean + } +} + +type ServerCapabilities = { + textDocumentSync?: + | number + | { + change?: number + } + diagnosticProvider?: unknown + [key: string]: unknown +} + +function getFilePath(uri: string) { + if (!uri.startsWith("file://")) return + return Filesystem.normalizePath(fileURLToPath(uri)) +} + +function getSyncKind(capabilities?: ServerCapabilities) { + if (!capabilities) return + const sync = capabilities.textDocumentSync + if (typeof sync === "number") return sync + return sync?.change +} + +function endPosition(text: string) { + const lines = text.split(/\r\n|\r|\n/) + return { + line: lines.length - 1, + character: lines.at(-1)?.length ?? 0, + } +} + +function dedupeDiagnostics(items: Diagnostic[]) { + const seen = new Set() + return items.filter((item) => { + const key = JSON.stringify({ + code: item.code, + severity: item.severity, + message: item.message, + source: item.source, + range: item.range, + }) + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -47,39 +126,81 @@ export async function create(input: { serverID: string; server: LSPServer.Handle new StreamMessageWriter(input.server.process.stdin as any), ) - const diagnostics = new Map() + const pushDiagnostics = new Map() + const pullDiagnostics = new Map() + const published = new Map() + const diagnosticRegistrations = new Map() + const registrationListeners = new Set<() => void>() + const mergedDiagnostics = (filePath: string) => + dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) + const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { + pushDiagnostics.set(filePath, next) + Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + } + const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { + pullDiagnostics.set(filePath, next) + } + const emitRegistrationChange = () => { + for (const listener of [...registrationListeners]) listener() + } + connection.onNotification("textDocument/publishDiagnostics", (params) => { - const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) + const filePath = getFilePath(params.uri) + if (!filePath) return l.info("textDocument/publishDiagnostics", { path: filePath, count: params.diagnostics.length, + version: params.version, }) - const exists = diagnostics.has(filePath) - diagnostics.set(filePath, params.diagnostics) - if (!exists && input.serverID === "typescript") return - Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + published.set(filePath, { + at: Date.now(), + version: typeof params.version === "number" ? params.version : undefined, + }) + if (input.serverID === "typescript" && !pushDiagnostics.has(filePath)) { + pushDiagnostics.set(filePath, params.diagnostics) + return + } + updatePushDiagnostics(filePath, params.diagnostics) }) connection.onRequest("window/workDoneProgress/create", (params) => { l.info("window/workDoneProgress/create", params) return null }) connection.onRequest("workspace/configuration", async () => { - // Return server initialization options return [input.server.initialization ?? {}] }) - connection.onRequest("client/registerCapability", async () => {}) - connection.onRequest("client/unregisterCapability", async () => {}) + connection.onRequest("client/registerCapability", async (params) => { + const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? [] + let changed = false + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue + diagnosticRegistrations.set(registration.id, registration) + changed = true + } + if (changed) emitRegistrationChange() + }) + connection.onRequest("client/unregisterCapability", async (params) => { + const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? [] + let changed = false + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue + diagnosticRegistrations.delete(registration.id) + changed = true + } + if (changed) emitRegistrationChange() + }) connection.onRequest("workspace/workspaceFolders", async () => [ { name: "workspace", uri: pathToFileURL(input.root).href, }, ]) + connection.onRequest("workspace/diagnostic/refresh", async () => null) connection.listen() l.info("sending initialize") - await withTimeout( - connection.sendRequest("initialize", { + const initialized = await withTimeout( + connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { rootUri: pathToFileURL(input.root).href, processId: input.server.process.pid, workspaceFolders: [ @@ -100,12 +221,19 @@ export async function create(input: { serverID: string; server: LSPServer.Handle didChangeWatchedFiles: { dynamicRegistration: true, }, + diagnostics: { + refreshSupport: true, + }, }, textDocument: { synchronization: { didOpen: true, didChange: true, }, + diagnostic: { + dynamicRegistration: true, + relatedDocumentSupport: true, + }, publishDiagnostics: { versionSupport: true, }, @@ -123,6 +251,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle ) }) + const syncKind = getSyncKind(initialized.capabilities) + const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider) + await connection.sendNotification("initialized", {}) if (input.server.initialization) { @@ -131,9 +262,241 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }) } - const files: { - [path: string]: number - } = {} + const files: Record = {} + + const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { + const handled = results.some((result) => result.handled) + const matched = results.some((result) => result.matched) + if (!handled) return { handled: false, matched: false } + + const merged = new Map() + for (const result of results) { + for (const [target, items] of result.byFile.entries()) { + const existing = merged.get(target) ?? [] + merged.set(target, existing.concat(items)) + } + } + + if (matched && !merged.has(filePath)) merged.set(filePath, []) + for (const [target, items] of merged.entries()) { + updatePullDiagnostics(target, dedupeDiagnostics(items)) + } + + return { handled, matched } + } + + async function requestDiagnosticReport(filePath: string, identifier?: string): Promise { + const report = await withTimeout( + connection.sendRequest("textDocument/diagnostic", { + ...(identifier ? { identifier } : {}), + textDocument: { + uri: pathToFileURL(filePath).href, + }, + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null) + if (!report) return { handled: false, matched: false, byFile: new Map() } + + const byFile = new Map() + const push = (target: string, items: Diagnostic[]) => { + const existing = byFile.get(target) ?? [] + byFile.set(target, existing.concat(items)) + } + + let handled = false + let matched = false + if (report.kind === "unchanged") { + push(filePath, mergedDiagnostics(filePath)) + handled = true + matched = true + } + if (Array.isArray(report.items)) { + push(filePath, report.items) + handled = true + matched = true + } + for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { + const relatedPath = getFilePath(uri) + if (!relatedPath) continue + if (related.kind === "unchanged") { + push(relatedPath, mergedDiagnostics(relatedPath)) + handled = true + matched = matched || relatedPath === filePath + continue + } + if (!Array.isArray(related.items)) continue + push(relatedPath, related.items) + handled = true + matched = matched || relatedPath === filePath + } + + return { handled, matched, byFile } + } + + async function requestWorkspaceDiagnosticReport(filePath: string, identifier?: string): Promise { + const report = await withTimeout( + connection.sendRequest("workspace/diagnostic", { + ...(identifier ? { identifier } : {}), + previousResultIds: [], + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null) + if (!report) return { handled: false, matched: false, byFile: new Map() } + + const byFile = new Map() + let matched = false + for (const item of report.items ?? []) { + const relatedPath = item.uri ? getFilePath(item.uri) : undefined + if (!relatedPath) continue + const items = item.kind === "unchanged" ? mergedDiagnostics(relatedPath) : (item.items ?? []) + const existing = byFile.get(relatedPath) ?? [] + byFile.set(relatedPath, existing.concat(items)) + matched = matched || relatedPath === filePath + } + + return { handled: byFile.size > 0, matched, byFile } + } + + function documentPullState() { + const documentRegistrations = [...diagnosticRegistrations.values()].filter( + (registration) => registration.registerOptions?.workspaceDiagnostics !== true, + ) + return { + documentIdentifiers: [...new Set(documentRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? []))], + supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, + } + } + + function workspacePullState() { + const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( + (registration) => registration.registerOptions?.workspaceDiagnostics === true, + ) + return { + workspaceIdentifiers: [...new Set(workspaceRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? []))], + supported: workspaceRegistrations.length > 0, + } + } + + async function requestDocumentDiagnostics(filePath: string) { + const state = documentPullState() + if (!state.supported) return { handled: false, matched: false } + return mergeResults( + filePath, + await Promise.all([ + requestDiagnosticReport(filePath), + ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), + ]), + ) + } + + async function requestFullDiagnostics(filePath: string) { + const documentState = documentPullState() + const workspaceState = workspacePullState() + if (!documentState.supported && !workspaceState.supported) return { handled: false, matched: false } + return mergeResults( + filePath, + await Promise.all([ + ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), + ...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), + ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), + ...workspaceState.workspaceIdentifiers.map((identifier) => requestWorkspaceDiagnosticReport(filePath, identifier)), + ]), + ) + } + + function waitForRegistrationChange(timeout: number) { + if (timeout <= 0) return Promise.resolve(false) + return new Promise((resolve) => { + let finished = false + let timer: ReturnType | undefined + const finish = (result: boolean) => { + if (finished) return + finished = true + if (timer) clearTimeout(timer) + registrationListeners.delete(listener) + resolve(result) + } + const listener = () => finish(true) + registrationListeners.add(listener) + timer = setTimeout(() => finish(false), timeout) + }) + } + + function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) { + if (request.timeout <= 0) return Promise.resolve(false) + return new Promise((resolve) => { + let finished = false + let debounceTimer: ReturnType | undefined + let timeoutTimer: ReturnType | undefined + let unsub: (() => void) | undefined + const finish = (result: boolean) => { + if (finished) return + finished = true + if (debounceTimer) clearTimeout(debounceTimer) + if (timeoutTimer) clearTimeout(timeoutTimer) + unsub?.() + resolve(result) + } + const schedule = () => { + const hit = published.get(request.path) + if (!hit || hit.at < request.after) return + if (typeof hit.version === "number" && hit.version !== request.version) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at))) + } + + timeoutTimer = setTimeout(() => finish(false), request.timeout) + unsub = Bus.subscribe(Event.Diagnostics, (event) => { + if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return + schedule() + }) + schedule() + }) + } + + async function waitForDocumentDiagnostics(request: { path: string; version: number }) { + const startedAt = Date.now() + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, + }) + + while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { + const result = await requestDocumentDiagnostics(request.path) + if (result.matched) return + const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + if (remaining <= 0) return + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : "timeout" as const)), + waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : "timeout" as const)), + ]) + if (next !== "registration") return + } + } + + async function waitForFullDiagnostics(request: { path: string; version: number }) { + const startedAt = Date.now() + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, + }) + + while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { + const result = await requestFullDiagnostics(request.path) + if (result.handled || result.matched) return + const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + if (remaining <= 0) return + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : "timeout" as const)), + waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : "timeout" as const)), + ]) + if (next !== "registration") return + } + } const result = { root: input.root, @@ -145,25 +508,29 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }, notify: { async open(request: { path: string }) { - request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path) + request.path = Filesystem.normalizePath( + path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), + ) const text = await Filesystem.readText(request.path) const extension = path.extname(request.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - const version = files[request.path] - if (version !== undefined) { + const document = files[request.path] + if (document !== undefined) { + pushDiagnostics.delete(request.path) + pullDiagnostics.delete(request.path) log.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(request.path).href, - type: 2, // Changed + type: 2, }, ], }) - const next = version + 1 - files[request.path] = next + const next = document.version + 1 + files[request.path] = { version: next, text } log.info("textDocument/didChange", { path: request.path, version: next, @@ -173,9 +540,20 @@ export async function create(input: { serverID: string; server: LSPServer.Handle uri: pathToFileURL(request.path).href, version: next, }, - contentChanges: [{ text }], + contentChanges: + syncKind === 2 + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: endPosition(document.text), + }, + text, + }, + ] + : [{ text }], }) - return + return next } log.info("workspace/didChangeWatchedFiles", request) @@ -183,13 +561,14 @@ export async function create(input: { serverID: string; server: LSPServer.Handle changes: [ { uri: pathToFileURL(request.path).href, - type: 1, // Created + type: 1, }, ], }) log.info("textDocument/didOpen", request) - diagnostics.delete(request.path) + pushDiagnostics.delete(request.path) + pullDiagnostics.delete(request.path) await connection.sendNotification("textDocument/didOpen", { textDocument: { uri: pathToFileURL(request.path).href, @@ -198,41 +577,31 @@ export async function create(input: { serverID: string; server: LSPServer.Handle text, }, }) - files[request.path] = 0 - return + files[request.path] = { version: 0, text } + return 0 }, }, get diagnostics() { - return diagnostics + const result = new Map() + for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { + result.set(key, mergedDiagnostics(key)) + } + return result }, - async waitForDiagnostics(request: { path: string }) { + async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full" }) { const normalizedPath = Filesystem.normalizePath( path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) - log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: () => void - let debounceTimer: ReturnType | undefined - return await withTimeout( - new Promise((resolve) => { - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { - // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - log.info("got diagnostics", { path: normalizedPath }) - unsub?.() - resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - }) - }), - 3000, - ) - .catch(() => {}) - .finally(() => { - if (debounceTimer) clearTimeout(debounceTimer) - unsub?.() - }) + log.info("waiting for diagnostics", { + path: normalizedPath, + mode: request.mode ?? "full", + version: request.version, + }) + if (request.mode === "document") { + await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version }) + return + } + await waitForFullDiagnostics({ path: normalizedPath, version: request.version }) }, async shutdown() { l.info("shutting down") diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 833285e7b562..27106582d91e 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -136,7 +136,7 @@ export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect readonly hasClients: (file: string) => Effect.Effect - readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect + readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect readonly diagnostics: () => Effect.Effect> readonly hover: (input: LocInput) => Effect.Effect readonly definition: (input: LocInput) => Effect.Effect @@ -358,15 +358,19 @@ export const layer = Layer.effect( }) }) - const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { + const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") { log.info("touching file", { file: input }) const clients = yield* getClients(input) yield* Effect.promise(() => Promise.all( clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait + const version = await client.notify.open({ path: input }) + if (!diagnostics) return + return client.waitForDiagnostics({ + path: input, + version, + mode: diagnostics, + }) }), ).catch((err) => { log.error("failed to touch file", { err, file: input }) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 7da7dd255c52..a28c51e6a330 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -248,7 +248,7 @@ export const ApplyPatchTool = Tool.define( for (const change of fileChanges) { if (change.type === "delete") continue const target = change.movePath ?? change.filePath - yield* lsp.touchFile(target, true) + yield* lsp.touchFile(target, "document") } const diagnostics = yield* lsp.diagnostics() diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 2c6c2c13084a..24d37328fe03 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -175,7 +175,7 @@ export const EditTool = Tool.define( }) let output = "Edit applied successfully." - yield* lsp.touchFile(filePath, true) + yield* lsp.touchFile(filePath, "document") const diagnostics = yield* lsp.diagnostics() const normalizedFilePath = AppFileSystem.normalizePath(filePath) const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? []) diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 263bfe81d2fc..0a0edc61edde 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -55,7 +55,7 @@ export const LspTool = Tool.define( const available = yield* lsp.hasClients(file) if (!available) throw new Error("No LSP server available for this file type.") - yield* lsp.touchFile(file, true) + yield* lsp.touchFile(file, "document") const result: unknown[] = yield* (() => { switch (args.operation) { diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index c9b304862652..a9b95346a1fb 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -75,7 +75,7 @@ export const ReadTool = Tool.define( }) const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { - yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) + yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope)) }) const readSample = Effect.fn("ReadTool.readSample")(function* ( diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 741091b21d3c..7a868e98733b 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -60,7 +60,7 @@ export const WriteTool = Tool.define( }) let output = "Wrote file successfully." - yield* lsp.touchFile(filepath, true) + yield* lsp.touchFile(filepath, "document") const diagnostics = yield* lsp.diagnostics() const normalizedFilepath = AppFileSystem.normalizePath(filepath) let projectDiagnosticsCount = 0 diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index be62f96f38f9..b4005680b44d 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -1,7 +1,19 @@ // Simple JSON-RPC 2.0 LSP-like fake server over stdio -// Implements a minimal LSP handshake and triggers a request upon notification let nextId = 1 +let readBuffer = Buffer.alloc(0) +let lastChange = null +let diagnosticRequestCount = 0 +let registeredCapability = false +let pullConfig = { + delayMs: 0, + registerOn: undefined, + registrations: [], + documentDiagnostics: [], + documentDiagnosticsByIdentifier: {}, + workspaceDiagnostics: [], + workspaceDiagnosticsByIdentifier: {}, +} function encode(message) { const json = JSON.stringify(message) @@ -14,29 +26,19 @@ function decodeFrames(buffer) { let idx while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) { const header = buffer.slice(0, idx).toString("utf8") - const m = /Content-Length:\s*(\d+)/i.exec(header) - const len = m ? parseInt(m[1], 10) : 0 + const match = /Content-Length:\s*(\d+)/i.exec(header) + const length = match ? parseInt(match[1], 10) : 0 const bodyStart = idx + 4 - const bodyEnd = bodyStart + len + const bodyEnd = bodyStart + length if (buffer.length < bodyEnd) break - const body = buffer.slice(bodyStart, bodyEnd).toString("utf8") - results.push(body) + results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8")) buffer = buffer.slice(bodyEnd) } return { messages: results, rest: buffer } } -let readBuffer = Buffer.alloc(0) - -process.stdin.on("data", (chunk) => { - readBuffer = Buffer.concat([readBuffer, chunk]) - const { messages, rest } = decodeFrames(readBuffer) - readBuffer = rest - for (const m of messages) handle(m) -}) - -function send(msg) { - process.stdout.write(encode(msg)) +function send(message) { + process.stdout.write(encode(message)) } function sendRequest(method, params) { @@ -45,6 +47,42 @@ function sendRequest(method, params) { return id } +function sendResponse(id, result) { + send({ jsonrpc: "2.0", id, result }) +} + +function sendNotification(method, params) { + send({ jsonrpc: "2.0", method, params }) +} + +function maybeRegister(method) { + if (pullConfig.registerOn !== method || registeredCapability) return + registeredCapability = true + sendRequest("client/registerCapability", { + registrations: pullConfig.registrations.map((registration, index) => ({ + id: registration.id ?? `pull-${index}`, + method: registration.method ?? "textDocument/diagnostic", + registerOptions: registration.registerOptions ?? registration, + })), + }) +} + +function delayed(id, result) { + if (!pullConfig.delayMs) { + sendResponse(id, result) + return + } + setTimeout(() => sendResponse(id, result), pullConfig.delayMs) +} + +function diagnosticsForIdentifier(identifier) { + return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics +} + +function workspaceDiagnosticsForIdentifier(identifier) { + return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics +} + function handle(raw) { let data try { @@ -52,24 +90,112 @@ function handle(raw) { } catch { return } + if (data.method === "initialize") { - send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } }) + sendResponse(data.id, { + capabilities: { + textDocumentSync: { + change: 2, + }, + }, + }) return } - if (data.method === "initialized") { + + if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") { return } - if (data.method === "workspace/didChangeConfiguration") { + + if (data.method === "textDocument/didOpen") { + maybeRegister("didOpen") return } + + if (data.method === "textDocument/didChange") { + lastChange = data.params + maybeRegister("didChange") + return + } + if (data.method === "test/trigger") { const method = data.params && data.params.method + if (method === "client/registerCapability") { + sendRequest(method, { + registrations: [ + { + id: "test-diagnostic-registration", + method: "textDocument/diagnostic", + registerOptions: { identifier: "syntax" }, + }, + ], + }) + return + } + if (method === "client/unregisterCapability") { + sendRequest(method, { + unregisterations: [{ id: "test-diagnostic-registration", method: "textDocument/diagnostic" }], + }) + return + } if (method) sendRequest(method, {}) return } - if (typeof data.id !== "undefined") { - // Respond OK to any request from client to keep transport flowing - send({ jsonrpc: "2.0", id: data.id, result: null }) + + if (data.method === "test/configure-pull-diagnostics") { + pullConfig = { + delayMs: data.params?.delayMs ?? 0, + registerOn: data.params?.registerOn, + registrations: data.params?.registrations ?? [], + documentDiagnostics: data.params?.documentDiagnostics ?? [], + documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {}, + workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [], + workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {}, + } + registeredCapability = false + sendResponse(data.id, null) + return + } + + if (data.method === "test/publish-diagnostics") { + sendNotification("textDocument/publishDiagnostics", data.params) + return + } + + if (data.method === "test/get-last-change") { + sendResponse(data.id, lastChange) + return + } + + if (data.method === "test/get-diagnostic-request-count") { + sendResponse(data.id, diagnosticRequestCount) + return + } + + if (data.method === "textDocument/diagnostic") { + diagnosticRequestCount += 1 + delayed(data.id, { + kind: "full", + items: diagnosticsForIdentifier(data.params?.identifier ?? ""), + }) return } + + if (data.method === "workspace/diagnostic") { + diagnosticRequestCount += 1 + delayed(data.id, { + items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""), + }) + return + } + + if (typeof data.id !== "undefined") { + sendResponse(data.id, null) + } } + +process.stdin.on("data", (chunk) => { + readBuffer = Buffer.concat([readBuffer, chunk]) + const { messages, rest } = decodeFrames(readBuffer) + readBuffer = rest + for (const message of messages) handle(message) +}) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index d6eaa317f945..3e4d14f1fddc 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, test, beforeEach } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import path from "path" +import { pathToFileURL } from "url" +import { tmpdir } from "../fixture/fixture" import { LSPClient } from "../../src/lsp" import { LSPServer } from "../../src/lsp" import { Instance } from "../../src/project/instance" import { Log } from "../../src/util" -// Minimal fake LSP server that speaks JSON-RPC over stdio function spawnFakeServer() { const { spawn } = require("child_process") const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") @@ -39,10 +40,8 @@ describe("LSPClient interop", () => { method: "workspace/workspaceFolders", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() - await client.shutdown() }) @@ -64,10 +63,8 @@ describe("LSPClient interop", () => { method: "client/registerCapability", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() - await client.shutdown() }) @@ -89,10 +86,211 @@ describe("LSPClient interop", () => { method: "client/unregisterCapability", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() - await client.shutdown() }) + + test("sends ranged didChange for incremental sync servers", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "first\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.notify.open({ path: file }) + await Bun.write(file, "second\nthird\n") + await client.notify.open({ path: file }) + + const change = await client.connection.sendRequest<{ + textDocument: { version: number } + contentChanges: { + range?: { start: { line: number; character: number }; end: { line: number; character: number } } + text: string + }[] + }>("test/get-last-change", {}) + expect(change.textDocument.version).toBe(1) + expect(change.contentChanges).toEqual([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + text: "second\nthird\n", + }, + ]) + + await client.shutdown() + }, + }) + }) + + test("document mode falls back to push diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "const x = 1\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + const version = await client.notify.open({ path: file }) + const wait = client.waitForDiagnostics({ path: file, version, mode: "document" }) + await client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + version, + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "push diagnostic", + severity: 1, + }, + ], + }) + await wait + + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("push diagnostic") + + const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) + expect(count).toBe(0) + + await client.shutdown() + }, + }) + }) + + test("document mode waits for pull diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ identifier: "DocumentCompilerSemantic" }], + documentDiagnosticsByIdentifier: { + DocumentCompilerSemantic: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "pull diagnostic", + severity: 1, + }, + ], + }, + }) + + const version = await client.notify.open({ path: file }) + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("pull diagnostic") + + const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) + expect(count).toBeGreaterThan(0) + + await client.shutdown() + }, + }) + }) + + test("full mode includes workspace pull diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + const related = path.join(tmp.path, "other.cs") + await Bun.write(file, "class C {}\n") + await Bun.write(related, "class D {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [ + { identifier: "DocumentCompilerSemantic" }, + { identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }, + ], + documentDiagnosticsByIdentifier: { + DocumentCompilerSemantic: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "current file", + severity: 1, + }, + ], + }, + workspaceDiagnosticsByIdentifier: { + WorkspaceDocumentsAndProject: [ + { + uri: pathToFileURL(related).href, + items: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "workspace file", + severity: 1, + }, + ], + }, + ], + }, + }) + + const version = await client.notify.open({ path: file }) + await client.waitForDiagnostics({ path: file, version, mode: "full" }) + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file") + expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file") + + await client.shutdown() + }, + }) + }) }) From b7f486d3a61711b6b2631feae266a3b4b546fcd3 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:43:47 +1000 Subject: [PATCH 05/14] chore: drop aspirational unchanged diagnostic handling --- packages/opencode/src/lsp/client.ts | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 259efbdba4e5..8df02fc08336 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -42,8 +42,6 @@ export const Event = { } type DocumentDiagnosticReport = { - kind?: "full" | "unchanged" - resultId?: string items?: Diagnostic[] relatedDocuments?: Record } @@ -51,7 +49,6 @@ type DocumentDiagnosticReport = { type WorkspaceDiagnosticReport = { items?: { uri?: string - kind?: "full" | "unchanged" items?: Diagnostic[] }[] } @@ -305,11 +302,6 @@ export async function create(input: { serverID: string; server: LSPServer.Handle let handled = false let matched = false - if (report.kind === "unchanged") { - push(filePath, mergedDiagnostics(filePath)) - handled = true - matched = true - } if (Array.isArray(report.items)) { push(filePath, report.items) handled = true @@ -317,14 +309,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { const relatedPath = getFilePath(uri) - if (!relatedPath) continue - if (related.kind === "unchanged") { - push(relatedPath, mergedDiagnostics(relatedPath)) - handled = true - matched = matched || relatedPath === filePath - continue - } - if (!Array.isArray(related.items)) continue + if (!relatedPath || !Array.isArray(related.items)) continue push(relatedPath, related.items) handled = true matched = matched || relatedPath === filePath @@ -347,10 +332,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle let matched = false for (const item of report.items ?? []) { const relatedPath = item.uri ? getFilePath(item.uri) : undefined - if (!relatedPath) continue - const items = item.kind === "unchanged" ? mergedDiagnostics(relatedPath) : (item.items ?? []) + if (!relatedPath || !Array.isArray(item.items)) continue const existing = byFile.get(relatedPath) ?? [] - byFile.set(relatedPath, existing.concat(items)) + byFile.set(relatedPath, existing.concat(item.items)) matched = matched || relatedPath === filePath } From f037c1bcc96f29afd9dbb3749f0362f300aa7b3c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:49:35 +1000 Subject: [PATCH 06/14] test: guard against pull diagnostics latency regressions --- packages/opencode/test/lsp/client.test.ts | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 3e4d14f1fddc..b446ae528d9b 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -228,6 +228,61 @@ describe("LSPClient interop", () => { }) }) + test("document mode issues identifier pulls in parallel without settle delay", async () => { + // Guards against two latency regressions: + // 1. Sequentially sweeping identifier pulls (would be ~5 * 300ms = 1500ms). + // 2. Re-introducing a settle/debounce wait after a matching pull response. + // Parallel dispatch completes in ~300ms; threshold leaves headroom for CI jitter. + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + const identifiers = ["syntax", "compiler", "analyzer-semantic", "analyzer-syntax", "non-local"] + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + delayMs: 300, + registrations: identifiers.map((identifier) => ({ identifier })), + documentDiagnosticsByIdentifier: Object.fromEntries( + identifiers.map((identifier) => [ + identifier, + [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: `from ${identifier}`, + severity: 1, + }, + ], + ]), + ), + }) + + const version = await client.notify.open({ path: file }) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + const duration = Date.now() - started + + expect(duration).toBeLessThan(1000) + expect(client.diagnostics.get(file)?.length ?? 0).toBeGreaterThan(0) + + await client.shutdown() + }, + }) + }) + test("full mode includes workspace pull diagnostics", async () => { const handle = spawnFakeServer() as any await using tmp = await tmpdir() From 2084840a431d603298d4c17544bea9256bf6ecd4 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:54:12 +1000 Subject: [PATCH 07/14] docs: guard against pull diagnostics latency regressions with a comment --- packages/opencode/src/lsp/client.ts | 6 +++ packages/opencode/test/lsp/client.test.ts | 55 ----------------------- 2 files changed, 6 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 8df02fc08336..6d1af60548d9 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -361,6 +361,12 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } } + // LATENCY-CRITICAL: dispatch identifier pulls in parallel and return on the first + // resolved batch. Do NOT sequentially await identifier-by-identifier, and do NOT + // add a post-match settle/debounce delay. Servers like Roslyn register many + // diagnostic identifiers (syntax, compiler, analyzer, non-local, ...) and each + // pull is network+compute bound. Sequencing them or waiting for "stability" + // after a match turned `edit`/`apply_patch` UX into 4s+ pauses. See PR #23771. async function requestDocumentDiagnostics(filePath: string) { const state = documentPullState() if (!state.supported) return { handled: false, matched: false } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index b446ae528d9b..3e4d14f1fddc 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -228,61 +228,6 @@ describe("LSPClient interop", () => { }) }) - test("document mode issues identifier pulls in parallel without settle delay", async () => { - // Guards against two latency regressions: - // 1. Sequentially sweeping identifier pulls (would be ~5 * 300ms = 1500ms). - // 2. Re-introducing a settle/debounce wait after a matching pull response. - // Parallel dispatch completes in ~300ms; threshold leaves headroom for CI jitter. - const handle = spawnFakeServer() as any - await using tmp = await tmpdir() - const file = path.join(tmp.path, "client.cs") - await Bun.write(file, "class C {}\n") - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const client = await LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: tmp.path, - directory: tmp.path, - }) - - const identifiers = ["syntax", "compiler", "analyzer-semantic", "analyzer-syntax", "non-local"] - await client.connection.sendRequest("test/configure-pull-diagnostics", { - registerOn: "didOpen", - delayMs: 300, - registrations: identifiers.map((identifier) => ({ identifier })), - documentDiagnosticsByIdentifier: Object.fromEntries( - identifiers.map((identifier) => [ - identifier, - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 5 }, - }, - message: `from ${identifier}`, - severity: 1, - }, - ], - ]), - ), - }) - - const version = await client.notify.open({ path: file }) - const started = Date.now() - await client.waitForDiagnostics({ path: file, version, mode: "document" }) - const duration = Date.now() - started - - expect(duration).toBeLessThan(1000) - expect(client.diagnostics.get(file)?.length ?? 0).toBeGreaterThan(0) - - await client.shutdown() - }, - }) - }) - test("full mode includes workspace pull diagnostics", async () => { const handle = spawnFakeServer() as any await using tmp = await tmpdir() From ec21c18bb7fa73965217acec3ef1c8956aa2c520 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:51:56 +1000 Subject: [PATCH 08/14] fix: preserve LSP diagnostics across didChange notifications Some servers (clangd is the canonical offender) don't re-emit diagnostics when textDocument/didChange arrives with identical content. Wiping the pushed/pulled diagnostics on every didChange lost those errors for no-op touchFile calls. Let the server's next publish/pull overwrite naturally instead. --- packages/opencode/src/lsp/client.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 6d1af60548d9..0757b2284db5 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -507,8 +507,10 @@ export async function create(input: { serverID: string; server: LSPServer.Handle const document = files[request.path] if (document !== undefined) { - pushDiagnostics.delete(request.path) - pullDiagnostics.delete(request.path) + // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only + // re-emit diagnostics when the content actually changes, so clearing + // here would lose errors for no-op touchFile calls. Let the server's + // next push/pull overwrite naturally. log.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ From c25e9b9d9315993ba92e4140dff64797070953b6 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:52:22 +1000 Subject: [PATCH 09/14] chore: surface LSP server stderr output Servers that fail to spawn (wrong binary, bad flags, missing runtime deps) typically print to stderr and then sit idle. The client's initialize request then times out after 45s with no explanation. Forward stderr lines to the client's logger at error level, capped at 1000 chars per chunk, so misconfigs surface immediately instead of hiding behind a timeout. --- packages/opencode/src/lsp/client.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 0757b2284db5..7c4c847c1ccd 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -122,6 +122,14 @@ export async function create(input: { serverID: string; server: LSPServer.Handle new StreamMessageReader(input.server.process.stdout as any), new StreamMessageWriter(input.server.process.stdin as any), ) + // LSP servers rarely write to stderr - when they do, it's almost always a + // misconfiguration (wrong binary, bad args, missing deps) that otherwise + // manifests as a silent 45s initialize timeout. Surface it at error level so + // operators don't have to hunt. + input.server.process.stderr?.on("data", (data: Buffer) => { + const text = data.toString().trim() + if (text) l.error("server stderr", { text: text.slice(0, 1000) }) + }) const pushDiagnostics = new Map() const pullDiagnostics = new Map() From c740cfe5e99b8daa74d6355374d1aac131f56c89 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:52:35 +1000 Subject: [PATCH 10/14] fix: spawn pyright-langserver, not the pyright CLI Pyright's npm package publishes two bins: the \pyright\ CLI (which rejects \--stdio\ with \Unexpected option --stdio\) and \pyright-langserver\. The LSP client was picking \pyright\ because \Npm.which\ returned the alphabetically first bin, causing a silent 45s initialize timeout. Add an optional \in\ hint to \Npm.which\ and pass \pyright-langserver\ from the pyright spawn. Other callers are unaffected. --- packages/opencode/src/lsp/server.ts | 2 +- packages/opencode/src/npm/index.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 8bb70a51166e..a0cb8fe3881f 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -490,7 +490,7 @@ export const Pyright: Info = { const args = [] if (!binary) { if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("pyright") + const resolved = await Npm.which("pyright", "pyright-langserver") if (!resolved) return binary = resolved } diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 477e99e06afe..fc8497d20b8c 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -34,7 +34,7 @@ export interface Interface { }, ) => Effect.Effect readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect - readonly which: (pkg: string) => Effect.Effect> + readonly which: (pkg: string, bin?: string) => Effect.Effect> } export class Service extends Context.Service()("@opencode/Npm") {} @@ -207,7 +207,7 @@ export const layer = Layer.effect( return }, Effect.scoped) - const which = Effect.fn("Npm.which")(function* (pkg: string) { + const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) { const dir = directory(pkg) const binDir = path.join(dir, "node_modules", ".bin") @@ -215,6 +215,9 @@ export const layer = Layer.effect( const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[]))) if (files.length === 0) return Option.none() + // Caller picked a specific bin (e.g. pyright exposes both `pyright` and + // `pyright-langserver`); trust the hint if the package provides it. + if (bin) return files.includes(bin) ? Option.some(bin) : Option.none() if (files.length === 1) return Option.some(files[0]) const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option) @@ -223,11 +226,11 @@ export const layer = Layer.effect( const parsed = pkgJson.value as { bin?: string | Record } if (parsed?.bin) { const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg - const bin = parsed.bin - if (typeof bin === "string") return Option.some(unscoped) - const keys = Object.keys(bin) + const parsedBin = parsed.bin + if (typeof parsedBin === "string") return Option.some(unscoped) + const keys = Object.keys(parsedBin) if (keys.length === 1) return Option.some(keys[0]) - return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) + return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) } } From 5f182c00dce47c29110a522175455fcb3ce8ff53 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:33:09 +1000 Subject: [PATCH 11/14] fix: tighten LSP diagnostic wait races Document-mode waits were still missing a matching push that arrived before the wait subscription, and pull diagnostics could still block on the slowest identifier even after the current file already had errors. Capture the wait start before didOpen/didChange, accept matching published versions that already landed, return document waits once current-file diagnostics exist, and treat empty workspace pull responses as handled. Add regression coverage for all three cases. --- packages/opencode/src/lsp/client.ts | 69 ++++++--- packages/opencode/src/lsp/lsp.ts | 2 + .../test/fixture/lsp/fake-lsp-server.js | 46 ++++-- packages/opencode/test/lsp/client.test.ts | 133 ++++++++++++++++++ 4 files changed, 222 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 7c4c847c1ccd..03b50a0be693 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -346,7 +346,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle matched = matched || relatedPath === filePath } - return { handled: byFile.size > 0, matched, byFile } + return { handled: true, matched, byFile } } function documentPullState() { @@ -369,21 +369,53 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } } - // LATENCY-CRITICAL: dispatch identifier pulls in parallel and return on the first - // resolved batch. Do NOT sequentially await identifier-by-identifier, and do NOT - // add a post-match settle/debounce delay. Servers like Roslyn register many - // diagnostic identifiers (syntax, compiler, analyzer, non-local, ...) and each - // pull is network+compute bound. Sequencing them or waiting for "stability" - // after a match turned `edit`/`apply_patch` UX into 4s+ pauses. See PR #23771. + const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) => + results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0) + + async function requestDiagnostics( + filePath: string, + requests: Promise[], + done: (results: DiagnosticRequestResult[]) => boolean, + ) { + if (!requests.length) return { handled: false, matched: false } + + const results: DiagnosticRequestResult[] = [] + return new Promise<{ handled: boolean; matched: boolean }>((resolve) => { + let pending = requests.length + let resolved = false + const finish = (merged: { handled: boolean; matched: boolean }, force = false) => { + if (resolved) return + if (!force && !done(results)) return + resolved = true + resolve(merged) + } + + for (const request of requests) { + request.then((result) => { + results.push(result) + pending -= 1 + const merged = mergeResults(filePath, results) + finish(merged) + if (pending === 0) finish(merged, true) + }) + } + }) + } + + // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one + // batch already produced diagnostics for the current file. Let slower pulls keep + // merging in the background; do not sequence identifier-by-identifier, and do + // not add a post-match settle/debounce delay. See PR #23771. async function requestDocumentDiagnostics(filePath: string) { const state = documentPullState() if (!state.supported) return { handled: false, matched: false } - return mergeResults( + return requestDiagnostics( filePath, - await Promise.all([ + [ requestDiagnosticReport(filePath), ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), - ]), + ], + (results) => hasCurrentFileDiagnostics(filePath, results), ) } @@ -437,8 +469,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } const schedule = () => { const hit = published.get(request.path) - if (!hit || hit.at < request.after) return + if (!hit) return if (typeof hit.version === "number" && hit.version !== request.version) return + if (hit.at < request.after && hit.version !== request.version) return if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at))) } @@ -452,8 +485,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }) } - async function waitForDocumentDiagnostics(request: { path: string; version: number }) { - const startedAt = Date.now() + async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) { + const startedAt = request.after ?? Date.now() const pushWait = waitForFreshPush({ path: request.path, version: request.version, @@ -474,8 +507,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } } - async function waitForFullDiagnostics(request: { path: string; version: number }) { - const startedAt = Date.now() + async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) { + const startedAt = request.after ?? Date.now() const pushWait = waitForFreshPush({ path: request.path, version: request.version, @@ -588,7 +621,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } return result }, - async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full" }) { + async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number }) { const normalizedPath = Filesystem.normalizePath( path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) @@ -598,10 +631,10 @@ export async function create(input: { serverID: string; server: LSPServer.Handle version: request.version, }) if (request.mode === "document") { - await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version }) + await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) return } - await waitForFullDiagnostics({ path: normalizedPath, version: request.version }) + await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) }, async shutdown() { l.info("shutting down") diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 27106582d91e..4c46cd9aa776 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -364,12 +364,14 @@ export const layer = Layer.effect( yield* Effect.promise(() => Promise.all( clients.map(async (client) => { + const after = Date.now() const version = await client.notify.open({ path: input }) if (!diagnostics) return return client.waitForDiagnostics({ path: input, version, mode: diagnostics, + after, }) }), ).catch((err) => { diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index b4005680b44d..6d33a07225c1 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -11,8 +11,10 @@ let pullConfig = { registrations: [], documentDiagnostics: [], documentDiagnosticsByIdentifier: {}, + documentDelayMsByIdentifier: {}, workspaceDiagnostics: [], workspaceDiagnosticsByIdentifier: {}, + workspaceDelayMsByIdentifier: {}, } function encode(message) { @@ -67,12 +69,12 @@ function maybeRegister(method) { }) } -function delayed(id, result) { - if (!pullConfig.delayMs) { +function delayed(id, result, delayMs = pullConfig.delayMs) { + if (!delayMs) { sendResponse(id, result) return } - setTimeout(() => sendResponse(id, result), pullConfig.delayMs) + setTimeout(() => sendResponse(id, result), delayMs) } function diagnosticsForIdentifier(identifier) { @@ -83,6 +85,14 @@ function workspaceDiagnosticsForIdentifier(identifier) { return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics } +function documentDelayForIdentifier(identifier) { + return pullConfig.documentDelayMsByIdentifier[identifier] ?? pullConfig.delayMs +} + +function workspaceDelayForIdentifier(identifier) { + return pullConfig.workspaceDelayMsByIdentifier[identifier] ?? pullConfig.delayMs +} + function handle(raw) { let data try { @@ -148,14 +158,22 @@ function handle(raw) { registrations: data.params?.registrations ?? [], documentDiagnostics: data.params?.documentDiagnostics ?? [], documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {}, + documentDelayMsByIdentifier: data.params?.documentDelayMsByIdentifier ?? {}, workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [], workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {}, + workspaceDelayMsByIdentifier: data.params?.workspaceDelayMsByIdentifier ?? {}, } registeredCapability = false sendResponse(data.id, null) return } + if (data.method === "test/register-configured-pull-diagnostics") { + maybeRegister(undefined) + sendResponse(data.id, null) + return + } + if (data.method === "test/publish-diagnostics") { sendNotification("textDocument/publishDiagnostics", data.params) return @@ -173,18 +191,26 @@ function handle(raw) { if (data.method === "textDocument/diagnostic") { diagnosticRequestCount += 1 - delayed(data.id, { - kind: "full", - items: diagnosticsForIdentifier(data.params?.identifier ?? ""), - }) + delayed( + data.id, + { + kind: "full", + items: diagnosticsForIdentifier(data.params?.identifier ?? ""), + }, + documentDelayForIdentifier(data.params?.identifier ?? ""), + ) return } if (data.method === "workspace/diagnostic") { diagnosticRequestCount += 1 - delayed(data.id, { - items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""), - }) + delayed( + data.id, + { + items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""), + }, + workspaceDelayForIdentifier(data.params?.identifier ?? ""), + ) return } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 3e4d14f1fddc..7394aa01bd24 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -180,6 +180,53 @@ describe("LSPClient interop", () => { }) }) + test("document mode accepts matching push diagnostics published before waiting", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "const x = 1\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + const version = await client.notify.open({ path: file }) + await client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + version, + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "push diagnostic", + severity: 1, + }, + ], + }) + + for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic") + + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + expect(Date.now() - started).toBeLessThan(1_000) + + await client.shutdown() + }, + }) + }) + test("document mode waits for pull diagnostics", async () => { const handle = spawnFakeServer() as any await using tmp = await tmpdir() @@ -228,6 +275,57 @@ describe("LSPClient interop", () => { }) }) + test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registrations: [{ identifier: "fast" }, { identifier: "slow" }], + documentDiagnosticsByIdentifier: { + fast: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "fast diagnostic", + severity: 1, + }, + ], + slow: [], + }, + documentDelayMsByIdentifier: { + slow: 2_500, + }, + }) + + const version = await client.notify.open({ path: file }) + await client.connection.sendRequest("test/register-configured-pull-diagnostics", {}) + await new Promise((resolve) => setTimeout(resolve, 100)) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + + expect(Date.now() - started).toBeLessThan(1_000) + expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic") + expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1) + + await client.shutdown() + }, + }) + }) + test("full mode includes workspace pull diagnostics", async () => { const handle = spawnFakeServer() as any await using tmp = await tmpdir() @@ -293,4 +391,39 @@ describe("LSPClient interop", () => { }, }) }) + + test("full mode treats an empty workspace pull response as handled", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }], + workspaceDiagnosticsByIdentifier: { + WorkspaceDocumentsAndProject: [], + }, + }) + + const version = await client.notify.open({ path: file }) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "full" }) + + expect(Date.now() - started).toBeLessThan(1_000) + + await client.shutdown() + }, + }) + }) }) From b924b700f1b9a5d089d1dc5c695ba794abcb3a34 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:55:14 +1000 Subject: [PATCH 12/14] fix: stop overstating minimal LSP client support The client was advertising publish-diagnostics version support and workspace diagnostic refresh support without implementing either behavior, and workspace/configuration always returned a single initialization blob instead of one result per requested item. Downgrade the unsupported capability claims and return configuration entries in the shape servers expect so they don't assume features we don't actually have. --- packages/opencode/src/lsp/client.ts | 18 +++++-- .../test/fixture/lsp/fake-lsp-server.js | 22 ++++++++ packages/opencode/test/lsp/client.test.ts | 53 +++++++++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 03b50a0be693..16e2140c1565 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -114,6 +114,15 @@ function dedupeDiagnostics(items: Diagnostic[]) { }) } +function configurationValue(settings: unknown, section?: string) { + if (!section) return settings ?? null + const result = section.split(".").reduce((acc, key) => { + if (!acc || typeof acc !== "object" || !(key in acc)) return undefined + return (acc as Record)[key] + }, settings) + return result ?? null +} + export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -171,8 +180,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle l.info("window/workDoneProgress/create", params) return null }) - connection.onRequest("workspace/configuration", async () => { - return [input.server.initialization ?? {}] + connection.onRequest("workspace/configuration", async (params) => { + const items = (params as { items?: { section?: string }[] }).items ?? [] + return items.map((item) => configurationValue(input.server.initialization, item.section)) }) connection.onRequest("client/registerCapability", async (params) => { const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? [] @@ -227,7 +237,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle dynamicRegistration: true, }, diagnostics: { - refreshSupport: true, + refreshSupport: false, }, }, textDocument: { @@ -240,7 +250,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle relatedDocumentSupport: true, }, publishDiagnostics: { - versionSupport: true, + versionSupport: false, }, }, }, diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index 6d33a07225c1..e6818009e1f6 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -3,8 +3,10 @@ let nextId = 1 let readBuffer = Buffer.alloc(0) let lastChange = null +let initializeParams = null let diagnosticRequestCount = 0 let registeredCapability = false +const pendingClientRequests = new Map() let pullConfig = { delayMs: 0, registerOn: undefined, @@ -101,7 +103,16 @@ function handle(raw) { return } + if (typeof data.method === "undefined" && typeof data.id !== "undefined") { + const pending = pendingClientRequests.get(data.id) + if (!pending) return + pendingClientRequests.delete(data.id) + sendResponse(pending, data.result ?? null) + return + } + if (data.method === "initialize") { + initializeParams = data.params sendResponse(data.id, { capabilities: { textDocumentSync: { @@ -112,6 +123,17 @@ function handle(raw) { return } + if (data.method === "test/get-initialize-params") { + sendResponse(data.id, initializeParams) + return + } + + if (data.method === "test/request-configuration") { + const id = sendRequest("workspace/configuration", data.params) + pendingClientRequests.set(id, data.id) + return + } + if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") { return } diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 7394aa01bd24..4862f6839490 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -91,6 +91,59 @@ describe("LSPClient interop", () => { await client.shutdown() }) + test("initialize does not overclaim unsupported diagnostics capabilities", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + directory: process.cwd(), + }), + }) + + const params = await client.connection.sendRequest("test/get-initialize-params", {}) + expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false) + expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false) + + await client.shutdown() + }) + + test("workspace/configuration returns one result per requested item", async () => { + const handle = spawnFakeServer() as any + const initialization = { + alpha: { + beta: 1, + }, + gamma: true, + } + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: { + ...(handle as unknown as LSPServer.Handle), + initialization, + }, + root: process.cwd(), + directory: process.cwd(), + }), + }) + + const response = await client.connection.sendRequest("test/request-configuration", { + items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}], + }) + + expect(response).toEqual([{ beta: 1 }, 1, null, initialization]) + + await client.shutdown() + }) + test("sends ranged didChange for incremental sync servers", async () => { const handle = spawnFakeServer() as any await using tmp = await tmpdir() From fef0bb4b2ab40d5c984250a6ced8e97569231014 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:40:15 +1000 Subject: [PATCH 13/14] make it debug level --- packages/opencode/src/lsp/client.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 16e2140c1565..1a7986a21660 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -131,13 +131,13 @@ export async function create(input: { serverID: string; server: LSPServer.Handle new StreamMessageReader(input.server.process.stdout as any), new StreamMessageWriter(input.server.process.stdin as any), ) - // LSP servers rarely write to stderr - when they do, it's almost always a - // misconfiguration (wrong binary, bad args, missing deps) that otherwise - // manifests as a silent 45s initialize timeout. Surface it at error level so - // operators don't have to hunt. + // Server stderr can contain both real errors and routine informational logs, + // which is normal stderr practice for some tools. Keep the raw stream at + // debug so users can opt in with --print-logs --log-level DEBUG without + // polluting normal logs. input.server.process.stderr?.on("data", (data: Buffer) => { const text = data.toString().trim() - if (text) l.error("server stderr", { text: text.slice(0, 1000) }) + if (text) l.debug("server stderr", { text: text.slice(0, 1000) }) }) const pushDiagnostics = new Map() From 766be2d07a008bb5ac036f029f7768b3c19f9713 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 23 Apr 2026 09:07:06 +1000 Subject: [PATCH 14/14] tidy --- packages/opencode/src/lsp/client.ts | 64 ++++++++++++++++++++--------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 1a7986a21660..b0418ca3f5e5 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -18,6 +18,13 @@ const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000 const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000 +const INITIALIZE_TIMEOUT_MS = 45_000 + +// LSP spec constants +const FILE_CHANGE_CREATED = 1 +const FILE_CHANGE_CHANGED = 2 +const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 + const log = Log.create({ service: "lsp.client" }) export type Info = NonNullable>> @@ -123,9 +130,16 @@ function configurationValue(settings: unknown, section?: string) { return result ?? null } +// TypeScript's built-in LSP pushes diagnostics aggressively on first open. +// We seed the push cache on the very first publish so waitForFreshPush can +// resolve immediately instead of waiting for a second debounced push. +function shouldSeedDiagnosticsOnFirstPush(serverID: string) { + return serverID === "typescript" +} + export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { - const l = log.clone().tag("serverID", input.serverID) - l.info("starting client") + const logger = log.clone().tag("serverID", input.serverID) + logger.info("starting client") const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), @@ -137,9 +151,11 @@ export async function create(input: { serverID: string; server: LSPServer.Handle // polluting normal logs. input.server.process.stderr?.on("data", (data: Buffer) => { const text = data.toString().trim() - if (text) l.debug("server stderr", { text: text.slice(0, 1000) }) + if (text) logger.debug("server stderr", { text: text.slice(0, 1000) }) }) + // --- Connection state --- + const pushDiagnostics = new Map() const pullDiagnostics = new Map() const published = new Map() @@ -158,10 +174,12 @@ export async function create(input: { serverID: string; server: LSPServer.Handle for (const listener of [...registrationListeners]) listener() } + // --- LSP connection handlers --- + connection.onNotification("textDocument/publishDiagnostics", (params) => { const filePath = getFilePath(params.uri) if (!filePath) return - l.info("textDocument/publishDiagnostics", { + logger.info("textDocument/publishDiagnostics", { path: filePath, count: params.diagnostics.length, version: params.version, @@ -170,14 +188,14 @@ export async function create(input: { serverID: string; server: LSPServer.Handle at: Date.now(), version: typeof params.version === "number" ? params.version : undefined, }) - if (input.serverID === "typescript" && !pushDiagnostics.has(filePath)) { + if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) { pushDiagnostics.set(filePath, params.diagnostics) return } updatePushDiagnostics(filePath, params.diagnostics) }) connection.onRequest("window/workDoneProgress/create", (params) => { - l.info("window/workDoneProgress/create", params) + logger.info("window/workDoneProgress/create", params) return null }) connection.onRequest("workspace/configuration", async (params) => { @@ -213,7 +231,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle connection.onRequest("workspace/diagnostic/refresh", async () => null) connection.listen() - l.info("sending initialize") + // --- Initialize handshake --- + + logger.info("sending initialize") const initialized = await withTimeout( connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { rootUri: pathToFileURL(input.root).href, @@ -255,9 +275,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }, }, }), - 45_000, + INITIALIZE_TIMEOUT_MS, ).catch((err) => { - l.error("initialize error", { error: err }) + logger.error("initialize error", { error: err }) throw new InitializeError( { serverID: input.serverID }, { @@ -279,6 +299,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle const files: Record = {} + // --- Diagnostic helpers --- + const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { const handled = results.some((result) => result.handled) const matched = results.some((result) => result.matched) @@ -539,6 +561,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle } } + // --- Public API --- + const result = { root: input.root, get serverID() { @@ -562,19 +586,19 @@ export async function create(input: { serverID: string; server: LSPServer.Handle // re-emit diagnostics when the content actually changes, so clearing // here would lose errors for no-op touchFile calls. Let the server's // next push/pull overwrite naturally. - log.info("workspace/didChangeWatchedFiles", request) + logger.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(request.path).href, - type: 2, + type: FILE_CHANGE_CHANGED, }, ], }) const next = document.version + 1 files[request.path] = { version: next, text } - log.info("textDocument/didChange", { + logger.info("textDocument/didChange", { path: request.path, version: next, }) @@ -584,7 +608,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle version: next, }, contentChanges: - syncKind === 2 + syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL ? [ { range: { @@ -599,17 +623,17 @@ export async function create(input: { serverID: string; server: LSPServer.Handle return next } - log.info("workspace/didChangeWatchedFiles", request) + logger.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(request.path).href, - type: 1, + type: FILE_CHANGE_CREATED, }, ], }) - log.info("textDocument/didOpen", request) + logger.info("textDocument/didOpen", request) pushDiagnostics.delete(request.path) pullDiagnostics.delete(request.path) await connection.sendNotification("textDocument/didOpen", { @@ -635,7 +659,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle const normalizedPath = Filesystem.normalizePath( path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) - log.info("waiting for diagnostics", { + logger.info("waiting for diagnostics", { path: normalizedPath, mode: request.mode ?? "full", version: request.version, @@ -647,15 +671,15 @@ export async function create(input: { serverID: string; server: LSPServer.Handle await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) }, async shutdown() { - l.info("shutting down") + logger.info("shutting down") connection.end() connection.dispose() await Process.stop(input.server.process) - l.info("shutdown") + logger.info("shutdown") }, } - l.info("initialized") + logger.info("initialized") return result }