Kaynağa Gözat

feat: support pull diagnostics in the LSP client (C#, Kotlin, etc) (#23771)

Luke Parker 4 ay önce
ebeveyn
işleme
e383df4b17

+ 1 - 2
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()
           }),
         ),

+ 505 - 69
packages/opencode/src/lsp/client.ts

@@ -14,6 +14,16 @@ 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 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" })
 
@@ -38,48 +48,194 @@ export const Event = {
   ),
 }
 
+type DocumentDiagnosticReport = {
+  items?: Diagnostic[]
+  relatedDocuments?: Record<string, DocumentDiagnosticReport>
+}
+
+type WorkspaceDiagnosticReport = {
+  items?: {
+    uri?: string
+    items?: Diagnostic[]
+  }[]
+}
+
+type DiagnosticRequestResult = {
+  handled: boolean
+  matched: boolean
+  byFile: Map<string, Diagnostic[]>
+}
+
+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<string>()
+  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
+  })
+}
+
+function configurationValue(settings: unknown, section?: string) {
+  if (!section) return settings ?? null
+  const result = section.split(".").reduce<unknown>((acc, key) => {
+    if (!acc || typeof acc !== "object" || !(key in acc)) return undefined
+    return (acc as Record<string, unknown>)[key]
+  }, settings)
+  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),
     new StreamMessageWriter(input.server.process.stdin as any),
   )
+  // 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) logger.debug("server stderr", { text: text.slice(0, 1000) })
+  })
+
+  // --- Connection state ---
+
+  const pushDiagnostics = new Map<string, Diagnostic[]>()
+  const pullDiagnostics = new Map<string, Diagnostic[]>()
+  const published = new Map<string, { at: number; version?: number }>()
+  const diagnosticRegistrations = new Map<string, CapabilityRegistration>()
+  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()
+  }
+
+  // --- LSP connection handlers ---
 
-  const diagnostics = new Map<string, Diagnostic[]>()
   connection.onNotification("textDocument/publishDiagnostics", (params) => {
-    const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
-    l.info("textDocument/publishDiagnostics", {
+    const filePath = getFilePath(params.uri)
+    if (!filePath) return
+    logger.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 (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 () => {
-    // Return server initialization options
-    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 ?? []
+    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("client/registerCapability", async () => {})
-  connection.onRequest("client/unregisterCapability", async () => {})
   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", {
+  // --- Initialize handshake ---
+
+  logger.info("sending initialize")
+  const initialized = await withTimeout(
+    connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
       rootUri: pathToFileURL(input.root).href,
       processId: input.server.process.pid,
       workspaceFolders: [
@@ -100,21 +256,28 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
           didChangeWatchedFiles: {
             dynamicRegistration: true,
           },
+          diagnostics: {
+            refreshSupport: false,
+          },
         },
         textDocument: {
           synchronization: {
             didOpen: true,
             didChange: true,
           },
+          diagnostic: {
+            dynamicRegistration: true,
+            relatedDocumentSupport: true,
+          },
           publishDiagnostics: {
-            versionSupport: true,
+            versionSupport: false,
           },
         },
       },
     }),
-    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 },
       {
@@ -123,6 +286,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 +297,271 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
     })
   }
 
-  const files: {
-    [path: string]: number
-  } = {}
+  const files: Record<string, { version: number; text: string }> = {}
+
+  // --- Diagnostic helpers ---
+
+  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<string, Diagnostic[]>()
+    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<DiagnosticRequestResult> {
+    const report = await withTimeout(
+      connection.sendRequest<DocumentDiagnosticReport | null>("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<string, Diagnostic[]>() }
+
+    const byFile = new Map<string, Diagnostic[]>()
+    const push = (target: string, items: Diagnostic[]) => {
+      const existing = byFile.get(target) ?? []
+      byFile.set(target, existing.concat(items))
+    }
+
+    let handled = false
+    let matched = false
+    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 || !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<DiagnosticRequestResult> {
+    const report = await withTimeout(
+      connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
+        ...(identifier ? { identifier } : {}),
+        previousResultIds: [],
+      }),
+      DIAGNOSTICS_REQUEST_TIMEOUT_MS,
+    ).catch(() => null)
+    if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() }
+
+    const byFile = new Map<string, Diagnostic[]>()
+    let matched = false
+    for (const item of report.items ?? []) {
+      const relatedPath = item.uri ? getFilePath(item.uri) : undefined
+      if (!relatedPath || !Array.isArray(item.items)) continue
+      const existing = byFile.get(relatedPath) ?? []
+      byFile.set(relatedPath, existing.concat(item.items))
+      matched = matched || relatedPath === filePath
+    }
+
+    return { handled: true, 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,
+    }
+  }
+
+  const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
+    results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0)
+
+  async function requestDiagnostics(
+    filePath: string,
+    requests: Promise<DiagnosticRequestResult>[],
+    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 requestDiagnostics(
+      filePath,
+      [
+        requestDiagnosticReport(filePath),
+        ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
+      ],
+      (results) => hasCurrentFileDiagnostics(filePath, results),
+    )
+  }
+
+  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<boolean>((resolve) => {
+      let finished = false
+      let timer: ReturnType<typeof setTimeout> | 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<boolean>((resolve) => {
+      let finished = false
+      let debounceTimer: ReturnType<typeof setTimeout> | undefined
+      let timeoutTimer: ReturnType<typeof setTimeout> | 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) 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)))
+      }
+
+      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; after?: number }) {
+    const startedAt = request.after ?? 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; after?: number }) {
+    const startedAt = request.after ?? 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
+    }
+  }
+
+  // --- Public API ---
 
   const result = {
     root: input.root,
@@ -145,26 +573,32 @@ 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) {
-          log.info("workspace/didChangeWatchedFiles", request)
+        const document = files[request.path]
+        if (document !== undefined) {
+          // 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.
+          logger.info("workspace/didChangeWatchedFiles", request)
           await connection.sendNotification("workspace/didChangeWatchedFiles", {
             changes: [
               {
                 uri: pathToFileURL(request.path).href,
-                type: 2, // Changed
+                type: FILE_CHANGE_CHANGED,
               },
             ],
           })
 
-          const next = version + 1
-          files[request.path] = next
-          log.info("textDocument/didChange", {
+          const next = document.version + 1
+          files[request.path] = { version: next, text }
+          logger.info("textDocument/didChange", {
             path: request.path,
             version: next,
           })
@@ -173,23 +607,35 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
               uri: pathToFileURL(request.path).href,
               version: next,
             },
-            contentChanges: [{ text }],
+            contentChanges:
+              syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
+                ? [
+                    {
+                      range: {
+                        start: { line: 0, character: 0 },
+                        end: endPosition(document.text),
+                      },
+                      text,
+                    },
+                  ]
+                : [{ text }],
           })
-          return
+          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, // Created
+              type: FILE_CHANGE_CREATED,
             },
           ],
         })
 
