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);