diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 4ac43661c1f..be206922d16 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -278,6 +278,21 @@ export namespace LSP { if (!root) continue if (s.broken.has(root + server.id)) continue + // kilocode_change start - use lightweight tsgo-based client when persistent LSP is not enabled + if (server.id === "typescript" && !Flag.KILO_EXPERIMENTAL_LSP_TOOL) { + const existing = s.clients.find((x) => x.root === root && x.serverID === server.id) + if (existing) { + result.push(existing) + continue + } + const client = TsClient.create({ root }) + s.clients.push(client) + result.push(client) + Bus.publish(Event.Updated, {}) + continue + } + // kilocode_change end + const match = s.clients.find((x) => x.root === root && x.serverID === server.id) if (match) { result.push(match) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 4ef60a11d83..da061d6fbca 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -106,27 +106,13 @@ export namespace LSPServer { ), extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], async spawn(root) { - const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) - log.info("typescript server", { tsserver }) - if (!tsserver) return - const bin = await Npm.which("typescript-language-server") - if (!bin) return - - const args = ["--stdio", "--tsserver-log-verbosity", "off", "--tsserver-path", tsserver] - - if ( - !(await pathExists(path.join(root, "tsconfig.json"))) && - !(await pathExists(path.join(root, "jsconfig.json"))) - ) { - args.push("--ignore-node-modules") + if (!Flag.KILO_EXPERIMENTAL_LSP_TOOL) return undefined + const bin = await TsCheck.native_tsgo(root) + if (!bin) { + log.info("tsgo native binary not found, falling back to lightweight client") + return undefined } - - const proc = spawn(bin, args, { - cwd: root, - env: { - ...process.env, - }, - }) + log.info("spawning tsgo --lsp", { bin, root }) return { process: spawn(bin, ["--lsp", "--stdio"], { cwd: root }), } diff --git a/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts b/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts new file mode 100644 index 00000000000..6d98279b067 --- /dev/null +++ b/packages/opencode/test/kilocode/lsp-typescript-lightweight.test.ts @@ -0,0 +1,89 @@ +// Tests for the lightweight TypeScript diagnostic mode. +// These are regression guards — if an upstream OpenCode merge overwrites the +// kilocode integration in shared LSP files, these tests catch it. + +import { describe, test, expect, spyOn, afterEach } from "bun:test" +import path from "path" +import { LSPServer } from "../../src/lsp/server" +import { TsClient } from "../../src/kilocode/ts-client" +import { TsCheck } from "../../src/kilocode/ts-check" +import { Flag } from "../../src/flag/flag" +import { Instance } from "../../src/project/instance" + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("typescript lightweight mode", () => { + describe("spawn gate", () => { + test("Typescript.spawn returns undefined when flag is off", async () => { + const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL + // @ts-expect-error - override static flag + Flag.KILO_EXPERIMENTAL_LSP_TOOL = false + try { + const result = await LSPServer.Typescript.spawn("/tmp/any") + expect(result).toBeUndefined() + } finally { + // @ts-expect-error + Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved + } + }) + + test("Typescript.spawn calls native_tsgo when flag is on", async () => { + const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL + // @ts-expect-error + Flag.KILO_EXPERIMENTAL_LSP_TOOL = true + const spy = spyOn(TsCheck, "native_tsgo").mockResolvedValue(undefined) + + try { + const result = await LSPServer.Typescript.spawn("/tmp/any") + expect(spy).toHaveBeenCalled() + expect(result).toBeUndefined() // undefined because mock returns no binary + } finally { + // @ts-expect-error + Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved + spy.mockRestore() + } + }) + }) + + describe("TsClient", () => { + test("create returns a valid LSPClient.Info", () => { + const client = TsClient.create({ root: "/tmp/test" }) + expect(client.serverID).toBe("typescript") + expect(client.root).toBe("/tmp/test") + expect(client.diagnostics).toBeInstanceOf(Map) + expect(typeof client.shutdown).toBe("function") + expect(typeof client.waitForDiagnostics).toBe("function") + expect(typeof client.notify.open).toBe("function") + }) + + test("connection.sendRequest rejects with descriptive error", async () => { + const client = TsClient.create({ root: "/tmp/test" }) + await expect(client.connection.sendRequest("anything")).rejects.toThrow("lightweight diagnostic mode") + }) + + test("shutdown clears diagnostics", async () => { + const client = TsClient.create({ root: "/tmp/test" }) + await client.shutdown() + expect(client.diagnostics.size).toBe(0) + }) + }) + + describe("source integration guards", () => { + // These tests verify that kilocode integration code exists in shared + // files. If an upstream merge strips the integration blocks, these fail. + + test("lsp/server.ts gates Typescript.spawn behind flag", async () => { + const src = await Bun.file(path.resolve(import.meta.dir, "../../src/lsp/server.ts")).text() + expect(src).toContain("KILO_EXPERIMENTAL_LSP_TOOL") + expect(src).toContain("native_tsgo") + }) + + test("lsp/index.ts uses TsClient for lightweight diagnostics", async () => { + const src = await Bun.file(path.resolve(import.meta.dir, "../../src/lsp/index.ts")).text() + expect(src).toContain("TsClient.create") + expect(src).toContain("KILO_EXPERIMENTAL_LSP_TOOL") + }) + }) +}) diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index cfab72d8347..e84fb575d53 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -6,6 +6,8 @@ import * as launch from "../../src/lsp/launch" import { LSPServer } from "../../src/lsp/server" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +import { Flag } from "../../src/flag/flag" // kilocode_change +import { TsCheck } from "../../src/kilocode/ts-check" // kilocode_change describe("lsp.spawn", () => { test("does not spawn builtin LSP for files outside instance", async () => { @@ -32,7 +34,11 @@ describe("lsp.spawn", () => { } }) + // kilocode_change start - enable flag so spawn() is actually reached (lightweight mode skips it) test("would spawn builtin LSP for files inside instance", async () => { + const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL + // @ts-expect-error - override static flag for test + Flag.KILO_EXPERIMENTAL_LSP_TOOL = true await using tmp = await tmpdir() const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) @@ -50,84 +56,60 @@ describe("lsp.spawn", () => { expect(spy).toHaveBeenCalledTimes(1) } finally { + // @ts-expect-error + Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved spy.mockRestore() await Instance.disposeAll() } }) + // kilocode_change end - test("spawns builtin Typescript LSP with correct arguments", async () => { + // kilocode_change start - Typescript spawn is gated behind KILO_EXPERIMENTAL_LSP_TOOL. + // When the flag is off (default), spawn() returns undefined immediately (lightweight + // TsClient mode). These tests verify the experimental tsgo LSP spawn path. + test("spawns tsgo LSP when KILO_EXPERIMENTAL_LSP_TOOL is enabled", async () => { + const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL + // @ts-expect-error - override static flag for test + Flag.KILO_EXPERIMENTAL_LSP_TOOL = true await using tmp = await tmpdir() - // Create dummy tsserver to satisfy Module.resolve - const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib") - await fs.mkdir(tsdk, { recursive: true }) - await fs.writeFile(path.join(tsdk, "tsserver.js"), "") - const spawnSpy = spyOn(launch, "spawn").mockImplementation( - () => - ({ - stdin: {}, - stdout: {}, - stderr: {}, - on: () => {}, - kill: () => {}, - }) as any, + () => ({ stdin: {}, stdout: {}, stderr: {}, on: () => {}, kill: () => {} }) as any, ) + const tsgoSpy = spyOn(TsCheck, "native_tsgo").mockResolvedValue("/fake/tsgo") try { await Instance.provide({ directory: tmp.path, fn: async () => { - await LSPServer.Typescript.spawn(tmp.path) + const result = await LSPServer.Typescript.spawn(tmp.path) + expect(result).toBeDefined() + expect(tsgoSpy).toHaveBeenCalledWith(tmp.path) + expect(spawnSpy).toHaveBeenCalled() + const args = spawnSpy.mock.calls[0][1] as string[] + expect(args).toContain("--lsp") + expect(args).toContain("--stdio") }, }) - - expect(spawnSpy).toHaveBeenCalled() - const args = spawnSpy.mock.calls[0][1] as string[] - - expect(args).toContain("--tsserver-path") - expect(args).toContain("--tsserver-log-verbosity") - expect(args).toContain("off") } finally { + // @ts-expect-error + Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved spawnSpy.mockRestore() + tsgoSpy.mockRestore() } }) - test("spawns builtin Typescript LSP with --ignore-node-modules if no config is found", async () => { - await using tmp = await tmpdir() - - // Create dummy tsserver to satisfy Module.resolve - const tsdk = path.join(tmp.path, "node_modules", "typescript", "lib") - await fs.mkdir(tsdk, { recursive: true }) - await fs.writeFile(path.join(tsdk, "tsserver.js"), "") - - // NO tsconfig.json or jsconfig.json created here - - const spawnSpy = spyOn(launch, "spawn").mockImplementation( - () => - ({ - stdin: {}, - stdout: {}, - stderr: {}, - on: () => {}, - kill: () => {}, - }) as any, - ) - + test("Typescript.spawn returns undefined when KILO_EXPERIMENTAL_LSP_TOOL is off", async () => { + const saved = Flag.KILO_EXPERIMENTAL_LSP_TOOL + // @ts-expect-error + Flag.KILO_EXPERIMENTAL_LSP_TOOL = false try { - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await LSPServer.Typescript.spawn(tmp.path) - }, - }) - - expect(spawnSpy).toHaveBeenCalled() - const args = spawnSpy.mock.calls[0][1] as string[] - - expect(args).toContain("--ignore-node-modules") + const result = await LSPServer.Typescript.spawn("/tmp/any") + expect(result).toBeUndefined() } finally { - spawnSpy.mockRestore() + // @ts-expect-error + Flag.KILO_EXPERIMENTAL_LSP_TOOL = saved } }) + // kilocode_change end })