From 8b120cecc567ae27adce464a694f3fd56cfc4604 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 4 Aug 2026 23:51:32 +0000 Subject: [PATCH] fix(lsp): normalized published diagnostic uri keys Canonicalized file URI keys at the diagnostics map boundary so server and client spellings match across percent encoding and Windows casing. Added regression coverage for equivalent percent-encoded URIs and the marksman Windows drive-letter form. Fixes #7662 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/src/lsp/client.ts | 4 +- packages/coding-agent/src/lsp/utils.ts | 29 +++++++++++ .../tools/lsp-diagnostics-freshness.test.ts | 49 ++++++++++++++++++- 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 00b5106e4..ea8b1a407 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Restored the legacy project-scoped session directory naming scheme and removed its automatic migration ([#7646](https://github.com/can1357/oh-my-pi/issues/7646)). +### Fixed + +- Fixed LSP diagnostics being dropped when servers normalize file URI percent-encoding or Windows path casing. + ## [17.2.8] - 2026-08-04 ### Changed diff --git a/packages/coding-agent/src/lsp/client.ts b/packages/coding-agent/src/lsp/client.ts index f4df6cb7b..a90b4fd02 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, fileToUri } from "./utils"; +import { detectLanguageId, EquivalentUriMap, fileToUri } from "./utils"; // ============================================================================= // Client State @@ -787,7 +787,7 @@ export async function getOrCreateClient( proc, config, requestId: 0, - diagnostics: new Map(), + diagnostics: new EquivalentUriMap(), diagnosticsVersion: 0, dynamicCapabilityRegistrations: new Map(), openFiles: new Map(), diff --git a/packages/coding-agent/src/lsp/utils.ts b/packages/coding-agent/src/lsp/utils.ts index d9eeac7ac..d96f7f97b 100644 --- a/packages/coding-agent/src/lsp/utils.ts +++ b/packages/coding-agent/src/lsp/utils.ts @@ -78,6 +78,35 @@ function laxUriToFile(uri: string): string { return filePath; } +/** Map that treats equivalent file URI spellings as the same key. */ +export class EquivalentUriMap extends Map { + #key(uri: string): string { + if (!uri.startsWith("file://")) return uri; + const filePath = path.normalize(uriToFile(uri)); + return process.platform === "win32" ? filePath.toLowerCase() : filePath; + } + + override delete(uri: string): boolean { + const key = this.#key(uri); + return super.delete(key); + } + + override get(uri: string): Value | undefined { + const key = this.#key(uri); + return super.get(key); + } + + override has(uri: string): boolean { + const key = this.#key(uri); + return super.has(key); + } + + override set(uri: string, value: Value): this { + const key = this.#key(uri); + return super.set(key, value); + } +} + // ============================================================================= // Diagnostic Formatting // ============================================================================= diff --git a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts index 13ed0a57c..4f967c1ad 100644 --- a/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts +++ b/packages/coding-agent/test/tools/lsp-diagnostics-freshness.test.ts @@ -5,7 +5,7 @@ import { createLspWritethrough, type FileDiagnosticsResult, FileFormatResult } f import * as lspClient from "@oh-my-pi/pi-coding-agent/lsp/client"; import * as lspConfig from "@oh-my-pi/pi-coding-agent/lsp/config"; import type { Diagnostic, LinterClient, LspClient, ServerConfig } from "@oh-my-pi/pi-coding-agent/lsp/types"; -import { fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils"; +import { EquivalentUriMap, fileToUri } from "@oh-my-pi/pi-coding-agent/lsp/utils"; import type { DeferredDiagnosticsEntry, ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write"; import { type ptree, TempDir } from "@oh-my-pi/pi-utils"; @@ -47,7 +47,7 @@ function createClient(cwd: string, config: ServerConfig): LspClient { config, proc: {} as ptree.ChildProcess<"pipe">, requestId: 0, - diagnostics: new Map(), + diagnostics: new EquivalentUriMap(), diagnosticsVersion: 0, openFiles: new Map(), pendingRequests: new Map(), @@ -438,6 +438,51 @@ describe("LSP diagnostics freshness", () => { expect(result?.messages.some(m => m.includes("stale error"))).toBe(false); }); + it("matches published diagnostics when the server renormalizes the document URI", async () => { + const filePath = path.join(tempDir.path(), "renormalized.ts"); + const uri = fileToUri(filePath); + const serverUri = uri.replace("/renormalized.ts", "/%72enormalized.ts"); + const client = createClient(tempDir.path(), TEST_SERVER); + const clock = new VirtualClock(Date.now()); + installVirtualTime(clock); + + vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined }); + vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", TEST_SERVER]]); + vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client); + vi.spyOn(lspClient, "syncContent").mockImplementation(async (mockClient, syncedFilePath) => { + const syncedUri = fileToUri(syncedFilePath); + mockClient.openFiles.set(syncedUri, { version: 1, languageId: "typescript" }); + }); + vi.spyOn(lspClient, "notifySaved").mockImplementation(async mockClient => { + clock.in(10, () => { + publishDiagnostics(mockClient, serverUri, [createDiagnostic("renormalized URI error")], 1); + }); + }); + + const writethrough = createLspWritethrough(tempDir.path(), { + enableFormat: false, + enableDiagnostics: true, + }); + const result = await writethrough(filePath, "export const value = missing;\n"); + + expect(result?.errored).toBe(true); + expect(result?.messages.some(message => message.includes("renormalized URI error"))).toBe(true); + }); + + it("matches Windows drive-letter case and percent-encoding differences", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + if (!platformDescriptor) throw new Error("process.platform descriptor is unavailable"); + Object.defineProperty(process, "platform", { ...platformDescriptor, value: "win32" }); + try { + const diagnostics = new EquivalentUriMap(); + diagnostics.set("file:///c%3A/Users/serge/doc.md", "published"); + + expect(diagnostics.get("file:///C:/Users/serge/doc.md")).toBe("published"); + } finally { + Object.defineProperty(process, "platform", platformDescriptor); + } + }); + it("returns completed pull diagnostics inside the inline write window", async () => { const filePath = path.join(tempDir.path(), "pull-only.ts"); const uri = fileToUri(filePath);