From fb5c8523842ce49b28629afdcaa585fa258284c2 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 13 Aug 2026 03:13:00 +0000 Subject: [PATCH] fix(lsp): synchronized applied workspace edits Reconciled open document overlays and watched files after committed WorkspaceEdits so later semantic requests observe disk state. Fixes #8372 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/lsp/client.ts | 88 +++++++++++++- packages/coding-agent/src/lsp/tool.ts | 7 +- .../test/tools/lsp-regressions.test.ts | 107 ++++++++++++++++++ 4 files changed, 201 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b387e76fd..6acbea275 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed applied LSP `WorkspaceEdit`s leaving already-open language-server document overlays stale for later semantic requests ([#8372](https://github.com/can1357/oh-my-pi/issues/8372)). + ## [17.2.15] - 2026-08-12 ### Added diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index e53c12fbf..f24207616 100644 --- a/packages/coding-agent/src/lsp/client.ts +++ b/packages/coding-agent/src/lsp/client.ts @@ -17,7 +17,7 @@ import type { ServerConfig, WorkspaceEdit, } from "./types"; -import { detectLanguageId, EquivalentUriMap, fileToUri } from "./utils"; +import { detectLanguageId, EquivalentUriMap, fileToUri, uriToFile } from "./utils"; // ============================================================================= // Client State @@ -484,13 +484,97 @@ async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequ } try { - await applyWorkspaceEdit(params.edit, client.cwd); + await applyWorkspaceEditWithLsp(params.edit, client.cwd); await sendResponse(client, message.id, { applied: true }, "workspace/applyEdit"); } catch (err) { await sendResponse(client, message.id, { applied: false, failureReason: String(err) }, "workspace/applyEdit"); } } +function workspaceEditChanges(edit: WorkspaceEdit): { + finalUris: Set; + deletedRoots: Set; + watchedFiles: WatchedFileChange[]; +} { + const finalUris = new Set(); + const deletedRoots = new Set(); + const watchedFiles: WatchedFileChange[] = []; + const watch = (uri: string, type: FileChangeType) => { + watchedFiles.push({ filePath: uriToFile(uri), type }); + }; + + if (edit.changes) { + for (const uri in edit.changes) { + if (edit.changes[uri].length === 0) continue; + finalUris.add(uri); + watch(uri, FileChangeType.Changed); + } + } + for (const change of edit.documentChanges ?? []) { + if ("textDocument" in change) { + if (change.edits.length === 0) continue; + finalUris.add(change.textDocument.uri); + watch(change.textDocument.uri, FileChangeType.Changed); + } else if (change.kind === "create") { + finalUris.add(change.uri); + watch(change.uri, FileChangeType.Created); + } else if (change.kind === "rename") { + deletedRoots.add(change.oldUri); + finalUris.add(change.newUri); + watch(change.oldUri, FileChangeType.Deleted); + watch(change.newUri, FileChangeType.Created); + } else if (change.kind === "delete") { + deletedRoots.add(change.uri); + watch(change.uri, FileChangeType.Deleted); + } + } + + return { finalUris, deletedRoots, watchedFiles }; +} + +function uriIsWithin(uri: string, root: string): boolean { + return uri === root || uri.startsWith(root.endsWith("/") ? root : `${root}/`); +} + +/** + * Apply a server-provided workspace edit and reconcile every affected open LSP document. + * Runtime callers use this wrapper so later semantic requests observe the committed files. + */ +export async function applyWorkspaceEditWithLsp( + edit: WorkspaceEdit, + cwd: string, + signal?: AbortSignal, +): Promise { + const applied = await applyWorkspaceEdit(edit, cwd); + const { finalUris, deletedRoots, watchedFiles } = workspaceEditChanges(edit); + const workspace = path.resolve(cwd); + const activeClients = Array.from(clients.values()).filter( + client => client.status === "ready" && path.resolve(client.cwd) === workspace, + ); + + for (const activeClient of activeClients) { + for (const uri of [...activeClient.openFiles.keys()]) { + let deleted = false; + for (const root of deletedRoots) { + if (uriIsWithin(uri, root)) { + deleted = true; + break; + } + } + if (!deleted) continue; + await sendNotification(activeClient, "textDocument/didClose", { textDocument: { uri } }, signal); + activeClient.openFiles.delete(uri); + activeClient.diagnostics.delete(uri); + } + for (const uri of finalUris) { + if (!activeClient.openFiles.has(uri)) continue; + await refreshFile(activeClient, uriToFile(uri), signal); + } + } + await notifyWorkspaceWatchedFiles(cwd, watchedFiles, signal); + return applied; +} + interface DynamicCapabilityRegistration { id?: unknown; method?: unknown; diff --git a/packages/coding-agent/src/lsp/tool.ts b/packages/coding-agent/src/lsp/tool.ts index 75394ad1a..5e1c39635 100644 --- a/packages/coding-agent/src/lsp/tool.ts +++ b/packages/coding-agent/src/lsp/tool.ts @@ -16,6 +16,7 @@ import { formatPathRelativeToCwd, resolveToCwd } from "../tools/path-utils"; import { ToolAbortError, ToolError, throwIfAborted } from "../tools/tool-errors"; import { clampTimeout } from "../tools/tool-timeouts"; import { + applyWorkspaceEditWithLsp, clearInitializationFailure, ensureFileOpen, getActiveClients, @@ -43,7 +44,7 @@ import { WORKSPACE_SYMBOL_LIMIT, waitForDiagnostics, } from "./diagnostics"; -import { applyTextEdits, applyWorkspaceEdit, flattenWorkspaceTextEdits, rangesOverlap } from "./edits"; +import { applyTextEdits, flattenWorkspaceTextEdits, rangesOverlap } from "./edits"; import { detectLspmux } from "./lspmux"; import { configCache, @@ -1208,7 +1209,7 @@ export class LspTool implements AgentTool (await sendRequest(client, "codeAction/resolve", actionItem, signal)) as CodeAction, - applyWorkspaceEdit: async edit => applyWorkspaceEdit(edit, this.session.cwd), + applyWorkspaceEdit: async edit => applyWorkspaceEditWithLsp(edit, this.session.cwd, signal), executeCommand: async commandItem => { await sendRequest( client, @@ -1304,7 +1305,7 @@ export class LspTool implements AgentTool ` ${a}`).join("\n")}`; } else { const preview = formatWorkspaceEdit(result, this.session.cwd); diff --git a/packages/coding-agent/test/tools/lsp-regressions.test.ts b/packages/coding-agent/test/tools/lsp-regressions.test.ts index 2d905928c..139665745 100644 --- a/packages/coding-agent/test/tools/lsp-regressions.test.ts +++ b/packages/coding-agent/test/tools/lsp-regressions.test.ts @@ -2333,6 +2333,113 @@ describe("lsp regressions", () => { } }); + it("synchronizes open document overlays after applying a rename workspace edit", async () => { + const tempDir = TempDir.createSync("@omp-lsp-workspace-edit-sync-"); + const filePath = path.join(tempDir.path(), "main.go"); + const uri = fileToUri(filePath); + let overlay = ""; + try { + await Bun.write(filePath, "package main\nfunc OldName() {}\n"); + const serverConfig: ServerConfig = { + command: "fake-gopls", + fileTypes: ["go"], + rootMarkers: [], + isLinter: true, + }; + const server = installFakeLsp((message, srv) => { + if (message.method === "initialize") { + srv.send({ + jsonrpc: "2.0", + id: message.id, + result: { capabilities: { documentSymbolProvider: true, renameProvider: true } }, + }); + } else if (message.method === "textDocument/didOpen") { + if ( + typeof message.params === "object" && + message.params !== null && + "textDocument" in message.params && + typeof message.params.textDocument === "object" && + message.params.textDocument !== null && + "text" in message.params.textDocument && + typeof message.params.textDocument.text === "string" + ) { + overlay = message.params.textDocument.text; + } + } else if (message.method === "textDocument/didChange") { + if ( + typeof message.params === "object" && + message.params !== null && + "contentChanges" in message.params && + Array.isArray(message.params.contentChanges) && + typeof message.params.contentChanges[0] === "object" && + message.params.contentChanges[0] !== null && + "text" in message.params.contentChanges[0] && + typeof message.params.contentChanges[0].text === "string" + ) { + overlay = message.params.contentChanges[0].text; + } + } else if (message.method === "textDocument/documentSymbol") { + const name = overlay.includes("NewName") ? "NewName" : "OldName"; + srv.send({ + jsonrpc: "2.0", + id: message.id, + result: [ + { + name, + kind: 12, + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 17 } }, + selectionRange: { start: { line: 1, character: 5 }, end: { line: 1, character: 12 } }, + }, + ], + }); + } else if (message.method === "textDocument/rename") { + srv.send({ + jsonrpc: "2.0", + id: message.id, + result: { + changes: { + [uri]: [ + { + range: { start: { line: 1, character: 5 }, end: { line: 1, character: 12 } }, + newText: "NewName", + }, + ], + }, + }, + }); + } else if (message.method === "shutdown") { + srv.send({ jsonrpc: "2.0", id: message.id, result: null }); + } else if (message.method === "exit") { + srv.exit(0); + } + }); + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ + servers: { "fake-gopls": serverConfig }, + idleTimeoutMs: undefined, + }); + + const tool = new LspTool(makeLspSession(tempDir.path())); + expect( + textResult(await tool.execute("symbols-before-rename", { action: "symbols", file: filePath })), + ).toContain("OldName"); + await tool.execute("rename-open-document", { + action: "rename", + file: filePath, + line: 2, + symbol: "OldName", + new_name: "NewName", + }); + expect(await Bun.file(filePath).text()).toContain("NewName"); + + const symbols = await tool.execute("symbols-after-rename", { action: "symbols", file: filePath }); + expect(textResult(symbols)).toContain("NewName"); + } finally { + configCache.delete(tempDir.path()); + await lspClient.shutdownAll(); + tempDir.removeSync(); + } + }); + it("flushes pending descendant text edits before a folder rename", async () => { const tempDir = TempDir.createSync("@omp-lsp-folder-rename-"); try {