-        log.info("textDocument/didOpen", request)
-        diagnostics.delete(request.path)
+        logger.info("textDocument/didOpen", request)
+        pushDiagnostics.delete(request.path)
+        pullDiagnostics.delete(request.path)
         await connection.sendNotification("textDocument/didOpen", {
           textDocument: {
             uri: pathToFileURL(request.path).href,
@@ -198,52 +644,42 @@ 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<string, Diagnostic[]>()
+      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"; after?: number }) {
       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<typeof setTimeout> | undefined
-      return await withTimeout(
-        new Promise<void>((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?.()
-        })
+      logger.info("waiting for diagnostics", {
+        path: normalizedPath,
+        mode: request.mode ?? "full",
+        version: request.version,
+      })
+      if (request.mode === "document") {
+        await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
+        return
+      }
+      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
 }

+ 11 - 5
packages/opencode/src/lsp/lsp.ts

@@ -136,7 +136,7 @@ export interface Interface {
   readonly init: () => Effect.Effect<void>
   readonly status: () => Effect.Effect<Status[]>
   readonly hasClients: (file: string) => Effect.Effect<boolean>
-  readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
+  readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect<void>
   readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
   readonly hover: (input: LocInput) => Effect.Effect<any>
   readonly definition: (input: LocInput) => Effect.Effect<any[]>
@@ -358,15 +358,21 @@ 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 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) => {
           log.error("failed to touch file", { err, file: input })

+ 1 - 1
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
     }

+ 9 - 6
packages/opencode/src/npm/index.ts

@@ -34,7 +34,7 @@ export interface Interface {
     },
   ) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
   readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
-  readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
+  readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@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<string>()
+        // 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<string>()
         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<string, string> }
           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])
           }
         }
 

+ 1 - 1
packages/opencode/src/tool/apply_patch.ts

@@ -258,7 +258,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()
 

+ 1 - 1
packages/opencode/src/tool/edit.ts

@@ -186,7 +186,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] ?? [])

+ 1 - 1
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) {

+ 1 - 1
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* (

+ 1 - 1
packages/opencode/src/tool/write.ts

@@ -67,7 +67,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

+ 197 - 23
packages/opencode/test/fixture/lsp/fake-lsp-server.js

@@ -1,7 +1,23 @@
 // 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 initializeParams = null
+let diagnosticRequestCount = 0
+let registeredCapability = false
+const pendingClientRequests = new Map()
+let pullConfig = {
+  delayMs: 0,
+  registerOn: undefined,
+  registrations: [],
+  documentDiagnostics: [],
+  documentDiagnosticsByIdentifier: {},
+  documentDelayMsByIdentifier: {},
+  workspaceDiagnostics: [],
+  workspaceDiagnosticsByIdentifier: {},
+  workspaceDelayMsByIdentifier: {},
+}
 
 function encode(message) {
   const json = JSON.stringify(message)
@@ -14,29 +30,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 +51,50 @@ 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, delayMs = pullConfig.delayMs) {
+  if (!delayMs) {
+    sendResponse(id, result)
+    return
+  }
+  setTimeout(() => sendResponse(id, result), delayMs)
+}
+
+function diagnosticsForIdentifier(identifier) {
+  return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics
+}
+
+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 {
@@ -52,24 +102,148 @@ function handle(raw) {
   } catch {
     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") {
-    send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } })
+    initializeParams = data.params
+    sendResponse(data.id, {
+      capabilities: {
+        textDocumentSync: {
+          change: 2,
+        },
+      },
+    })
     return
   }
-  if (data.method === "initialized") {
+
+  if (data.method === "test/get-initialize-params") {
+    sendResponse(data.id, initializeParams)
     return
   }
-  if (data.method === "workspace/didChangeConfiguration") {
+
+  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
   }
+
+  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 ?? {},
+      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
+  }
+
+  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 ?? ""),
+      },
+      documentDelayForIdentifier(data.params?.identifier ?? ""),
+    )
+    return
+  }
+
+  if (data.method === "workspace/diagnostic") {
+    diagnosticRequestCount += 1
+    delayed(
+      data.id,
+      {
+        items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""),
+      },
+      workspaceDelayForIdentifier(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)
+})

+ 394 - 10
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,397 @@ 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("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<any>("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<any[]>("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()
+    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 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()
+    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("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()
+    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()
+      },
+    })
+  })
+
+  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()
+      },
+    })
+  })
 })