Files
oh-my-pi/packages/coding-agent/test/tools/lsp-regressions.test.ts
T
roboomp dce0216bcb fix(lsp): fail diagnostics when every applicable server fails
The direct diagnostics loop caught non-abort server errors without recording them, so a file whose every applicable server failed produced an empty aggregate rendered as OK with success: true — a false-negative hiding a total diagnostics failure.

Track per-file and global success/failure counts: zero successful server responses now yields success: false with an explicit failure line, while partial success still surfaces diagnostics and names the servers that failed.

Fixes #8377
2026-08-13 03:07:07 +00:00

3535 lines
123 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { AgentToolResult, RenderResultOptions } from "@oh-my-pi/pi-agent-core";
import { arkToWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { preloadPluginRoots } from "@oh-my-pi/pi-coding-agent/discovery/helpers";
import { LspTool } from "@oh-my-pi/pi-coding-agent/lsp";
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 { getServersForFile, type LspConfig, loadConfig } from "@oh-my-pi/pi-coding-agent/lsp/config";
import {
applyTextEditsToString,
applyWorkspaceEdit,
sortAndValidateTextEdits,
} from "@oh-my-pi/pi-coding-agent/lsp/edits";
import { renderCall, renderResult } from "@oh-my-pi/pi-coding-agent/lsp/render";
import { configCache, getConfig } from "@oh-my-pi/pi-coding-agent/lsp/servers";
import {
type CodeAction,
type CreateFile,
type DeleteFile,
type Diagnostic,
type LspClient,
type LspToolDetails,
lspSchema,
type RenameFile,
type ServerConfig,
type SymbolInformation,
type TextDocumentEdit,
type WorkspaceEdit,
} from "@oh-my-pi/pi-coding-agent/lsp/types";
import {
applyCodeAction,
collectGlobMatches,
dedupeWorkspaceSymbols,
detectLanguageId,
fileToUri,
filterWorkspaceSymbols,
hasGlobPattern,
resolveDiagnosticTargets,
resolveSymbolColumn,
uriToFile,
} from "@oh-my-pi/pi-coding-agent/lsp/utils";
import { getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors";
import { clampTimeout } from "@oh-my-pi/pi-coding-agent/tools/tool-timeouts";
import * as piUtils from "@oh-my-pi/pi-utils";
import { sanitizeText, TempDir } from "@oh-my-pi/pi-utils";
import type { Subprocess } from "bun";
import DEFAULTS from "../../src/lsp/defaults.json" with { type: "json" };
import { renderResult as renderLocalResult } from "../../src/lsp/render";
import { getLanguageFromPath } from "../../src/utils/lang-from-path";
/** Minimal LSP tool session: production always supplies `settings`; these tests only need cwd + a default settings stub. */
function makeLspSession(cwd: string): ToolSession {
return { cwd, settings: Settings.isolated() } as ToolSession;
}
interface RpcMessage {
jsonrpc?: string;
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: { code: number; message?: string };
}
interface FakeLspServer {
/** Parsed JSON-RPC messages the client wrote to the server, in arrival order. */
readonly received: RpcMessage[];
/** Server -> client: frame and enqueue a JSON-RPC message onto stdout. */
send(message: RpcMessage): void;
/** Resolve the process `exited` promise and close stdout. */
exit(code?: number): void;
/** Fail stdout without exiting the process. */
failStdout(error: Error): void;
/** Whether the client invoked `proc.kill()` (production's hard-kill fallback). */
readonly killed: boolean;
/** Resolve once a received message matches `predicate` (already-seen or future). */
waitFor(predicate: (message: RpcMessage) => boolean, timeoutMs?: number): Promise<RpcMessage>;
}
type FakeLspHandler = (message: RpcMessage, server: FakeLspServer) => void | Promise<void>;
interface FakeLspOptions {
killResolvesExit?: boolean;
stderr?: string;
stdoutClosesBeforeExit?: boolean;
}
// In-memory LSP transport fake. Replaces the real subprocess (`ptree.spawn`)
// with an in-process JSON-RPC peer so the initialize / shutdown / exit and
// workspace-folder handshakes resolve deterministically -- no subprocess spawn,
// no real-clock latency. Installed by spying on the shared `ptree` namespace
// object (NOT `mock.module`, which would leak across files); the suite's
// `afterEach` `vi.restoreAllMocks()` removes it.
function installFakeLsp(handler: FakeLspHandler, options?: FakeLspOptions): FakeLspServer {
const encoder = new TextEncoder();
const received: RpcMessage[] = [];
const waiters: Array<{
predicate: (message: RpcMessage) => boolean;
resolve: (message: RpcMessage) => void;
timer: Timer;
}> = [];
let exitCode: number | null = null;
let killed = false;
let stdoutStopped = false;
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
const { promise: exited, resolve: resolveExited } = Promise.withResolvers<number>();
const frame = (message: RpcMessage): Uint8Array => {
const content = JSON.stringify(message);
return encoder.encode(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
};
const stdout = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
});
const server: FakeLspServer = {
received,
send(message) {
if (controller && exitCode === null && !stdoutStopped) controller.enqueue(frame(message));
},
exit(code = 0) {
if (exitCode !== null) return;
if (!stdoutStopped) {
stdoutStopped = true;
controller?.close();
}
if (options?.stdoutClosesBeforeExit) {
queueMicrotask(() => {
exitCode = code;
resolveExited(code);
});
return;
}
exitCode = code;
resolveExited(code);
},
failStdout(error) {
if (stdoutStopped) return;
stdoutStopped = true;
controller?.error(error);
},
get killed() {
return killed;
},
waitFor(predicate, timeoutMs = 1_000) {
const existing = received.find(predicate);
if (existing) return Promise.resolve(existing);
return new Promise<RpcMessage>((resolve, reject) => {
const timer = setTimeout(() => {
const index = waiters.findIndex(entry => entry.timer === timer);
if (index >= 0) waiters.splice(index, 1);
reject(new Error("FakeLspServer.waitFor: timed out"));
}, timeoutMs);
waiters.push({ predicate, resolve, timer });
});
},
};
// Frame + dispatch the client -> server byte stream. The chain serialises
// handler runs so message ordering mirrors the wire.
let pendingBytes = Buffer.alloc(0);
let chain: Promise<void> = Promise.resolve();
const feed = (raw: string | Uint8Array): void => {
const chunk = typeof raw === "string" ? Buffer.from(raw, "utf-8") : Buffer.from(raw);
pendingBytes = pendingBytes.length === 0 ? chunk : Buffer.concat([pendingBytes, chunk]);
chain = chain.then(async () => {
while (true) {
const headerEnd = pendingBytes.indexOf("\r\n\r\n");
if (headerEnd === -1) break;
const match = /Content-Length: (\d+)/i.exec(pendingBytes.toString("utf-8", 0, headerEnd));
if (!match) {
pendingBytes = pendingBytes.subarray(headerEnd + 4);
continue;
}
const start = headerEnd + 4;
const end = start + Number(match[1]);
if (pendingBytes.length < end) break;
const message = JSON.parse(pendingBytes.toString("utf-8", start, end)) as RpcMessage;
pendingBytes = pendingBytes.subarray(end);
received.push(message);
for (let i = waiters.length - 1; i >= 0; i--) {
if (waiters[i].predicate(message)) {
clearTimeout(waiters[i].timer);
waiters[i].resolve(message);
waiters.splice(i, 1);
}
}
await handler(message, server);
}
});
};
const proc = {
get exited() {
return exited;
},
get exitCode() {
return exitCode;
},
stdin: {
write(chunk: string | Uint8Array) {
feed(chunk);
return typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.byteLength;
},
flush: async () => 0,
end: async () => 0,
},
stdout,
peekStderr: () => options?.stderr ?? "",
kill() {
killed = true;
if (options?.killResolvesExit !== false) server.exit(0);
},
} as unknown as LspClient["proc"];
vi.spyOn(piUtils.ptree, "spawn").mockReturnValue(proc as unknown as piUtils.ptree.ChildProcess<"pipe">);
return server;
}
type BunSpawnOptions = Bun.SpawnOptions.SpawnOptions<
Bun.SpawnOptions.Writable,
Bun.SpawnOptions.Readable,
Bun.SpawnOptions.Readable
>;
interface BunSpawnCall {
cmd: string[];
options?: BunSpawnOptions;
}
interface BunSpawnOutput {
stdout?: string;
stderr?: string;
exitCode?: number;
}
function textStream(text: string): ReadableStream<Uint8Array> {
const body = new Response(text).body;
if (!body) {
throw new Error("Failed to create text stream");
}
return body;
}
function completedProcess(stdout = "", stderr = "", exitCode = 0): Subprocess {
return {
pid: 12_345,
stdout: textStream(stdout),
stderr: textStream(stderr),
exited: Promise.resolve(exitCode),
kill: () => {},
} as Subprocess;
}
function recordBunSpawn(calls: BunSpawnCall[], outputForCommand: (cmd: string[]) => BunSpawnOutput = () => ({})): void {
vi.spyOn(Bun, "spawn").mockImplementation(((cmd: string[], options?: BunSpawnOptions) => {
const recordedCmd = [...cmd];
calls.push({ cmd: recordedCmd, options });
const output = outputForCommand(recordedCmd);
return completedProcess(output.stdout, output.stderr, output.exitCode);
}) as typeof Bun.spawn);
}
function textResult(result: AgentToolResult<LspToolDetails>): string {
return result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
}
describe("lsp regressions", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("detects bracket-style glob patterns", () => {
expect(hasGlobPattern("src/[ab].ts")).toBe(true);
expect(hasGlobPattern("src/**/*.ts")).toBe(true);
expect(hasGlobPattern("src/main.ts")).toBe(false);
});
it("supports long LSP timeouts up to the advertised ceiling", () => {
expect(clampTimeout("lsp")).toBe(20);
expect(clampTimeout("lsp", 1)).toBe(5);
expect(clampTimeout("lsp", 120)).toBe(120);
expect(clampTimeout("lsp", 1000)).toBe(300);
expect(arkToWireSchema(lspSchema)).toMatchObject({
properties: {
timeout: {
description: "Timeout in seconds (default 20; range 5–300).",
maximum: 300,
minimum: 5,
},
},
});
});
it("uses a custom server languageId for disk and in-memory document opens", async () => {
const tempDir = TempDir.createSync("@omp-lsp-language-id-");
const filePath = path.join(tempDir.path(), "foo.gd");
const syncedFilePath = path.join(tempDir.path(), "unsaved.gd");
try {
await Bun.write(
path.join(tempDir.path(), ".omp", "lsp.json"),
JSON.stringify({
servers: {
"fake-gd": {
command: process.execPath,
fileTypes: [".gd"],
languageId: "gdscript",
rootMarkers: [".omp"],
},
},
}),
);
await Bun.write(filePath, "extends Node\n");
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} 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 = loadConfig(tempDir.path());
const serverConfig = getServersForFile(config, filePath)[0]?.[1];
expect(serverConfig).toBeDefined();
if (!serverConfig) throw new Error("Custom GDScript server was not loaded");
const client = await lspClient.getOrCreateClient(serverConfig, tempDir.path(), 1_000);
await lspClient.ensureFileOpen(client, filePath);
const diskOpen = await server.waitFor(message => message.method === "textDocument/didOpen");
expect(diskOpen.params).toMatchObject({ textDocument: { languageId: "gdscript" } });
await lspClient.syncContent(client, syncedFilePath, "extends Resource\n");
const syncedOpen = await server.waitFor(
message => message.method === "textDocument/didOpen" && message !== diskOpen,
);
expect(syncedOpen.params).toMatchObject({ textDocument: { languageId: "gdscript" } });
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("sends the LSP exit notification and releases the idle checker after shutdown", async () => {
const tempDir = TempDir.createSync("@omp-lsp-shutdown-");
try {
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} 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: ["ts"],
rootMarkers: [],
};
await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
await lspClient.shutdownAll();
// Graceful handshake: the client sends `shutdown`, waits for its reply,
// then sends the `exit` notification -- and never resorts to the hard
// `proc.kill()` (production's SIGTERM fallback) because the server exits
// cleanly on `exit`.
const methods = server.received.map(message => message.method);
const shutdownIndex = methods.indexOf("shutdown");
const exitIndex = methods.indexOf("exit");
expect(shutdownIndex).toBeGreaterThanOrEqual(0);
expect(exitIndex).toBeGreaterThan(shutdownIndex);
expect(server.killed).toBe(false);
const clientModule = new URL("../../src/lsp/client.ts", import.meta.url).href;
const shutdownProbe = Bun.spawn(
[
process.execPath,
"-e",
`import { setIdleTimeout, shutdownAll } from ${JSON.stringify(clientModule)}; setIdleTimeout(60_000); await shutdownAll();`,
],
{ stdout: "ignore", stderr: "inherit" },
);
// Real time is required because fake timers cannot advance a separate Bun process.
// The process exit itself proves shutdown released the event loop.
const probeExit = await Promise.race([shutdownProbe.exited, Bun.sleep(5_000).then(() => null)]);
if (probeExit === null) {
shutdownProbe.kill();
await shutdownProbe.exited;
}
expect(probeExit).toBe(0);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("rearms the idle checker from cached config after global shutdown", async () => {
const cwd = "/cached-lsp-config";
const intervalSpy = vi.spyOn(globalThis, "setInterval");
configCache.set(cwd, { servers: {}, idleTimeoutMs: 60_000 });
try {
lspClient.setIdleTimeout(60_000);
expect(intervalSpy).toHaveBeenCalledTimes(1);
await lspClient.shutdownAll();
getConfig(cwd);
expect(intervalSpy).toHaveBeenCalledTimes(2);
} finally {
lspClient.setIdleTimeout(null);
configCache.delete(cwd);
}
});
it("returns an already-starting client without creating a second client", async () => {
const tempDir = TempDir.createSync("@omp-lsp-pending-client-");
const initialize = Promise.withResolvers<void>();
try {
const server = installFakeLsp(async (message, srv) => {
if (message.method === "initialize") {
await initialize.promise;
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} 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-pending-lsp",
fileTypes: ["ts"],
rootMarkers: [],
};
const startingClient = lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
await server.waitFor(message => message.method === "initialize");
const existingClient = lspClient.getActiveOrPendingClient(config, tempDir.path());
let settled = false;
void existingClient.then(() => {
settled = true;
});
await Bun.sleep(0);
expect(settled).toBe(false);
initialize.resolve();
expect(await existingClient).toBe(await startingClient);
expect(server.received.filter(message => message.method === "initialize")).toHaveLength(1);
} finally {
initialize.resolve();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("stops waiting for a pending client on caller abort without cancelling its initialization", async () => {
const tempDir = TempDir.createSync("@omp-lsp-pending-abort-");
const initialize = Promise.withResolvers<void>();
try {
const server = installFakeLsp(async (message, srv) => {
if (message.method === "initialize") {
await initialize.promise;
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} 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-abort-pending-lsp",
fileTypes: ["ts"],
rootMarkers: [],
};
const startingClient = lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
await server.waitFor(message => message.method === "initialize");
const controller = new AbortController();
const waitingClient = lspClient.getActiveOrPendingClient(config, tempDir.path(), controller.signal);
controller.abort();
await expect(waitingClient).rejects.toThrow();
initialize.resolve();
expect(await startingClient).toBeDefined();
expect(server.received.filter(message => message.method === "initialize")).toHaveLength(1);
} finally {
initialize.resolve();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("advertises workspace folder support during LSP initialization", async () => {
const tempDir = TempDir.createSync("@omp-lsp-workspace-folders-");
try {
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} 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: ["rs"],
rootMarkers: [],
};
await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
const init = server.received.find(message => message.method === "initialize");
const params = init?.params as {
capabilities?: { workspace?: { workspaceFolders?: unknown } };
workspaceFolders?: unknown;
};
expect(params.capabilities?.workspace?.workspaceFolders).toBe(true);
expect(params.workspaceFolders).toEqual([
{ uri: fileToUri(tempDir.path()), name: path.basename(tempDir.path()) },
]);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("answers workspace/workspaceFolders requests with the current folder set", async () => {
const tempDir = TempDir.createSync("@omp-lsp-workspace-folders-request-");
try {
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
// Server-initiated request: the client must answer with the folder set.
srv.send({ jsonrpc: "2.0", id: 9001, method: "workspace/workspaceFolders" });
} 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: ["rs"],
rootMarkers: [],
};
await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
const response = await server.waitFor(message => message.id === 9001 && message.method === undefined);
expect(response.error).toBeUndefined();
expect(response.result).toEqual([{ uri: fileToUri(tempDir.path()), name: path.basename(tempDir.path()) }]);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("sends initial workspace configuration after initialized before semantic requests (#5276)", async () => {
const tempDir = TempDir.createSync("@omp-lsp-initial-config-");
let receivedInitialConfiguration = false;
try {
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 === "workspace/didChangeConfiguration") {
receivedInitialConfiguration = true;
} else if (message.method === "textDocument/hover" && receivedInitialConfiguration) {
srv.send({ jsonrpc: "2.0", id: message.id, result: { contents: "configured hover" } });
} else if (message.method === "shutdown") {
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "exit") {
srv.exit(0);
}
});
const settings = {
"typescript-language-server": {
preferences: {
includePackageJsonAutoImports: "on",
importModuleSpecifierPreference: "non-relative",
},
inlayHints: {
includeInlayEnumMemberValueHints: true,
includeInlayParameterNameHints: "all",
},
},
};
const config: ServerConfig = {
command: "fake-lsp",
fileTypes: ["ts"],
rootMarkers: [],
settings,
};
const client = await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
const result = await lspClient.sendRequest(
client,
"textDocument/hover",
{
textDocument: { uri: fileToUri(path.join(tempDir.path(), "src", "configured.ts")) },
position: { line: 0, character: 0 },
},
undefined,
50,
);
expect(result).toEqual({ contents: "configured hover" });
const methods = server.received.map(message => message.method);
expect(methods.slice(0, 4)).toEqual([
"initialize",
"initialized",
"workspace/didChangeConfiguration",
"textDocument/hover",
]);
expect(server.received[2].params).toEqual({ settings: config.settings });
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("answers missing workspace configuration sections with null in request order", async () => {
const tempDir = TempDir.createSync("@omp-lsp-configuration-null-");
try {
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "initialized") {
srv.send({
jsonrpc: "2.0",
id: 5745,
method: "workspace/configuration",
params: {
items: [
{ section: "razor.format.attribute_indent_style" },
{ section: "html.auto_closing_tags" },
{},
],
},
});
} 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: ["cs"],
rootMarkers: [],
settings: { "html.auto_closing_tags": true },
};
await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
const response = await server.waitFor(message => message.id === 5745 && message.method === undefined);
expect(response.result).toEqual([null, true, null]);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("keeps the session alive when configuration is pulled after didChangeConfiguration", async () => {
const tempDir = TempDir.createSync("@omp-lsp-configuration-session-");
let configurationAccepted = false;
try {
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 === "workspace/didChangeConfiguration") {
srv.send({
jsonrpc: "2.0",
id: "roslyn-config",
method: "workspace/configuration",
params: { items: [{ section: "razor.format.attribute_indent_style" }] },
});
} else if (message.id === "roslyn-config" && message.method === undefined) {
if (Array.isArray(message.result) && message.result[0] === null) {
configurationAccepted = true;
} else {
srv.exit(-6);
}
} else if (message.method === "textDocument/hover" && configurationAccepted) {
srv.send({ jsonrpc: "2.0", id: message.id, result: { contents: "string C.Target" } });
} 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: ["cs"],
rootMarkers: [],
};
const client = await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
await server.waitFor(message => message.id === "roslyn-config" && message.method === undefined);
const result = await lspClient.sendRequest(
client,
"textDocument/hover",
{
textDocument: { uri: fileToUri(path.join(tempDir.path(), "Target.cs")) },
position: { line: 0, character: 6 },
},
undefined,
50,
);
expect(result).toEqual({ contents: "string C.Target" });
expect(configurationAccepted).toBe(true);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
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("drains every workspace/configuration pull during lazy cold start when a pull id collides with an in-flight request", async () => {
// #3001: basedpyright/pyright pull `workspace/configuration` repeatedly
// during cold start and gate document analysis on every pull being
// answered. Their pull ids live in the server's own id space and routinely
// coincide with the client's in-flight request ids. The reader must route a
// message by its `method` (a server-initiated request) BEFORE matching it
// against pending client requests by id -- otherwise a colliding config
// pull is swallowed as a bogus response, never answered, and the server
// wedges (the lazy `lsp symbols` call returns nothing and hangs). The
// eager warmup/reload path escapes this only because it issues no
// concurrent semantic request while the cold-start pulls drain.
const tempDir = TempDir.createSync("@omp-lsp-lazy-config-drain-");
try {
const symbols = [
{
name: "main",
kind: 12,
location: {
uri: fileToUri(path.join(tempDir.path(), "main.py")),
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 4 } },
},
},
];
// Pulls the server still awaits an answer for. The gated documentSymbol
// response is withheld until this set drains, mirroring pyright.
const unansweredConfigPulls = new Set<number | string>();
let symbolReqId: number | string | undefined;
let symbolsSent = false;
installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({
jsonrpc: "2.0",
id: message.id,
result: { capabilities: { documentSymbolProvider: true } },
});
return;
}
if (message.method === "textDocument/documentSymbol") {
symbolReqId = message.id;
// Cold-start config storm issued while the request is in flight.
// One pull uses a fresh server id; the other reuses the request's
// own id (server + client id counters collide) and pulls the bare
// `<server>` section the report flags as dropped.
const pulls: Array<{ id: number | string; items: Array<{ section?: string }> }> = [
{ id: 8200, items: [{ section: "basedpyright" }] },
{ id: message.id as number, items: [{}] },
];
for (const pull of pulls) {
unansweredConfigPulls.add(pull.id);
srv.send({
jsonrpc: "2.0",
id: pull.id,
method: "workspace/configuration",
params: { items: pull.items },
});
}
return;
}
// Client -> server config responses: an id + result, no method.
if (message.method === undefined && message.id !== undefined && unansweredConfigPulls.has(message.id)) {
unansweredConfigPulls.delete(message.id);
if (unansweredConfigPulls.size === 0 && !symbolsSent && symbolReqId !== undefined) {
symbolsSent = true;
srv.send({ jsonrpc: "2.0", id: symbolReqId, result: symbols });
}
return;
}
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: ["py"],
rootMarkers: [],
};
const client = await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
const result = await lspClient.sendRequest(
client,
"textDocument/documentSymbol",
{ textDocument: { uri: fileToUri(path.join(tempDir.path(), "main.py")) } },
undefined,
2_000,
);
// The gated request resolves with the server's real symbols only once the
// client has answered every config pull -- including the one whose id
// collided with this request. On baseline the colliding pull is
// mis-routed as the documentSymbol response (resolving it with
// `undefined`), so the pull is never answered and the server wedges.
expect(result).toEqual(symbols);
expect(unansweredConfigPulls.size).toBe(0);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("answers defined server→client requests with spec no-op results", async () => {
// Same failure class as #3029: a defined server→client request
// (window/showMessage{Request}, window/showDocument, workspace/*/refresh)
// must receive a spec-shaped reply, not a -32601. Headless omp can't
// surface UI prompts but still owes a defined no-op.
const tempDir = TempDir.createSync("@omp-lsp-server-requests-");
try {
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "initialized") {
srv.send({
jsonrpc: "2.0",
id: 9101,
method: "window/showMessageRequest",
params: { type: 1, message: "x", actions: [{ title: "Cancel" }] },
});
srv.send({
jsonrpc: "2.0",
id: 9102,
method: "window/showDocument",
params: { uri: "file:///tmp/a.md" },
});
srv.send({ jsonrpc: "2.0", id: 9103, method: "workspace/semanticTokens/refresh" });
srv.send({ jsonrpc: "2.0", id: 9104, method: "workspace/inlayHint/refresh" });
srv.send({ jsonrpc: "2.0", id: 9105, method: "workspace/codeLens/refresh" });
srv.send({ jsonrpc: "2.0", id: 9106, method: "workspace/diagnostic/refresh" });
srv.send({ jsonrpc: "2.0", id: 9107, method: "workspace/inlineValue/refresh" });
srv.send({ jsonrpc: "2.0", id: 9108, method: "workspace/foldingRange/refresh" });
} 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: ["rs"],
rootMarkers: [],
};
await lspClient.getOrCreateClient(config, tempDir.path(), 1_000);
const showMessage = await server.waitFor(message => message.id === 9101 && message.method === undefined);
expect(showMessage.error).toBeUndefined();
expect(showMessage.result).toBeNull();
const showDocument = await server.waitFor(message => message.id === 9102 && message.method === undefined);
expect(showDocument.error).toBeUndefined();
expect(showDocument.result).toEqual({ success: false });
for (const id of [9103, 9104, 9105, 9106, 9107, 9108]) {
const refresh = await server.waitFor(message => message.id === id && message.method === undefined);
expect(refresh.error).toBeUndefined();
expect(refresh.result).toBeNull();
}
} 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 {
const sourcePath = path.join(tempDir.path(), "src", "main.rs");
await Bun.write(path.join(tempDir.path(), "Cargo.toml"), '[package]\nname = "fixture"\nversion = "0.0.0"\n');
await Bun.write(sourcePath, "fn greet() {}\nfn main() { greet(); }\n");
const events: string[] = [];
let statusRequests = 0;
const fakeServer = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: { definitionProvider: true } } });
srv.send({
jsonrpc: "2.0",
method: "$/progress",
params: { token: "workspace", value: { kind: "begin" } },
});
srv.send({
jsonrpc: "2.0",
method: "$/progress",
params: { token: "workspace", value: { kind: "end" } },
});
} else if (message.method === "rust-analyzer/analyzerStatus") {
statusRequests++;
events.push("status");
// The first status request is intentionally dropped so the client
// must treat the request timeout as a retry signal
// (deadline-as-signal), then keep polling through "No workspaces"
// until the workspace reports ready.
if (statusRequests === 1) return;
const ready = statusRequests >= 3;
srv.send({
jsonrpc: "2.0",
id: message.id,
result: ready ? "Workspaces:\nLoaded 1 package across 1 workspace." : "No workspaces",
});
} else if (message.method === "textDocument/didOpen") {
events.push("open");
} else if (message.method === "textDocument/definition") {
srv.send({
jsonrpc: "2.0",
id: message.id,
result: [
{
uri: fileToUri(sourcePath),
range: { start: { line: 0, character: 3 }, end: { line: 0, character: 8 } },
},
],
});
} else if (message.method === "shutdown") {
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "exit") {
srv.exit(0);
}
});
const server: ServerConfig = {
command: "rust-analyzer",
resolvedCommand: process.execPath,
fileTypes: ["rs"],
rootMarkers: [],
// Drive the timeout -> retry -> ready loop without real-clock latency.
// The first status request still times out (proving deadline-as-signal),
// just on a tiny budget instead of the 2s production settle window.
workspaceReadyTimings: { timeoutMs: 5_000, pollMs: 1, settleMs: 2, statusRequestTimeoutMs: 20 },
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "rust-analyzer": server },
idleTimeoutMs: undefined,
});
vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["rust-analyzer", server]]);
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("rust-wait-test", {
action: "definition",
file: sourcePath,
line: 2,
symbol: "greet",
timeout: 10,
});
const output = result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(output).toContain("Found 1 definition(s)");
expect(events[0]).toBe("open");
expect(events.filter(line => line === "status").length).toBeGreaterThanOrEqual(3);
const firstStatusRequest = fakeServer.received.find(
message => message.method === "rust-analyzer/analyzerStatus",
);
if (!firstStatusRequest) throw new Error("Expected the timed-out analyzer status request");
const cancellation = await fakeServer.waitFor(message => message.method === "$/cancelRequest");
expect(cancellation.params).toEqual({ id: firstStatusRequest.id });
} finally {
vi.restoreAllMocks();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("skips rust-analyzer workspace polling for standalone Rust files", async () => {
const tempDir = TempDir.createSync("@omp-lsp-rust-standalone-");
try {
const sourcePath = path.join(tempDir.path(), "foo.rs");
await Bun.write(sourcePath, 'fn greet() -> &\'static str { "hi" }\n');
const events: string[] = [];
installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: { definitionProvider: true } } });
} else if (message.method === "rust-analyzer/analyzerStatus") {
events.push("status");
srv.send({ jsonrpc: "2.0", id: message.id, result: "No workspaces" });
} else if (message.method === "textDocument/didOpen") {
events.push("open");
} else if (message.method === "textDocument/definition") {
srv.send({
jsonrpc: "2.0",
id: message.id,
result: [
{
uri: fileToUri(sourcePath),
range: { start: { line: 0, character: 3 }, end: { line: 0, character: 8 } },
},
],
});
} else if (message.method === "shutdown") {
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "exit") {
srv.exit(0);
}
});
const server: ServerConfig = {
command: "rust-analyzer",
resolvedCommand: process.execPath,
fileTypes: ["rs"],
rootMarkers: [],
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "rust-analyzer": server },
idleTimeoutMs: undefined,
});
vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["rust-analyzer", server]]);
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("rust-standalone-test", {
action: "definition",
file: sourcePath,
line: 1,
symbol: "greet",
timeout: 10,
});
const output = result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
// Standalone .rs (no Cargo workspace ancestor): the file is opened but the
// analyzerStatus readiness poll is skipped entirely. The direct
// `not.toContain("status")` assertion is the skip contract; the original
// `elapsed < 2000ms` wall-clock proxy is dropped -- it is vacuous once the
// transport is in-memory.
expect(output).toContain("Found 1 definition(s)");
expect(events).toContain("open");
expect(events).not.toContain("status");
} finally {
vi.restoreAllMocks();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("limits glob collection to avoid large diagnostic stalls", async () => {
const tempDir = TempDir.createSync("@omp-lsp-glob-");
try {
await Promise.all([
Bun.write(path.join(tempDir.path(), "a.ts"), "export const a = 1;\n"),
Bun.write(path.join(tempDir.path(), "b.ts"), "export const b = 1;\n"),
Bun.write(path.join(tempDir.path(), "c.ts"), "export const c = 1;\n"),
]);
const result = await collectGlobMatches("*.ts", tempDir.path(), 2);
expect(result.matches).toHaveLength(2);
expect(result.truncated).toBe(true);
} finally {
tempDir.removeSync();
}
});
it("treats existing bracket paths as literal diagnostic targets", async () => {
const tempDir = TempDir.createSync("@omp-lsp-bracket-path-");
try {
const diagnosticTarget = path.join(
"apps",
"frontend",
"src",
"app",
"runs",
"[runId]",
"public",
"opengraph-image.tsx",
);
const filePath = path.join(tempDir.path(), diagnosticTarget);
await Bun.write(filePath, "export default function OpenGraphImage() {}\n");
const result = await resolveDiagnosticTargets(diagnosticTarget, tempDir.path(), 10);
expect(result).toEqual({
matches: [diagnosticTarget],
truncated: false,
});
} finally {
tempDir.removeSync();
}
});
it("resolves the requested symbol occurrence on a line", async () => {
const tempDir = TempDir.createSync("@omp-lsp-regression-");
try {
const filePath = path.join(tempDir.path(), "symbol.ts");
await Bun.write(filePath, "foo(bar(foo));\n");
expect(await resolveSymbolColumn(filePath, 1, "foo")).toBe(0);
expect(await resolveSymbolColumn(filePath, 1, "foo#2")).toBe(8);
} finally {
tempDir.removeSync();
}
});
it("throws when symbol does not exist on the target line", async () => {
const tempDir = TempDir.createSync("@omp-lsp-missing-symbol-");
try {
const filePath = path.join(tempDir.path(), "symbol.ts");
await Bun.write(filePath, "winston.info('x');\n");
expect(resolveSymbolColumn(filePath, 1, "nonexistent_symbol")).rejects.toThrow(
'Symbol "nonexistent_symbol" not found on line 1',
);
} finally {
tempDir.removeSync();
}
});
it("throws when occurrence is out of bounds", async () => {
const tempDir = TempDir.createSync("@omp-lsp-occurrence-");
try {
const filePath = path.join(tempDir.path(), "symbol.ts");
await Bun.write(filePath, "foo();\n");
expect(resolveSymbolColumn(filePath, 1, "foo#2")).rejects.toThrow(
'Symbol "foo" occurrence 2 is out of bounds on line 1 (found 1)',
);
} finally {
tempDir.removeSync();
}
});
it("filters and deduplicates workspace symbols by query", () => {
const rustUri = fileToUri(path.join(os.tmpdir(), "rust.rs"));
const loggerUri = fileToUri(path.join(os.tmpdir(), "logger.ts"));
const symbols: SymbolInformation[] = [
{
name: "DisallowOverwritingRegularFilesViaOutputRedirection",
kind: 12,
location: {
uri: rustUri,
range: {
start: { line: 10, character: 2 },
end: { line: 10, character: 60 },
},
},
},
{
name: "logger",
kind: 13,
location: {
uri: loggerUri,
range: {
start: { line: 5, character: 1 },
end: { line: 5, character: 7 },
},
},
},
{
name: "logger",
kind: 13,
location: {
uri: loggerUri,
range: {
start: { line: 5, character: 1 },
end: { line: 5, character: 7 },
},
},
},
];
const filtered = filterWorkspaceSymbols(symbols, "logger");
const unique = dedupeWorkspaceSymbols(filtered);
expect(filtered).toHaveLength(2);
expect(unique).toHaveLength(1);
expect(unique[0]?.name).toBe("logger");
});
it("applies command-only code actions by executing workspace commands", async () => {
const executedCommands: string[] = [];
const result = await applyCodeAction(
{ title: "Organize Imports", command: "source.organizeImports" },
{
applyWorkspaceEdit: async () => [],
executeCommand: async command => {
executedCommands.push(command.command);
},
},
);
expect(executedCommands).toEqual(["source.organizeImports"]);
expect(result).toEqual({
title: "Organize Imports",
edits: [],
executedCommands: ["source.organizeImports"],
});
});
it("resolves code actions before applying edits", async () => {
const unresolvedAction: CodeAction = { title: "Add import" };
const appliedEdits: string[] = [];
const result = await applyCodeAction(unresolvedAction, {
resolveCodeAction: async action => ({
...action,
edit: {
changes: {
[fileToUri(path.join(os.tmpdir(), "example.ts"))]: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
newText: "import x from 'y';\n",
},
],
},
},
}),
applyWorkspaceEdit: async () => {
appliedEdits.push("example.ts: 1 edit");
return ["example.ts: 1 edit"];
},
executeCommand: async () => {},
});
expect(appliedEdits).toEqual(["example.ts: 1 edit"]);
expect(result).toEqual({
title: "Add import",
edits: ["example.ts: 1 edit"],
executedCommands: [],
});
});
it("sanitizes symbol metadata in renderer output", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const renderOptions: RenderResultOptions = { expanded: false, isPartial: false };
const call = renderCall(
{ action: "definition", file: "src/example.ts", line: 10, symbol: "foo\tbar\nbaz" },
renderOptions,
uiTheme,
);
const callText = sanitizeText(call.render(120).join("\n"));
const normalizedCallText = callText.replace(/\s+/g, " ");
expect(normalizedCallText).toContain("foo bar baz");
expect(callText).not.toContain("\t");
const result = renderResult(
{
content: [{ type: "text", text: "No definition found" }],
details: {
action: "definition",
success: true,
request: {
action: "definition",
file: "src/example.ts",
line: 10,
symbol: "foo\tbar\nbaz",
},
},
},
renderOptions,
uiTheme,
);
const resultText = sanitizeText(result.render(120).join("\n"));
const normalizedResultText = resultText.replace(/\s+/g, " ");
expect(normalizedResultText).toContain("symbol: foo bar baz");
expect(resultText).not.toContain("\t");
});
it("sanitizes tabs in rendered diagnostic output", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const uiTheme = theme!;
const renderOptions: RenderResultOptions = { expanded: false, isPartial: false };
const result = renderResult(
{
content: [
{
type: "text",
text: "Diagnostics: 1 error(s)\nsrc/example.go:183:41 [error] [compiler] too many\targuments in call (WrongArgCount)",
},
],
},
renderOptions,
uiTheme,
);
const resultText = sanitizeText(result.render(120).join("\n"));
expect(resultText).not.toContain("\t");
expect(resultText.replace(/\s+/g, " ")).toContain("too many arguments in call");
});
it("sanitizes expanded generic error output (#7041)", async () => {
const theme = await getThemeByName("dark");
expect(theme).toBeDefined();
const result = renderLocalResult(
{
content: [{ type: "text", text: `Error:\nserver\tstderr ${"x".repeat(200)}` }],
},
{ expanded: true, isPartial: false },
theme!,
);
const lines = sanitizeText(result.render(300).join("\n")).split("\n");
expect(lines.join("\n")).not.toContain("\t");
expect(lines.join("\n")).not.toContain("x".repeat(100));
});
for (const dynamicRegistration of [false, true]) {
it(`reports pull diagnostics advertised through ${dynamicRegistration ? "dynamic registration" : "server capabilities"}`, async () => {
const tempDir = TempDir.createSync("@omp-lsp-pull-diags-");
try {
const targetFile = path.join(tempDir.path(), "target.ts");
await Bun.write(targetFile, "const broken: string = 42;\n");
const diagnostic: Diagnostic = {
message: "Type 'number' is not assignable to type 'string'.",
severity: 1,
code: 2322,
source: "ts",
range: {
start: { line: 0, character: 6 },
end: { line: 0, character: 12 },
},
};
const fakeServer = installFakeLsp((message, server) => {
if (message.method === "initialize") {
server.send({
jsonrpc: "2.0",
id: message.id,
result: { capabilities: dynamicRegistration ? {} : { diagnosticProvider: true } },
});
server.send({
jsonrpc: "2.0",
method: "$/progress",
params: { token: "workspace", value: { kind: "begin" } },
});
server.send({
jsonrpc: "2.0",
method: "$/progress",
params: { token: "workspace", value: { kind: "end" } },
});
} else if (dynamicRegistration && message.method === "initialized") {
server.send({
jsonrpc: "2.0",
id: "register-diagnostics",
method: "client/registerCapability",
params: {
registrations: [
{
id: "pull-diagnostics",
method: "textDocument/diagnostic",
registerOptions: {},
},
],
},
});
} else if (message.method === "textDocument/diagnostic") {
server.send({
jsonrpc: "2.0",
id: message.id,
result: { kind: "full", items: [diagnostic] },
});
} else if (message.method === "shutdown") {
server.send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "exit") {
server.exit(0);
}
});
const serverConfig: ServerConfig = {
command: "fake-lsp",
fileTypes: ["ts"],
rootMarkers: [],
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "fake-lsp": serverConfig },
idleTimeoutMs: undefined,
});
vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["fake-lsp", serverConfig]]);
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute(`pull-diagnostics-${dynamicRegistration}`, {
action: "diagnostics",
file: targetFile,
timeout: 20,
});
const initialize = fakeServer.received.find(message => message.method === "initialize");
expect(initialize).toMatchObject({
params: {
capabilities: {
textDocument: { diagnostic: { dynamicRegistration: true } },
},
},
});
expect(fakeServer.received.map(message => message.method)).toContain("textDocument/diagnostic");
expect(textResult(result)).toContain("Type 'number' is not assignable to type 'string'.");
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
}, 15_000);
}
it("does not reuse stale file diagnostics after another URI publishes", async () => {
const tempDir = TempDir.createSync("@omp-lsp-stale-diags-");
try {
const targetFile = path.join(tempDir.path(), "target.ts");
const otherFile = path.join(tempDir.path(), "other.ts");
await Bun.write(targetFile, "export const target = 1;\n");
await Bun.write(otherFile, "export const other = 1;\n");
const targetUri = fileToUri(targetFile);
const otherUri = fileToUri(otherFile);
const server: ServerConfig = { command: "test-lsp", fileTypes: ["ts"], rootMarkers: [] };
const staleDiagnostic: Diagnostic = {
message: "stale target error",
severity: 1,
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 1 },
},
};
const otherDiagnostic: Diagnostic = {
message: "other file warning",
severity: 2,
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 1 },
},
};
const client: LspClient = {
name: "test-lsp",
cwd: tempDir.path(),
config: server,
proc: {
stdin: {
write() {},
flush: async () => {},
},
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map([[targetUri, { diagnostics: [staleDiagnostic], version: null }]]),
diagnosticsVersion: 1,
openFiles: new Map([[targetUri, { version: 1, languageId: "typescript" }]]),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: {}, idleTimeoutMs: undefined });
vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-lsp", server]]);
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
setTimeout(() => {
client.diagnostics.set(otherUri, { diagnostics: [otherDiagnostic], version: 1 });
client.diagnosticsVersion += 1;
}, 20);
setTimeout(() => {
client.diagnostics.set(targetUri, {
diagnostics: [],
version: client.openFiles.get(targetUri)?.version ?? 2,
});
client.diagnosticsVersion += 1;
}, 80);
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("diag-stale", {
action: "diagnostics",
file: targetFile,
timeout: 5,
});
const output = result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(output).toBe("OK");
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("reports failure when every applicable diagnostics server fails (#8377)", async () => {
const tempDir = TempDir.createSync("@omp-lsp-all-servers-fail-");
try {
const targetFile = path.join(tempDir.path(), "target.ts");
await Bun.write(targetFile, "export const target = 1;\n");
// The diagnostics renderer prefixes status lines via the global theme,
// which production initializes before any tool runs.
await initTheme();
const serverConfig: ServerConfig = {
command: "broken-lsp",
fileTypes: ["ts"],
rootMarkers: [],
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { broken: serverConfig },
idleTimeoutMs: undefined,
});
vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["broken", serverConfig]]);
vi.spyOn(lspClient, "getOrCreateClient").mockRejectedValue(new Error("server exited with code 7"));
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("all-servers-fail", {
action: "diagnostics",
file: targetFile,
timeout: 5,
});
// A total server failure must not masquerade as a clean file.
expect(result.details?.success).toBe(false);
const output = textResult(result);
expect(output).not.toBe("OK");
expect(output).toContain("all language servers failed");
expect(output).toContain("broken");
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("treats a go.work-only root as a Go workspace for workspace diagnostics", async () => {
const tempDir = TempDir.createSync("@omp-lsp-go-work-only-");
const spawnCalls: BunSpawnCall[] = [];
recordBunSpawn(spawnCalls, cmd => {
if (cmd.join("\0") === "go\0work\0edit\0-json") {
return { stdout: JSON.stringify({ Use: [{ DiskPath: "./service" }] }) };
}
return {};
});
try {
const serviceDir = path.join(tempDir.path(), "service");
await fs.promises.mkdir(serviceDir, { recursive: true });
await Bun.write(path.join(tempDir.path(), "go.work"), ["go 1.22", "", "use ./service", ""].join("\n"));
await Bun.write(path.join(serviceDir, "go.mod"), "module example.com/service\n\ngo 1.22\n");
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("go-work-only-diagnostics", {
action: "diagnostics",
file: "*",
});
const buildCalls = spawnCalls.filter(call => call.cmd[0] === "go" && call.cmd[1] === "build");
expect(buildCalls).toHaveLength(1);
expect(buildCalls[0]?.cmd).toEqual(["go", "build", "./service/..."]);
expect(buildCalls[0]?.options?.cwd).toBe(tempDir.path());
const output = textResult(result);
expect(output).toContain("Workspace diagnostics (");
expect(output).toContain("go build");
expect(output).toContain("No issues found");
expect(output).not.toContain("Cannot detect project type");
} finally {
tempDir.removeSync();
}
});
it("builds every go.work use module when go.work and go.mod coexist", async () => {
const tempDir = TempDir.createSync("@omp-lsp-go-work-before-mod-");
const spawnCalls: BunSpawnCall[] = [];
recordBunSpawn(spawnCalls, cmd => {
if (cmd.join("\0") === "go\0work\0edit\0-json") {
return {
stdout: JSON.stringify({
Use: [{ DiskPath: "." }, { DiskPath: "./service" }, { DiskPath: "./tools/helper" }],
}),
};
}
return {};
});
try {
const serviceDir = path.join(tempDir.path(), "service");
const helperDir = path.join(tempDir.path(), "tools", "helper");
await fs.promises.mkdir(serviceDir, { recursive: true });
await fs.promises.mkdir(helperDir, { recursive: true });
await Bun.write(path.join(tempDir.path(), "go.mod"), "module example.com/root\n\ngo 1.22\n");
await Bun.write(path.join(serviceDir, "go.mod"), "module example.com/service\n\ngo 1.22\n");
await Bun.write(path.join(helperDir, "go.mod"), "module example.com/helper\n\ngo 1.22\n");
await Bun.write(
path.join(tempDir.path(), "go.work"),
["go 1.22", "", "use (", "\t.", "\t./service", "\t./tools/helper", ")", ""].join("\n"),
);
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("go-work-module-patterns", {
action: "diagnostics",
file: "*",
});
const buildCalls = spawnCalls.filter(call => call.cmd[0] === "go" && call.cmd[1] === "build");
expect(buildCalls).toHaveLength(1);
expect(buildCalls[0]?.cmd.slice(0, 2)).toEqual(["go", "build"]);
expect(buildCalls[0]?.cmd.slice(2).sort()).toEqual(["./...", "./service/...", "./tools/helper/..."]);
expect(textResult(result)).toContain("Workspace diagnostics (");
expect(textResult(result)).toContain("go build");
} finally {
tempDir.removeSync();
}
});
it("detects Windows local .exe LSP shims in node_modules/.bin", async () => {
if (process.platform !== "win32") {
return;
}
const tempDir = TempDir.createSync("@omp-lsp-win32-bin-");
const whichSpy = vi.spyOn(Bun, "which").mockReturnValue(null);
try {
await Bun.write(path.join(tempDir.path(), "package.json"), "{}");
const binDir = path.join(tempDir.path(), "node_modules", ".bin");
await fs.promises.mkdir(binDir, { recursive: true });
const localTsServer = path.join(binDir, "typescript-language-server.exe");
await Bun.write(localTsServer, "");
const config = loadConfig(tempDir.path());
expect(config.servers["typescript-language-server"]?.resolvedCommand).toBe(localTsServer);
expect(whichSpy).not.toHaveBeenCalledWith("typescript-language-server");
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("detects Ruff in Windows virtualenv Scripts directories", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", configurable: true, writable: true });
const tempDir = TempDir.createSync("@omp-lsp-win32-ruff-");
const whichSpy = vi.spyOn(Bun, "which").mockReturnValue(null);
try {
await Bun.write(path.join(tempDir.path(), "pyproject.toml"), '[project]\nname = "demo"\n');
const scriptsDir = path.join(tempDir.path(), ".venv", "Scripts");
await fs.promises.mkdir(scriptsDir, { recursive: true });
const localRuff = path.join(scriptsDir, "ruff.exe");
await Bun.write(localRuff, "");
const config = loadConfig(tempDir.path());
expect(config.servers.ruff?.resolvedCommand).toBe(localRuff);
expect(whichSpy).not.toHaveBeenCalledWith("ruff");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true, writable: true });
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("detects Ruff in Windows virtualenv Scripts directories for Ruff-only roots", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", configurable: true, writable: true });
const whichSpy = vi.spyOn(Bun, "which").mockReturnValue(null);
try {
for (const marker of ["ruff.toml", ".ruff.toml"] as const) {
const tempDir = TempDir.createSync("@omp-lsp-win32-ruff-marker-");
try {
await Bun.write(path.join(tempDir.path(), marker), "");
const scriptsDir = path.join(tempDir.path(), ".venv", "Scripts");
await fs.promises.mkdir(scriptsDir, { recursive: true });
const localRuff = path.join(scriptsDir, "ruff.exe");
await Bun.write(localRuff, "");
const config = loadConfig(tempDir.path());
expect(config.servers.ruff?.resolvedCommand).toBe(localRuff);
} finally {
tempDir.removeSync();
}
}
expect(whichSpy).not.toHaveBeenCalledWith("ruff");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true, writable: true });
vi.restoreAllMocks();
}
});
it("detects pyright and pylsp in Windows virtualenv Scripts for Python-only roots", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "win32", configurable: true, writable: true });
const whichSpy = vi.spyOn(Bun, "which").mockReturnValue(null);
try {
const cases: Array<{ marker: string; server: string; binary: string }> = [
{ marker: "pyrightconfig.json", server: "pyright", binary: "pyright-langserver.exe" },
{ marker: "setup.cfg", server: "pylsp", binary: "pylsp.exe" },
];
for (const { marker, server, binary } of cases) {
const tempDir = TempDir.createSync("@omp-lsp-win32-py-marker-");
try {
await Bun.write(path.join(tempDir.path(), marker), "");
const scriptsDir = path.join(tempDir.path(), ".venv", "Scripts");
await fs.promises.mkdir(scriptsDir, { recursive: true });
const localBin = path.join(scriptsDir, binary);
await Bun.write(localBin, "");
const config = loadConfig(tempDir.path());
expect(config.servers[server]?.resolvedCommand).toBe(localBin);
} finally {
tempDir.removeSync();
}
}
expect(whichSpy).not.toHaveBeenCalledWith("pyright-langserver");
expect(whichSpy).not.toHaveBeenCalledWith("pylsp");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true, writable: true });
vi.restoreAllMocks();
}
});
it("detects tlaplus files for LSP startup and language ids", async () => {
const tempDir = TempDir.createSync("@omp-lsp-tlaplus-");
const specPath = path.join(tempDir.path(), "Spec.tla");
const aliasPath = path.join(tempDir.path(), "Spec.tlaplus");
await Bun.write(specPath, "---- MODULE Spec ----\n====\n");
const resolvedTlapmLsp = path.join(tempDir.path(), "bin", "tlapm_lsp");
const whichSpy = vi
.spyOn(piUtils, "$which")
.mockImplementation(command => (command === "tlapm_lsp" ? resolvedTlapmLsp : null));
const existsSpy = vi
.spyOn(fs, "existsSync")
.mockImplementation(candidate => typeof candidate === "string" && candidate === specPath);
try {
const config = loadConfig(tempDir.path());
expect(getServersForFile(config, specPath).map(([name]) => name)).toEqual(["tlaplus"]);
expect(whichSpy).toHaveBeenCalledWith("tlapm_lsp");
expect(existsSpy).toHaveBeenCalled();
expect(detectLanguageId(specPath)).toBe("tlaplus");
expect(detectLanguageId(aliasPath)).toBe("tlaplus");
} finally {
tempDir.removeSync();
}
});
it("detects extensionless .emacs files for UI and LSP language ids", () => {
const emacsPath = path.join(os.tmpdir(), "example", ".emacs");
expect(getLanguageFromPath(emacsPath)).toBe("emacs-lisp");
expect(detectLanguageId(emacsPath)).toBe("emacs-lisp");
});
it("loads config-only marketplace LSP servers from Claude plugin cache", async () => {
const tempDir = TempDir.createSync("@omp-lsp-marketplace-config-");
const home = path.join(tempDir.path(), "home");
const cwd = path.join(tempDir.path(), "repo");
const pluginRoot = path.join(
home,
".claude",
"plugins",
"cache",
"claude-plugins-official",
"csharp-lsp",
"1.0.0",
);
const marketplaceRoot = path.dirname(path.dirname(pluginRoot));
const registryPath = path.join(home, ".claude", "plugins", "installed_plugins.json");
await fs.promises.mkdir(pluginRoot, { recursive: true });
await fs.promises.mkdir(cwd, { recursive: true });
await fs.promises.mkdir(path.dirname(registryPath), { recursive: true });
await Bun.write(path.join(cwd, "Example.csproj"), "<Project />\n");
await Bun.write(
registryPath,
`${JSON.stringify(
{
version: 2,
plugins: {
"csharp-lsp@claude-plugins-official": [
{
scope: "user",
installPath: pluginRoot,
version: "1.0.0",
installedAt: "2026-05-25T00:00:00.000Z",
lastUpdated: "2026-05-25T00:00:00.000Z",
},
],
},
},
null,
2,
)}\n`,
);
await Bun.write(
path.join(marketplaceRoot, "marketplace.json"),
`${JSON.stringify(
{
name: "claude-plugins-official",
owner: { name: "anthropic" },
plugins: [
{
name: "csharp-lsp",
version: "1.0.0",
source: "./csharp-lsp/1.0.0",
lspServers: {
"csharp-ls": {
command: "csharp-ls",
extensionToLanguage: { ".cs": "csharp" },
},
},
},
],
},
null,
2,
)}\n`,
);
const resolvedCsharpLs = path.join(tempDir.path(), "bin", "csharp-ls");
const whichSpy = vi
.spyOn(piUtils, "$which")
.mockImplementation(command => (command === "csharp-ls" ? resolvedCsharpLs : null));
try {
await preloadPluginRoots(home, cwd);
const config = loadConfig(cwd);
expect(config.servers["csharp-ls"]?.resolvedCommand).toBe(resolvedCsharpLs);
expect(getServersForFile(config, path.join(cwd, "Program.cs")).map(([name]) => name)).toEqual(["csharp-ls"]);
expect(config.servers["csharp-ls"]?.rootMarkers).toEqual(["."]);
expect(whichSpy).toHaveBeenCalledWith("csharp-ls");
} finally {
await preloadPluginRoots(path.join(tempDir.path(), "empty-home"), cwd);
tempDir.removeSync();
}
});
it("rename_file applies LSP willRenameFiles edits and renames the file", async () => {
const tempDir = TempDir.createSync("@omp-lsp-rename-file-");
try {
const sourceFile = path.join(tempDir.path(), "src", "old.ts");
const destFile = path.join(tempDir.path(), "src", "new.ts");
const referencingFile = path.join(tempDir.path(), "src", "consumer.ts");
await Bun.write(sourceFile, "export const value = 42;\n");
await Bun.write(referencingFile, "import { value } from './old';\nconsole.log(value);\n");
const sourceUri = fileToUri(sourceFile);
const destUri = fileToUri(destFile);
const referencingUri = fileToUri(referencingFile);
const server: ServerConfig = { command: "test-lsp", fileTypes: ["ts"], rootMarkers: [] };
const client: LspClient = {
name: "test-lsp",
cwd: tempDir.path(),
config: server,
proc: {
stdin: { write() {}, flush: async () => {} },
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "test-lsp": server },
idleTimeoutMs: undefined,
});
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
const willRenameRequests: Array<{ method: string; params: unknown }> = [];
vi.spyOn(lspClient, "sendRequest").mockImplementation(async (_client, method, params) => {
willRenameRequests.push({ method, params });
if (method === "workspace/willRenameFiles") {
return {
changes: {
[referencingUri]: [
{
range: {
start: { line: 0, character: 22 },
end: { line: 0, character: 29 },
},
newText: "'./new'",
},
],
},
};
}
return null;
});
const notifications: Array<{ method: string; params: unknown }> = [];
vi.spyOn(lspClient, "sendNotification").mockImplementation(async (_client, method, params) => {
notifications.push({ method, params });
});
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("rename-file-test", {
action: "rename_file",
file: sourceFile,
new_name: destFile,
timeout: 5,
});
expect(willRenameRequests).toHaveLength(1);
expect(willRenameRequests[0]?.method).toBe("workspace/willRenameFiles");
expect(willRenameRequests[0]?.params).toEqual({
files: [{ oldUri: sourceUri, newUri: destUri }],
});
// Filesystem actually moved
expect(fs.existsSync(sourceFile)).toBe(false);
expect(fs.existsSync(destFile)).toBe(true);
// Importer file got the LSP-provided edit
const updatedConsumer = await Bun.file(referencingFile).text();
expect(updatedConsumer).toBe("import { value } from './new';\nconsole.log(value);\n");
// didRenameFiles notification fired with the same pair list
const didRename = notifications.find(n => n.method === "workspace/didRenameFiles");
expect(didRename).toBeDefined();
expect(didRename?.params).toEqual({
files: [{ oldUri: sourceUri, newUri: destUri }],
});
const output = result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(output).toContain("Renamed");
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("rename_file with apply:false previews edits without filesystem changes", async () => {
const tempDir = TempDir.createSync("@omp-lsp-rename-file-preview-");
try {
const sourceFile = path.join(tempDir.path(), "old.ts");
const destFile = path.join(tempDir.path(), "new.ts");
await Bun.write(sourceFile, "export const value = 42;\n");
const server: ServerConfig = { command: "test-lsp", fileTypes: ["ts"], rootMarkers: [] };
const client: LspClient = {
name: "test-lsp",
cwd: tempDir.path(),
config: server,
proc: {
stdin: { write() {}, flush: async () => {} },
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "test-lsp": server },
idleTimeoutMs: undefined,
});
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
vi.spyOn(lspClient, "sendRequest").mockResolvedValue({
documentChanges: [],
});
const notifySpy = vi.spyOn(lspClient, "sendNotification").mockResolvedValue();
const tool = new LspTool(makeLspSession(tempDir.path()));
await tool.execute("rename-file-preview", {
action: "rename_file",
file: sourceFile,
new_name: destFile,
apply: false,
timeout: 5,
});
expect(fs.existsSync(sourceFile)).toBe(true);
expect(fs.existsSync(destFile)).toBe(false);
expect(notifySpy).not.toHaveBeenCalledWith(expect.anything(), "workspace/didRenameFiles", expect.anything());
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("rename_file enumerates every file inside a directory rename", async () => {
const tempDir = TempDir.createSync("@omp-lsp-rename-dir-");
try {
const srcDir = path.join(tempDir.path(), "old");
const dstDir = path.join(tempDir.path(), "new");
const fileA = path.join(srcDir, "a.ts");
const fileB = path.join(srcDir, "nested", "b.ts");
await Bun.write(fileA, "export const a = 1;\n");
await Bun.write(fileB, "export const b = 2;\n");
const server: ServerConfig = { command: "test-lsp", fileTypes: ["ts"], rootMarkers: [] };
const client: LspClient = {
name: "test-lsp",
cwd: tempDir.path(),
config: server,
proc: {
stdin: { write() {}, flush: async () => {} },
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "test-lsp": server },
idleTimeoutMs: undefined,
});
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
const requests: Array<{ method: string; params: unknown }> = [];
vi.spyOn(lspClient, "sendRequest").mockImplementation(async (_c, method, params) => {
requests.push({ method, params });
return null;
});
vi.spyOn(lspClient, "sendNotification").mockResolvedValue();
const tool = new LspTool(makeLspSession(tempDir.path()));
await tool.execute("rename-dir-test", {
action: "rename_file",
file: srcDir,
new_name: dstDir,
timeout: 5,
});
expect(requests).toHaveLength(1);
const params = requests[0]?.params as { files: Array<{ oldUri: string; newUri: string }> };
expect(params.files).toHaveLength(2);
const oldUris = params.files.map(f => f.oldUri).sort();
const newUris = params.files.map(f => f.newUri).sort();
expect(oldUris).toEqual([fileToUri(fileA), fileToUri(fileB)].sort());
expect(newUris).toEqual(
[fileToUri(path.join(dstDir, "a.ts")), fileToUri(path.join(dstDir, "nested", "b.ts"))].sort(),
);
// Directory was actually moved
expect(fs.existsSync(srcDir)).toBe(false);
expect(fs.existsSync(path.join(dstDir, "a.ts"))).toBe(true);
expect(fs.existsSync(path.join(dstDir, "nested", "b.ts"))).toBe(true);
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("request action sends raw LSP method with auto-built textDocument/position params", async () => {
const tempDir = TempDir.createSync("@omp-lsp-request-");
try {
const filePath = path.join(tempDir.path(), "src", "lib.rs");
await Bun.write(filePath, 'fn main() {\n println!("hi");\n}\n');
const server: ServerConfig = { command: "test-rs", fileTypes: ["rs"], rootMarkers: [] };
const client: LspClient = {
name: "test-rs",
cwd: tempDir.path(),
config: server,
proc: {
stdin: { write() {}, flush: async () => {} },
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "test-rs": server },
idleTimeoutMs: undefined,
});
vi.spyOn(lspConfig, "getServersForFile").mockReturnValue([["test-rs", server]]);
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
vi.spyOn(lspClient, "ensureFileOpen").mockResolvedValue();
vi.spyOn(lspClient, "sendNotification").mockResolvedValue();
const captured: Array<{ method: string; params: unknown }> = [];
vi.spyOn(lspClient, "sendRequest").mockImplementation(async (_c, method, requestParams) => {
captured.push({ method, params: requestParams });
return { expansion: "macro_rules!" };
});
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("request-test", {
action: "request",
file: filePath,
line: 2,
query: "rust-analyzer/expandMacro",
timeout: 5,
});
expect(captured).toHaveLength(1);
expect(captured[0]?.method).toBe("rust-analyzer/expandMacro");
expect(captured[0]?.params).toEqual({
textDocument: { uri: fileToUri(filePath) },
position: { line: 1, character: 4 },
});
const output = result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(output).toContain("rust-analyzer/expandMacro");
expect(output).toContain('"expansion"');
expect(output).toContain("macro_rules!");
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("request action forwards explicit JSON payload verbatim", async () => {
const tempDir = TempDir.createSync("@omp-lsp-request-payload-");
try {
const server: ServerConfig = { command: "test-lsp", fileTypes: ["ts"], rootMarkers: [] };
const client: LspClient = {
name: "test-lsp",
cwd: tempDir.path(),
config: server,
proc: {
stdin: { write() {}, flush: async () => {} },
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "test-lsp": server },
idleTimeoutMs: undefined,
});
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
const captured: Array<{ method: string; params: unknown }> = [];
vi.spyOn(lspClient, "sendRequest").mockImplementation(async (_c, method, requestParams) => {
captured.push({ method, params: requestParams });
return null;
});
const tool = new LspTool(makeLspSession(tempDir.path()));
await tool.execute("request-payload", {
action: "request",
query: "workspace/executeCommand",
payload: JSON.stringify({ command: "_typescript.organizeImports", arguments: ["a.ts"] }),
timeout: 5,
});
expect(captured).toHaveLength(1);
expect(captured[0]?.method).toBe("workspace/executeCommand");
expect(captured[0]?.params).toEqual({
command: "_typescript.organizeImports",
arguments: ["a.ts"],
});
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("capabilities action dumps server capabilities", async () => {
const tempDir = TempDir.createSync("@omp-lsp-caps-");
try {
const server: ServerConfig = { command: "test-lsp", fileTypes: ["ts"], rootMarkers: [] };
const client: LspClient = {
name: "test-lsp",
cwd: tempDir.path(),
config: server,
proc: {
stdin: { write() {}, flush: async () => {} },
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
serverCapabilities: {
hoverProvider: true,
definitionProvider: true,
executeCommandProvider: { commands: ["_typescript.organizeImports"] },
experimental: { "rust-analyzer/expandMacro": true },
},
};
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "test-lsp": server },
idleTimeoutMs: undefined,
});
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("caps-test", {
action: "capabilities",
timeout: 5,
});
const output = result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(output).toContain("test-lsp:");
expect(output).toContain("hoverProvider");
expect(output).toContain("_typescript.organizeImports");
expect(output).toContain("rust-analyzer/expandMacro");
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("flushes pending descendant text edits before a folder rename", async () => {
const tempDir = TempDir.createSync("@omp-lsp-folder-rename-");
try {
const srcDir = path.join(tempDir.path(), "src");
fs.mkdirSync(srcDir, { recursive: true });
const childPath = path.join(srcDir, "a.ts");
await Bun.write(childPath, "export const a = 1;\n");
const childUri = fileToUri(childPath);
const oldFolderUri = fileToUri(srcDir);
const newFolderUri = fileToUri(path.join(tempDir.path(), "src2"));
const childEdit: TextDocumentEdit = {
textDocument: { uri: childUri, version: null },
edits: [
{
range: {
start: { line: 0, character: 13 },
end: { line: 0, character: 14 },
},
newText: "renamed",
},
],
};
const folderRename: RenameFile = {
kind: "rename",
oldUri: oldFolderUri,
newUri: newFolderUri,
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [childEdit, folderRename],
};
const applied = await applyWorkspaceEdit(workspaceEdit, tempDir.path());
// Old folder is gone, new folder holds the edited child.
expect(fs.existsSync(srcDir)).toBe(false);
const renamedChildPath = path.join(tempDir.path(), "src2", "a.ts");
expect(fs.existsSync(renamedChildPath)).toBe(true);
expect(fs.readFileSync(renamedChildPath, "utf8")).toBe("export const renamed = 1;\n");
// Both ops are reported in original order: edit first, then rename.
expect(applied).toHaveLength(2);
expect(applied[0]).toContain("Applied 1 edit(s)");
expect(applied[0]).toContain("src/a.ts");
expect(applied[1]).toContain("Renamed");
expect(applied[1]).toContain("src");
expect(applied[1]).toContain("src2");
} finally {
tempDir.removeSync();
}
});
it("flushes pending edits queued against a rename target before performing the rename", async () => {
// LSP §3.16.2: documentChanges run in declared order. When a TextDocumentEdit
// targets `renameOp.newUri` *before* the rename, those edits must land on the
// existing file at that location BEFORE the rename overwrites/replaces it.
// Otherwise the rename clobbers the post-edit content (or worse, the edits
// land on the moved-in file with stale offsets).
const tempDir = TempDir.createSync("@omp-lsp-rename-target-prefill-");
try {
const oldPath = path.join(tempDir.path(), "old.ts");
const newPath = path.join(tempDir.path(), "new.ts");
await Bun.write(oldPath, "export const moved = 1;\n");
// A pre-existing target file the rename is about to clobber.
await Bun.write(newPath, "export const target = 2;\n");
const oldUri = fileToUri(oldPath);
const newUri = fileToUri(newPath);
// Edit the target file first, then rename onto it. Pre-edit content
// MUST be observable somewhere in the applied log — proving the flush
// ran before the rename clobbered the file.
const targetEdit: TextDocumentEdit = {
textDocument: { uri: newUri, version: null },
edits: [
{
range: {
start: { line: 0, character: 13 },
end: { line: 0, character: 19 },
},
newText: "before",
},
],
};
const renameOp: RenameFile = {
kind: "rename",
oldUri,
newUri,
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [targetEdit, renameOp],
};
const applied = await applyWorkspaceEdit(workspaceEdit, tempDir.path());
// Three steps observable in order: edit on newUri, then rename clobbers it.
expect(applied).toHaveLength(2);
expect(applied[0]).toContain("Applied 1 edit(s)");
expect(applied[0]).toContain("new.ts");
expect(applied[1]).toContain("Renamed");
// Final state: new.ts holds the moved-in content (rename ran last and won).
expect(fs.existsSync(oldPath)).toBe(false);
expect(fs.readFileSync(newPath, "utf8")).toBe("export const moved = 1;\n");
} finally {
tempDir.removeSync();
}
});
it("dedupes byte-identical non-empty text edits before overlap validation", () => {
const edit = {
range: { start: { line: 9, character: 55 }, end: { line: 9, character: 62 } },
newText: "./megaMenu",
};
expect(sortAndValidateTextEdits([edit, { ...edit }])).toEqual([edit]);
expect(
applyTextEditsToString("import x from './menu';\n", [
{
range: { start: { line: 0, character: 15 }, end: { line: 0, character: 21 } },
newText: "./megaMenu",
},
{
range: { start: { line: 0, character: 15 }, end: { line: 0, character: 21 } },
newText: "./megaMenu",
},
]),
).toBe("import x from './megaMenu';\n");
});
it("keeps byte-identical zero-width inserts because they are not idempotent", () => {
const result = applyTextEditsToString("abc", [
{ range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, newText: "X" },
{ range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, newText: "X" },
]);
expect(result).toBe("aXXbc");
});
it("applies equal-position inserts in array order", () => {
// LSP spec: multiple inserts at the same position land in the order they
// appear in the edits array (import + reference insertions rely on this).
const result = applyTextEditsToString("abc", [
{ range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, newText: "X" },
{ range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } }, newText: "Y" },
]);
expect(result).toBe("aXYbc");
});
it("validates every file's edits before writing any workspace-edit file", async () => {
const tempDir = TempDir.createSync("@omp-lsp-atomic-validate-");
try {
const okPath = path.join(tempDir.path(), "ok.ts");
const badPath = path.join(tempDir.path(), "bad.ts");
const okContent = "export const ok = 1;\n";
await Bun.write(okPath, okContent);
await Bun.write(badPath, "export const bad = 2;\n");
const workspaceEdit: WorkspaceEdit = {
changes: {
[fileToUri(okPath)]: [
{
range: { start: { line: 0, character: 13 }, end: { line: 0, character: 15 } },
newText: "changed",
},
],
[fileToUri(badPath)]: [
// Overlapping edits — must reject the whole workspace edit.
{
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 10 } },
newText: "x",
},
{
range: { start: { line: 0, character: 5 }, end: { line: 0, character: 12 } },
newText: "y",
},
],
},
};
expect(applyWorkspaceEdit(workspaceEdit, tempDir.path())).rejects.toThrow(/overlapping LSP edits/);
// The valid file must be untouched: validation runs before any write.
expect(fs.readFileSync(okPath, "utf8")).toBe(okContent);
} finally {
tempDir.removeSync();
}
});
it("round-trips file URIs containing percent and hash characters", () => {
const tricky = path.resolve(os.tmpdir(), "omp uri", "100% #1.ts");
const uri = fileToUri(tricky);
// Percent-encoded so the server cannot misparse a fragment or escape.
expect(uri).not.toContain("#");
expect(uri).not.toContain(" ");
expect(uriToFile(uri)).toBe(tricky);
// Lax servers sending unencoded paths are tolerated.
const plain = path.resolve(os.tmpdir(), "omp uri", "plain.ts");
expect(uriToFile(fileToUri(plain).replaceAll("%20", " "))).toBe(plain);
});
it("resolves $-prefixed identifiers past compound matches", async () => {
// Pre-fix, BARE_IDENTIFIER_RE rejected leading `$`, so requireWordBoundary
// was false and `resolveSymbolColumn(_, _, "$store")` returned the column
// inside `bar$store` rather than the standalone occurrence, feeding the
// LSP server the wrong column. The new regex `/^[$A-Za-z_][\w$]*$/` plus
// IDENTIFIER_CHAR_RE's existing `$` membership enforces the boundary.
const tempDir = TempDir.createSync("@omp-lsp-dollar-identifier-");
try {
const filePath = path.join(tempDir.path(), "store.ts");
// Standalone `$store` starts at column 16; compound `bar$store`
// contains the substring at column 7. Old code returned 7; new code
// returns 16.
await Bun.write(filePath, "let bar$store = $store + 1;\n");
const column = await resolveSymbolColumn(filePath, 1, "$store");
expect(column).toBe(16);
// `bar$store` is itself a valid `$`-bearing identifier and resolves
// to its own start, not into either fragment.
const compoundColumn = await resolveSymbolColumn(filePath, 1, "bar$store");
expect(compoundColumn).toBe(4);
} finally {
tempDir.removeSync();
}
});
it("applies a create op followed by a text edit for the same URI in declared order", async () => {
// LSP §3.16.2 motivating case for the rewrite: "Extract to new file"
// code actions emit `[CreateFile(newUri), TextDocumentEdit(newUri, ...)]`.
// Pre-fix, all text edits flushed first → applyTextEdits opened a
// not-yet-created file → ENOENT. The new walk processes each entry in
// order, so the create lands first and the edit reads the empty file
// the create just wrote.
const tempDir = TempDir.createSync("@omp-lsp-create-then-edit-");
try {
const newFilePath = path.join(tempDir.path(), "extracted.ts");
expect(fs.existsSync(newFilePath)).toBe(false);
const newUri = fileToUri(newFilePath);
const createOp: CreateFile = {
kind: "create",
uri: newUri,
};
const textEdit: TextDocumentEdit = {
textDocument: { uri: newUri, version: null },
edits: [
{
range: {
start: { line: 0, character: 0 },
end: { line: 0, character: 0 },
},
newText: "export const extracted = 42;\n",
},
],
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [createOp, textEdit],
};
const applied = await applyWorkspaceEdit(workspaceEdit, tempDir.path());
expect(fs.existsSync(newFilePath)).toBe(true);
expect(fs.readFileSync(newFilePath, "utf8")).toBe("export const extracted = 42;\n");
// Declared order observable in the applied log: create first, then edit.
expect(applied).toHaveLength(2);
expect(applied[0]).toContain("Created");
expect(applied[0]).toContain("extracted.ts");
expect(applied[1]).toContain("Applied 1 edit(s)");
expect(applied[1]).toContain("extracted.ts");
} finally {
tempDir.removeSync();
}
});
it("flushes pending descendant text edits before a folder delete", async () => {
// Mirror of the folder-rename subtree-flush test for the `delete` arm:
// edits queued against a child URI must land at the original path
// BEFORE the parent folder is removed, otherwise the flush at end of
// walk would target a non-existent path and throw.
const tempDir = TempDir.createSync("@omp-lsp-folder-delete-");
try {
const srcDir = path.join(tempDir.path(), "src");
fs.mkdirSync(srcDir, { recursive: true });
const childPath = path.join(srcDir, "a.ts");
await Bun.write(childPath, "export const a = 1;\n");
const childUri = fileToUri(childPath);
const folderUri = fileToUri(srcDir);
const childEdit: TextDocumentEdit = {
textDocument: { uri: childUri, version: null },
edits: [
{
range: {
start: { line: 0, character: 18 },
end: { line: 0, character: 19 },
},
newText: "999",
},
],
};
const folderDelete: DeleteFile = {
kind: "delete",
uri: folderUri,
};
const workspaceEdit: WorkspaceEdit = {
documentChanges: [childEdit, folderDelete],
};
const applied = await applyWorkspaceEdit(workspaceEdit, tempDir.path());
// Folder is gone; "Applied" message proves the flush ran before delete.
expect(fs.existsSync(srcDir)).toBe(false);
expect(applied).toHaveLength(2);
expect(applied[0]).toContain("Applied 1 edit(s)");
expect(applied[0]).toContain("src/a.ts");
expect(applied[1]).toContain("Deleted");
expect(applied[1]).toContain("src");
} finally {
tempDir.removeSync();
}
});
it("sendRequest respects an explicit timeoutMs and reports it in the error", async () => {
// Synthesise a minimal in-memory LSP client and never resolve the request
// so the per-request timer is the only thing that can fire.
const client: LspClient = {
name: "test-lsp",
cwd: process.cwd(),
config: { command: "test-lsp", fileTypes: [".ts"], rootMarkers: [] },
proc: { stdin: { write() {}, flush: async () => {} } } as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
expect(lspClient.sendRequest(client, "test/method", {}, undefined, 25)).rejects.toThrow(/after 25ms/);
});
it("sendRequest uses the signal as the deadline when no explicit timeout is set", async () => {
// With a signal but no explicit timeoutMs, the per-request 30s default
// MUST NOT fire — the signal owns the deadline. Otherwise `timeout: 60`
// on the LSP tool got truncated to 30000ms.
const client: LspClient = {
name: "test-lsp",
cwd: process.cwd(),
config: { command: "test-lsp", fileTypes: [".ts"], rootMarkers: [] },
proc: { stdin: { write() {}, flush: async () => {} } } as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
const signal = AbortSignal.timeout(20);
expect(lspClient.sendRequest(client, "test/method", {}, signal)).rejects.toThrow();
// If the per-request 30s timer had fired, the message would say "after 30000ms".
// We assert the negative: the rejection came from the signal, not the timer.
try {
await lspClient.sendRequest(client, "test/method", {}, AbortSignal.timeout(20));
} catch (err) {
expect(String(err)).not.toContain("30000ms");
}
});
it("rename_file skips the LSP loop when no configured server handles the file extension", async () => {
const tempDir = TempDir.createSync("@omp-lsp-rename-irrelevant-");
try {
const sourceFile = path.join(tempDir.path(), "notes.md");
const destFile = path.join(tempDir.path(), "renamed.md");
await Bun.write(sourceFile, "# heading\n");
// Only a TS server is configured; .md should not trigger any willRenameFiles.
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { "test-ts": { command: "test-ts", fileTypes: [".ts"], rootMarkers: [] } },
idleTimeoutMs: undefined,
});
const sendSpy = vi.spyOn(lspClient, "sendRequest");
const notifySpy = vi.spyOn(lspClient, "sendNotification");
const getClientSpy = vi.spyOn(lspClient, "getOrCreateClient");
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("rename-md", {
action: "rename_file",
file: sourceFile,
new_name: destFile,
timeout: 5,
});
expect(sendSpy).not.toHaveBeenCalled();
expect(notifySpy).not.toHaveBeenCalled();
expect(getClientSpy).not.toHaveBeenCalled();
expect(fs.existsSync(sourceFile)).toBe(false);
expect(fs.existsSync(destFile)).toBe(true);
const output = result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(output).toContain("Renamed");
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("workspace reload rediscovers LSP servers after an empty config was cached", async () => {
const tempDir = TempDir.createSync("@omp-lsp-reload-redetect-");
try {
const server: ServerConfig = {
command: "test-lsp",
fileTypes: [".ts"],
rootMarkers: ["package.json"],
};
const configs: LspConfig[] = [
{ servers: {}, idleTimeoutMs: undefined },
{ servers: { "test-lsp": server }, idleTimeoutMs: undefined },
{ servers: { "test-lsp": server }, idleTimeoutMs: undefined },
];
const loadConfigSpy = vi
.spyOn(lspConfig, "loadConfig")
.mockImplementation(() => configs.shift() ?? configs[0]);
const client = { proc: { kill: vi.fn() } } as unknown as LspClient;
vi.spyOn(lspClient, "getOrCreateClient").mockResolvedValue(client);
vi.spyOn(lspClient, "sendRequest").mockResolvedValue(null);
const tool = new LspTool(makeLspSession(tempDir.path()));
const initial = await tool.execute("reload-redetect-status", { action: "status" });
const initialOutput = initial.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(initialOutput).toContain("No language servers configured for this project");
const starResult = await tool.execute("reload-redetect-star", { action: "reload", file: "*" });
const starOutput = starResult.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
const omittedResult = await tool.execute("reload-redetect-omitted", { action: "reload" });
const omittedOutput = omittedResult.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(loadConfigSpy).toHaveBeenCalledTimes(3);
expect(starOutput).toContain("Reloaded test-lsp");
expect(omittedOutput).toContain("Reloaded test-lsp");
expect(lspClient.getOrCreateClient).toHaveBeenCalledWith(server, tempDir.path(), undefined, expect.anything());
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
});
it("status distinguishes configured servers from started clients", async () => {
// `loadConfig` claims rust-analyzer + tsls are configured, but only
// tsls has actually been spawned. Status must reflect that — claiming
// rust-analyzer is 'active' when the process never started was the
// original bug.
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: {
"rust-analyzer": { command: "rust-analyzer", fileTypes: [".rs"], rootMarkers: ["Cargo.toml"] },
"typescript-language-server": {
command: "typescript-language-server",
fileTypes: [".ts"],
rootMarkers: ["tsconfig.json"],
},
},
idleTimeoutMs: undefined,
});
vi.spyOn(lspClient, "getActiveClients").mockReturnValue([
{ name: "typescript-language-server", status: "ready", fileTypes: [".ts"] },
]);
const tool = new LspTool(makeLspSession(process.cwd()));
const result = await tool.execute("status-test", { action: "status" });
const output = result.content
.filter(block => block.type === "text")
.map(block => block.text)
.join("\n");
expect(output).toContain("rust-analyzer (configured, not started)");
expect(output).toContain("typescript-language-server (ready)");
});
it("reload * invalidates the per-cwd config cache so newly written .omp/lsp.json is observed", async () => {
// #3546: `getConfig` caches the first `loadConfig` result per cwd
// permanently. Creating `.omp/lsp.json` after the first LSP call left
// the tool stuck on "No language servers configured" until the process
// restarted. `reload *` (the user's explicit refresh) must invalidate
// that cache so subsequent calls observe the fresh config from disk.
const tempDir = TempDir.createSync("@omp-lsp-config-cache-reload-");
try {
const cwd = tempDir.path();
const empty: LspConfig = { servers: {}, idleTimeoutMs: undefined };
const withServer: LspConfig = {
servers: {
"fake-pylsp": {
command: "true",
fileTypes: [".py"],
rootMarkers: [".python-root"],
resolvedCommand: "/bin/true",
},
},
idleTimeoutMs: undefined,
};
const loadConfigSpy = vi
.spyOn(lspConfig, "loadConfig")
.mockImplementation(() => (loadConfigSpy.mock.calls.length === 1 ? empty : withServer));
// Prevent any real LSP subprocess from spawning when reload iterates
// the refreshed server list — the spawn path would race with the
// test's teardown.
vi.spyOn(lspClient, "getOrCreateClient").mockRejectedValue(new Error("spawn suppressed in test"));
vi.spyOn(lspClient, "getActiveClients").mockReturnValue([]);
const tool = new LspTool(makeLspSession(cwd));
const status1 = await tool.execute("cache-1", { action: "status" });
const text1 = status1.content
.filter(b => b.type === "text")
.map(b => b.text)
.join("\n");
expect(text1).toContain("No language servers configured");
expect(loadConfigSpy).toHaveBeenCalledTimes(1);
// Second status hits the cache — proves caching is the baseline, so
// the next assertion measures invalidation, not a missing cache.
await tool.execute("cache-2", { action: "status" });
expect(loadConfigSpy).toHaveBeenCalledTimes(1);
// `reload *` MUST drop the cached empty config and re-read from disk.
const reload = await tool.execute("cache-3", { action: "reload", file: "*" });
expect(loadConfigSpy).toHaveBeenCalledTimes(2);
const reloadText = reload.content
.filter(b => b.type === "text")
.map(b => b.text)
.join("\n");
// Spawn was suppressed, so the per-server output is the failure line —
// the contract under test is that the fresh server was even considered.
expect(reloadText).toContain("fake-pylsp");
// The refreshed config now sits in the cache; status sees the new
// server without another disk read.
const status3 = await tool.execute("cache-4", { action: "status" });
const text3 = status3.content
.filter(b => b.type === "text")
.map(b => b.text)
.join("\n");
expect(text3).toContain("fake-pylsp (configured, not started)");
expect(loadConfigSpy).toHaveBeenCalledTimes(2);
} finally {
tempDir.removeSync();
}
});
describe("reload cancellation and truthful teardown (#6369)", () => {
// A JSON-RPC error response the client maps to isMethodNotFoundError, so a
// non-rust server falls through from `rust-analyzer/reloadWorkspace` to the
// generic `workspace/didChangeConfiguration` reload.
const methodNotFound = (id: RpcMessage["id"]): RpcMessage => ({
jsonrpc: "2.0",
id,
error: { code: -32_601, message: "method not found" },
});
it("propagates cancellation of the reload request instead of reporting Restarted", async () => {
const tempDir = TempDir.createSync("@omp-lsp-reload-cancel-req-");
try {
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "shutdown") {
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "exit") {
srv.exit(0);
}
// rust-analyzer/reloadWorkspace is left pending: only the caller
// signal decides its fate.
});
const config: ServerConfig = { command: "fake-reload-cancel-req", fileTypes: [".ts"], rootMarkers: [] };
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined });
const tool = new LspTool(makeLspSession(tempDir.path()));
const controller = new AbortController();
const pending = tool.execute("reload-cancel-req", { action: "reload", file: "*" }, controller.signal);
await server.waitFor(m => m.method === "rust-analyzer/reloadWorkspace");
controller.abort(new ToolAbortError());
// Pre-fix, the bare `catch` swallowed the abort, fell through to the
// notification (also aborted), hit the second bare `catch`, killed
// the process, and returned "Restarted".
await expect(pending).rejects.toBeInstanceOf(ToolAbortError);
} finally {
vi.restoreAllMocks();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("propagates cancellation that arrives during the notification fallback", async () => {
const tempDir = TempDir.createSync("@omp-lsp-reload-cancel-fallback-");
const controller = new AbortController();
try {
installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "rust-analyzer/reloadWorkspace") {
// Fall through to the generic reload, then cancel before it lands.
srv.send(methodNotFound(message.id));
controller.abort(new ToolAbortError());
} 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-reload-cancel-fb", fileTypes: [".ts"], rootMarkers: [] };
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined });
const tool = new LspTool(makeLspSession(tempDir.path()));
const pending = tool.execute("reload-cancel-fb", { action: "reload", file: "*" }, controller.signal);
await expect(pending).rejects.toBeInstanceOf(ToolAbortError);
} finally {
vi.restoreAllMocks();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("still falls back to the generic reload on method-not-found without killing the server", async () => {
const tempDir = TempDir.createSync("@omp-lsp-reload-fallback-ok-");
try {
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "rust-analyzer/reloadWorkspace") {
srv.send(methodNotFound(message.id));
} 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-reload-fallback", fileTypes: [".ts"], rootMarkers: [] };
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined });
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("reload-fallback", { action: "reload", file: "*" });
expect(textResult(result)).toContain("Reloaded fake");
expect(server.killed).toBe(false);
} finally {
vi.restoreAllMocks();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("recognizes -32601 by code even when the server's message text is nonstandard", async () => {
const tempDir = TempDir.createSync("@omp-lsp-reload-fallback-code-");
try {
const server = installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "rust-analyzer/reloadWorkspace") {
// None of isMethodNotFoundError's message substrings — only the
// JSON-RPC code identifies this as method-not-found.
srv.send({ jsonrpc: "2.0", id: message.id, error: { code: -32_601, message: "Unknown request" } });
} 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-reload-fallback-code", fileTypes: [".ts"], rootMarkers: [] };
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({ servers: { fake: config }, idleTimeoutMs: undefined });
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("reload-fallback-code", { action: "reload", file: "*" });
expect(textResult(result)).toContain("Reloaded fake");
expect(server.killed).toBe(false);
} finally {
vi.restoreAllMocks();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("shutdownClientInstance removes the client by identity and confirms process exit", async () => {
const tempDir = TempDir.createSync("@omp-lsp-teardown-confirm-");
try {
installFakeLsp((message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} 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-teardown-confirm", fileTypes: [".ts"], rootMarkers: [] };
const client = await lspClient.getOrCreateClient(config, tempDir.path());
expect(lspClient.getActiveClients().some(s => s.name === config.command)).toBe(true);
const exited = await lspClient.shutdownClientInstance(client);
expect(exited).toBe(true);
expect(lspClient.getActiveClients().some(s => s.name === config.command)).toBe(false);
} finally {
vi.restoreAllMocks();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("shutdownClientInstance reports a failed teardown when the process outlives the kill", async () => {
const tempDir = TempDir.createSync("@omp-lsp-teardown-delayed-");
try {
installFakeLsp(
(message, srv) => {
if (message.method === "initialize") {
srv.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "shutdown") {
srv.send({ jsonrpc: "2.0", id: message.id, result: null });
}
// `exit` notification and `kill()` never resolve `proc.exited`.
},
{ killResolvesExit: false },
);
const config: ServerConfig = { command: "fake-teardown-delayed", fileTypes: [".ts"], rootMarkers: [] };
const client = await lspClient.getOrCreateClient(config, tempDir.path());
const exited = await lspClient.shutdownClientInstance(client);
expect(exited).toBe(false);
expect(lspClient.getActiveClients().some(s => s.name === config.command)).toBe(false);
} finally {
vi.restoreAllMocks();
tempDir.removeSync();
}
}, 15_000);
});
describe("reader exit ordering and initialization backoff (#7041)", () => {
it("surfaces the process diagnostic when stdout closes before exit publication", async () => {
installFakeLsp(
(message, server) => {
if (message.method === "initialize") server.exit(23);
},
{
stdoutClosesBeforeExit: true,
stderr: "simulated rust-analyzer crash",
},
);
const tempDir = TempDir.createSync("@omp-lsp-quick-exit-");
try {
const config: ServerConfig = {
command: "fake-lsp-quick-exit",
fileTypes: [".rs"],
rootMarkers: [],
};
await expect(lspClient.getOrCreateClient(config, tempDir.path())).rejects.toThrow(
"LSP server exited (code 23): simulated rust-analyzer crash",
);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("kills and evicts a client whose stdout reader fails while the process is alive", async () => {
const server = installFakeLsp((message, fake) => {
if (message.method === "initialize") fake.failStdout(new Error("simulated reader failure"));
});
const tempDir = TempDir.createSync("@omp-lsp-reader-failure-");
try {
const config: ServerConfig = {
command: "fake-lsp-reader-failure",
fileTypes: [".ts"],
rootMarkers: [],
};
await expect(lspClient.getOrCreateClient(config, tempDir.path())).rejects.toThrow(
"LSP connection closed: Error: simulated reader failure",
);
expect(server.killed).toBe(true);
expect(lspClient.getActiveClients().some(client => client.name === config.command)).toBe(false);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("keeps ordinary backoff but lets explicit reload retry immediately", async () => {
installFakeLsp((message, server) => {
if (message.method === "initialize") server.exit(23);
});
const tempDir = TempDir.createSync("@omp-lsp-reload-init-failure-");
const config: ServerConfig = {
command: "fake-lsp-reload-init-failure",
fileTypes: [".ts"],
rootMarkers: [],
};
try {
await expect(lspClient.getOrCreateClient(config, tempDir.path())).rejects.toBeInstanceOf(Error);
await expect(lspClient.getOrCreateClient(config, tempDir.path())).rejects.toThrow(
"failed to initialize recently",
);
vi.restoreAllMocks();
const retryServer = installFakeLsp((message, server) => {
if (message.method === "initialize") {
server.send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "rust-analyzer/reloadWorkspace") {
server.send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "shutdown") {
server.send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "exit") {
server.exit(0);
}
});
vi.spyOn(lspConfig, "loadConfig").mockReturnValue({
servers: { fake: config },
idleTimeoutMs: undefined,
});
const tool = new LspTool(makeLspSession(tempDir.path()));
const result = await tool.execute("reload-init-failure", { action: "reload", file: "*" });
expect(textResult(result)).toContain("Reloaded fake");
expect(retryServer.received.some(message => message.method === "initialize")).toBe(true);
} finally {
vi.restoreAllMocks();
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
});
// #3962 — LSP cold-start and notification writes must honor the tool's
// combined timeout/caller abort signal. Before the fix, a wedged server
// hung past the tool's advertised deadline: `initialize` fell back to the
// 30s internal timer because no signal was threaded, and notification
// writes (`didOpen`/`didChange`/`didSave`) had no timeout at all, so a
// stuck `sink.flush()` blocked every later op on the client's write queue.
describe("lsp cold-start and notification writes honor caller signal (#3962)", () => {
it("aborts a wedged cold-start initialize on the caller signal instead of the 30s internal fallback", async () => {
// Server accepts spawn but never answers the `initialize` request.
// Pre-fix, `getOrCreateClient` swallowed the signal and only bailed
// after the 30s `DEFAULT_REQUEST_TIMEOUT_MS` fallback fired.
installFakeLsp(() => {});
const tempDir = TempDir.createSync("@omp-lsp-init-abort-");
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 100);
const config: ServerConfig = {
command: "fake-lsp-init-abort",
fileTypes: ["ts"],
rootMarkers: [],
};
const start = Date.now();
await expect(
lspClient.getOrCreateClient(config, tempDir.path(), undefined, controller.signal),
).rejects.toBeInstanceOf(Error);
const elapsed = Date.now() - start;
clearTimeout(timer);
// The signal fired at 100ms. Allow a wide margin, but the pre-fix
// path only bailed after 30s.
expect(elapsed).toBeLessThan(2_000);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("does not negative-cache caller-aborted initialize attempts", async () => {
installFakeLsp(() => {});
const tempDir = TempDir.createSync("@omp-lsp-init-abort-cache-");
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 100);
const config: ServerConfig = {
command: "fake-lsp-init-abort-cache",
fileTypes: ["ts"],
rootMarkers: [],
};
await expect(
lspClient.getOrCreateClient(config, tempDir.path(), undefined, controller.signal),
).rejects.toBeInstanceOf(Error);
clearTimeout(timer);
await expect(lspClient.getOrCreateClient(config, tempDir.path(), 25)).rejects.not.toThrow(
"failed to initialize recently",
);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("does not tear down when a caller aborts before its queued write reaches flush", async () => {
const firstFlush = Promise.withResolvers<number>();
const writes: Array<string | Uint8Array> = [];
const kill = vi.fn();
const client: LspClient = {
name: "fake-lsp-queued-abort:/tmp",
cwd: "/tmp",
config: { command: "fake-lsp-queued-abort", fileTypes: ["ts"], rootMarkers: [] },
proc: {
exited: new Promise<number>(() => {}),
exitCode: null,
stdin: {
write(chunk: string | Uint8Array) {
writes.push(chunk);
return typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.byteLength;
},
flush: () => firstFlush.promise,
},
stdout: new ReadableStream<Uint8Array>(),
peekStderr: () => "",
kill,
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(0),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
const first = lspClient.sendNotification(client, "workspace/didChangeConfiguration", { settings: {} });
await Bun.sleep(0);
const controller = new AbortController();
const second = lspClient.sendNotification(client, "textDocument/didOpen", {}, controller.signal);
controller.abort();
await Bun.sleep(0);
expect(kill).not.toHaveBeenCalled();
firstFlush.resolve(0);
await first;
await expect(second).rejects.toBeInstanceOf(Error);
expect(kill).not.toHaveBeenCalled();
expect(writes).toHaveLength(1);
});
it("surfaces an asynchronous stdin write rejection instead of resolving the notification", async () => {
const epipe = Object.assign(new Error("EPIPE: broken pipe, write"), {
code: "EPIPE",
syscall: "write",
});
const client: LspClient = {
name: "fake-lsp-write-epipe:/tmp",
cwd: "/tmp",
config: { command: "fake-lsp-write-epipe", fileTypes: ["ts"], rootMarkers: [] },
proc: {
exited: Promise.withResolvers<number>().promise,
exitCode: null,
stdin: {
write: () => Promise.reject(epipe),
flush: () => 0,
},
stdout: new ReadableStream<Uint8Array>(),
peekStderr: () => "",
kill() {},
} as unknown as LspClient["proc"],
requestId: 0,
diagnostics: new Map(),
diagnosticsVersion: 0,
openFiles: new Map(),
pendingRequests: new Map(),
messageBuffer: new Uint8Array(0),
isReading: false,
status: "ready",
lastActivity: Date.now(),
writeQueue: Promise.resolve(),
activeProgressTokens: new Set(),
projectLoaded: Promise.resolve(),
resolveProjectLoaded: () => {},
};
await expect(
lspClient.sendNotification(client, "workspace/didChangeWatchedFiles", {
changes: [{ uri: "file:///tmp/example.ts", type: 2 }],
}),
).rejects.toBe(epipe);
});
it("bounds a wedged notification flush on the caller signal and tears down the client", async () => {
// Custom fake: stdin.flush is gated by a controllable promise so we
// can simulate a server that stopped draining stdin AFTER init has
// completed. Pre-fix, `sendNotification` had no signal and the
// stuck flush wedged the write queue permanently.
const encoder = new TextEncoder();
const { promise: exited, resolve: resolveExited } = Promise.withResolvers<number>();
let stdoutController: ReadableStreamDefaultController<Uint8Array> | null = null;
let exitCode: number | null = null;
let killed = false;
let flushGate: Promise<void> = Promise.resolve();
const frame = (message: RpcMessage): Uint8Array => {
const content = JSON.stringify(message);
return encoder.encode(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
};
const stdout = new ReadableStream<Uint8Array>({
start(c) {
stdoutController = c;
},
});
let pendingBytes = Buffer.alloc(0);
let chain: Promise<void> = Promise.resolve();
const feed = (raw: string | Uint8Array): void => {
const chunk = typeof raw === "string" ? Buffer.from(raw, "utf-8") : Buffer.from(raw);
pendingBytes = pendingBytes.length === 0 ? chunk : Buffer.concat([pendingBytes, chunk]);
chain = chain.then(async () => {
while (true) {
const headerEnd = pendingBytes.indexOf("\r\n\r\n");
if (headerEnd === -1) break;
const match = /Content-Length: (\d+)/i.exec(pendingBytes.toString("utf-8", 0, headerEnd));
if (!match) {
pendingBytes = pendingBytes.subarray(headerEnd + 4);
continue;
}
const start = headerEnd + 4;
const end = start + Number(match[1]);
if (pendingBytes.length < end) break;
const message = JSON.parse(pendingBytes.toString("utf-8", start, end)) as RpcMessage;
pendingBytes = pendingBytes.subarray(end);
if (message.method === "initialize") {
stdoutController?.enqueue(frame({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } }));
}
}
});
};
const proc = {
get exited() {
return exited;
},
get exitCode() {
return exitCode;
},
stdin: {
write(chunk: string | Uint8Array) {
feed(chunk);
return typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.byteLength;
},
flush: async () => {
await flushGate;
return 0;
},
end: async () => 0,
},
stdout,
peekStderr: () => "",
kill() {
killed = true;
if (exitCode === null) {
exitCode = 0;
stdoutController?.close();
resolveExited(0);
}
},
} as unknown as LspClient["proc"];
vi.spyOn(piUtils.ptree, "spawn").mockReturnValue(proc as unknown as piUtils.ptree.ChildProcess<"pipe">);
const tempDir = TempDir.createSync("@omp-lsp-flush-wedge-");
try {
const config: ServerConfig = {
command: "fake-lsp-flush-wedge",
fileTypes: ["ts"],
rootMarkers: [],
};
const client = await lspClient.getOrCreateClient(config, tempDir.path());
expect(lspClient.getActiveClients().some(s => s.name === config.command)).toBe(true);
// Wedge every subsequent flush: sink.flush() now awaits a promise
// that never settles, mirroring a server that stopped draining stdin.
flushGate = new Promise<void>(() => {});
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 100);
const start = Date.now();
await expect(
lspClient.sendNotification(
client,
"textDocument/didOpen",
{
textDocument: {
uri: "file:///tmp/x.ts",
languageId: "typescript",
version: 1,
text: "",
},
},
controller.signal,
),
).rejects.toBeInstanceOf(Error);
const elapsed = Date.now() - start;
clearTimeout(timer);
expect(elapsed).toBeLessThan(2_000);
// Teardown contract: an aborted write kills the client so the
// next `getOrCreateClient` spawns a fresh server instead of
// queueing behind the wedged flush forever.
expect(killed).toBe(true);
expect(lspClient.getActiveClients().some(s => s.name === config.command)).toBe(false);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
});
});
describe("expert elixir lsp", () => {
it("registers expert for .ex while keeping elixirls primary", () => {
const config = { servers: DEFAULTS as unknown as Record<string, ServerConfig> };
const names = getServersForFile(config, "lib/app.ex").map(([name]) => name);
expect(names).toContain("expert");
expect(names).toContain("elixirls");
expect(names.indexOf("elixirls")).toBeLessThan(names.indexOf("expert"));
});
});