diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 47e1183a5..7504877bd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed LSP servers that dynamically register capabilities, including Expert, hanging semantic requests after `client/registerCapability` was rejected ([#3029](https://github.com/can1357/oh-my-pi/issues/3029)). + ## [16.1.0] - 2026-06-19 ### Added diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index a2b6293e1..3a4ab3af0 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -5,6 +5,7 @@ import { applyWorkspaceEdit } from "./edits"; import { getLspmuxCommand, isLspmuxSupported } from "./lspmux"; import type { LspClient, + LspJsonRpcId, LspJsonRpcNotification, LspJsonRpcRequest, LspJsonRpcResponse, @@ -416,7 +417,6 @@ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name: * Handle workspace/workspaceFolders requests from the server. */ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders"); } @@ -424,7 +424,6 @@ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJson * Handle workspace/configuration requests from the server. */ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; const params = message.params as { items?: Array<{ section?: string }> }; const items = params?.items ?? []; const result = items.map(item => { @@ -438,7 +437,6 @@ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpc * Handle workspace/applyEdit requests from the server. */ async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequest): Promise { - if (typeof message.id !== "number") return; const params = message.params as { edit?: WorkspaceEdit }; if (!params?.edit) { await sendResponse( @@ -475,13 +473,15 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest return; } if (message.method === "window/workDoneProgress/create") { - // Accept progress token registration from the server - if (typeof message.id === "number") { - await sendResponse(client, message.id, null, message.method); - } + // Accept progress token registration from the server. + await sendResponse(client, message.id, null, message.method); + return; + } + if (message.method === "client/registerCapability" || message.method === "client/unregisterCapability") { + // Some servers block semantic requests until dynamic registration succeeds. + await sendResponse(client, message.id, null, message.method); return; } - if (typeof message.id !== "number") return; await sendResponse(client, message.id, null, message.method, { code: -32601, message: `Method not found: ${message.method}`, @@ -493,7 +493,7 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest */ async function sendResponse( client: LspClient, - id: number, + id: LspJsonRpcId, result: unknown, method: string, error?: { code: number; message: string; data?: unknown }, diff --git a/packages/coding-agent/src/lsp/types.ts b/packages/coding-agent/src/lsp/types.ts index d33b8ccca..13f2c6b80 100644 --- a/packages/coding-agent/src/lsp/types.ts +++ b/packages/coding-agent/src/lsp/types.ts @@ -399,7 +399,7 @@ export interface LspClient { diagnostics: Map; diagnosticsVersion: number; openFiles: Map; - pendingRequests: Map; + pendingRequests: Map; messageBuffer: Uint8Array; isReading: boolean; /** Lifecycle state: "connecting" until initialize completes, then "ready"; "error" on init failure or reader death. */ @@ -420,16 +420,19 @@ export interface LspClient { // JSON-RPC Protocol Types // ============================================================================= +/** JSON-RPC request/response identifier accepted by LSP peers. */ +export type LspJsonRpcId = number | string; + export interface LspJsonRpcRequest { jsonrpc: "2.0"; - id: number; + id: LspJsonRpcId; method: string; params: unknown; } export interface LspJsonRpcResponse { jsonrpc: "2.0"; - id?: number; + id?: LspJsonRpcId; result?: unknown; error?: { code: number; message: string; data?: unknown }; } diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 768fddd49..45039443e 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -308,6 +308,78 @@ describe("lsp regressions", () => { } }); + it("accepts dynamic capability registration before semantic requests", async () => { + const tempDir = TempDir.createSync("@omp-lsp-dynamic-registration-"); + try { + let dynamicRegistrationAccepted = false; + const server = installFakeLsp((message, srv) => { + if (message.method === "initialize") { + srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: { hoverProvider: true } } }); + } else if (message.method === "initialized") { + srv.send({ + jsonrpc: "2.0", + id: 9002, + method: "client/registerCapability", + params: { + registrations: [ + { + id: "-42", + method: "workspace/didChangeWatchedFiles", + registerOptions: { + watchers: [{ globPattern: "**/mix.lock" }, { globPattern: "**/*.{ex,exs}" }], + }, + }, + ], + }, + }); + srv.send({ + jsonrpc: "2.0", + id: "expert-unregister-1", + method: "client/unregisterCapability", + params: { unregisterations: [{ id: "-42", method: "workspace/didChangeWatchedFiles" }] }, + }); + } else if (message.id === 9002 && message.method === undefined) { + dynamicRegistrationAccepted = message.error === undefined; + } else if (message.method === "textDocument/hover" && dynamicRegistrationAccepted) { + srv.send({ jsonrpc: "2.0", id: message.id, result: { contents: "Atas.version()" } }); + } else if (message.method === "shutdown") { + srv.send({ jsonrpc: "2.0", id: message.id, result: null }); + } else if (message.method === "exit") { + srv.exit(0); + } + }); + + const config: ServerConfig = { + command: "fake-lsp", + fileTypes: ["ex"], + rootMarkers: [], + }; + + const client = await lspClient.getOrCreateClient(config, tempDir.path(), 1_000); + const registerResponse = await server.waitFor(message => message.id === 9002 && message.method === undefined); + const unregisterResponse = await server.waitFor( + message => message.id === "expert-unregister-1" && message.method === undefined, + ); + expect(registerResponse.error).toBeUndefined(); + expect(unregisterResponse.error).toBeUndefined(); + const result = await lspClient.sendRequest( + client, + "textDocument/hover", + { + textDocument: { uri: fileToUri(path.join(tempDir.path(), "lib", "atas.ex")) }, + position: { line: 0, character: 0 }, + }, + undefined, + 50, + ); + + expect(result).toEqual({ contents: "Atas.version()" }); + } finally { + await lspClient.shutdownAll(); + tempDir.removeSync(); + } + }); + it("opens rust-analyzer Cargo workspace files before polling workspace readiness", async () => { const tempDir = TempDir.createSync("@omp-lsp-rust-workspace-"); try {