diff --git a/packages/omo-codex/plugin/components/lsp/test/lazy-lsp-mcp.test.ts b/packages/omo-codex/plugin/components/lsp/test/lazy-lsp-mcp.test.ts index b2cd3e888..fb52bed3c 100644 --- a/packages/omo-codex/plugin/components/lsp/test/lazy-lsp-mcp.test.ts +++ b/packages/omo-codex/plugin/components/lsp/test/lazy-lsp-mcp.test.ts @@ -1,5 +1,6 @@ +import { PassThrough } from "node:stream"; import { describe, expect, it } from "vitest"; -import { resolveLazyLspIdleTimeoutMs } from "../src/lazy-lsp-mcp.js"; +import { resolveLazyLspIdleTimeoutMs, runLazyLspMcpServer } from "../src/lazy-lsp-mcp.js"; import { DEFAULT_LAZY_MCP_IDLE_TIMEOUT_MS } from "../src/lazy-mcp-proxy.js"; describe("lazy LSP MCP config", () => { @@ -18,4 +19,84 @@ describe("lazy LSP MCP config", () => { expect(malformed.value).toBe(fallback); expect(malformed.warning).toContain("Ignoring malformed lazy MCP idle timeout"); }); + + it("#given missing lazy backend config #when metadata requests are served #then the server stays usable without starting a backend", async () => { + // given + const input = new PassThrough(); + const output = new PassThrough(); + const chunks: string[] = []; + const previousBackendConfig = process.env["CODEX_LSP_LAZY_BACKEND"]; + delete process.env["CODEX_LSP_LAZY_BACKEND"]; + output.on("data", (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + try { + const server = runLazyLspMcpServer(input, output); + + // when + input.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "test" } }, + })}\n`, + ); + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} })}\n`); + input.end(); + await server; + } finally { + restoreEnv("CODEX_LSP_LAZY_BACKEND", previousBackendConfig); + } + + // then + const responses = parseJsonRpcResponses(chunks.join("")); + expect(responses).toHaveLength(2); + expect(responses[0]?.result?.serverInfo).toEqual({ name: "lsp", version: "0.2.0" }); + expect(toolNames(responses[1])).toContain("status"); + }); }); + +interface JsonRpcTestResponse { + readonly result?: { + readonly serverInfo?: unknown; + readonly tools?: unknown; + }; +} + +function parseJsonRpcResponses(output: string): readonly JsonRpcTestResponse[] { + return output + .trim() + .split(/\n+/) + .filter((line) => line.length > 0) + .map((line) => { + const parsed: unknown = JSON.parse(line); + return isJsonRpcTestResponse(parsed) ? parsed : {}; + }); +} + +function toolNames(response: JsonRpcTestResponse | undefined): readonly string[] { + const tools = response?.result?.tools; + if (!Array.isArray(tools)) return []; + return tools.flatMap((tool) => { + if (!isRecord(tool) || typeof tool["name"] !== "string") return []; + return [tool["name"]]; + }); +} + +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +function isJsonRpcTestResponse(value: unknown): value is JsonRpcTestResponse { + return isRecord(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +}