diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 17:52:14 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 17:52:14 +0900 |
| commit | 062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 (patch) | |
| tree | 6097df0d53265f1a5e734aadab75c0334cb8e0e7 /packages/core/tests/tools | |
| parent | b3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff) | |
| download | dispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.tar.gz dispatch-062d01bd2f5c3ab6de7747dc5028e66b81dac6f5.zip | |
feat(lsp): add config-driven LSP support (Roblox Luau via luau-lsp)
Add Language Server Protocol integration modeled on opencode's, wired for
this codebase's plain-TypeScript tool/agent architecture.
Core (@dispatch/core):
- lsp/client.ts: LSP/JSON-RPC client over stdio (vscode-jsonrpc) with the
initialize handshake, didOpen/didChange sync, push + pull diagnostics
(textDocument/diagnostic, workspace/diagnostic), and a generic request()
passthrough for hover/definition/references/documentSymbol.
- lsp/server.ts: resolves dispatch.toml [lsp] entries into spawn specs.
Config-driven only — no builtin registry, no auto-download.
- lsp/manager.ts: process-wide LspManager owning client lifecycles, keyed
by root+serverID, lazy spawn + reuse + graceful shutdown.
- lsp/language.ts: extension->languageId map incl. .luau -> "luau".
- lsp/diagnostic.ts: error-only <diagnostics> block formatting (1-based).
- tools/lsp.ts: on-demand 'lsp' tool (1-based coords -> 0-based wire).
- write-file.ts: optional onAfterWrite hook for diagnostics-on-write.
- config schema: validate [lsp] block; DispatchConfig.lsp + LspServerConfig.
API (@dispatch/api):
- AgentManager owns one LspManager; per-working-directory server cache
cleared on config reload; diagnostics appended to write_file results;
'lsp' tool gated by new perm_lsp setting; shutdownAll on destroy().
Config:
- dispatch.toml: documented, commented [lsp.luau-lsp] Roblox example.
Tests: fake-lsp-server fixture + client/manager/server/diagnostic/schema/
tool/write-hook suites, plus an opt-in real-binary luau-lsp smoke test
(auto-skipped when luau-lsp is absent). 652 pass; biome + 3 typechecks green.
Diffstat (limited to 'packages/core/tests/tools')
| -rw-r--r-- | packages/core/tests/tools/lsp-tool.test.ts | 110 | ||||
| -rw-r--r-- | packages/core/tests/tools/write-file.test.ts | 46 |
2 files changed, 156 insertions, 0 deletions
diff --git a/packages/core/tests/tools/lsp-tool.test.ts b/packages/core/tests/tools/lsp-tool.test.ts new file mode 100644 index 0000000..7f26522 --- /dev/null +++ b/packages/core/tests/tools/lsp-tool.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; +import type { LspManager } from "../../src/lsp/manager.js"; +import type { ResolvedLspServer } from "../../src/lsp/server.js"; +import { createLspTool, type LspToolContext } from "../../src/tools/lsp.js"; + +const SERVER: ResolvedLspServer = { + id: "luau-lsp", + extensions: [".luau"], + spawn: () => ({ process: {} as never }), +}; + +function makeManager(overrides: Partial<LspManager> = {}): LspManager { + return { + hasServerForFile: vi.fn(() => true), + touchFile: vi.fn(async () => {}), + getDiagnostics: vi.fn(() => ({})), + request: vi.fn(async () => []), + getClients: vi.fn(async () => []), + shutdownAll: vi.fn(async () => {}), + ...overrides, + } as unknown as LspManager; +} + +function ctx(manager: LspManager, servers = [SERVER]): () => LspToolContext { + return () => ({ manager, workingDirectory: "/work", servers }); +} + +describe("createLspTool", () => { + it("exposes the expected schema/name", () => { + const tool = createLspTool(ctx(makeManager())); + expect(tool.name).toBe("lsp"); + expect(tool.description).toMatch(/luau-lsp/i); + }); + + it("errors when no servers are configured", async () => { + const tool = createLspTool(ctx(makeManager(), [])); + const out = await tool.execute({ operation: "diagnostics", path: "a.luau" }); + expect(out).toMatch(/no LSP servers are configured/i); + }); + + it("errors when no server matches the file", async () => { + const manager = makeManager({ hasServerForFile: vi.fn(() => false) as never }); + const tool = createLspTool(ctx(manager)); + const out = await tool.execute({ operation: "diagnostics", path: "a.ts" }); + expect(out).toMatch(/no configured LSP server matches/i); + }); + + it("diagnostics: touches the file then reports errors", async () => { + const touchFile = vi.fn(async () => {}); + const getDiagnostics = vi.fn(() => ({ + "/work/a.luau": [ + { + range: { start: { line: 2, character: 1 }, end: { line: 2, character: 9 } }, + severity: 1, + message: "bad type", + }, + ], + })); + const manager = makeManager({ + touchFile: touchFile as never, + getDiagnostics: getDiagnostics as never, + }); + const tool = createLspTool(ctx(manager)); + const out = await tool.execute({ operation: "diagnostics", path: "a.luau" }); + expect(touchFile).toHaveBeenCalledOnce(); + expect(out).toContain("ERROR [3:2] bad type"); + }); + + it("diagnostics: reports clean when no errors", async () => { + const tool = createLspTool(ctx(makeManager())); + const out = await tool.execute({ operation: "diagnostics", path: "a.luau" }); + expect(out).toMatch(/No errors reported/i); + }); + + it("hover: requires line and character", async () => { + const tool = createLspTool(ctx(makeManager())); + const out = await tool.execute({ operation: "hover", path: "a.luau" }); + expect(out).toMatch(/requires both 'line' and 'character'/i); + }); + + it("hover: converts 1-based coords to 0-based on the wire", async () => { + const request = vi.fn(async () => [{ contents: "hi" }]); + const manager = makeManager({ request: request as never }); + const tool = createLspTool(ctx(manager)); + await tool.execute({ operation: "hover", path: "a.luau", line: 5, character: 3 }); + expect(request).toHaveBeenCalledOnce(); + const arg = request.mock.calls[0]?.[0] as { method: string; params: { position: unknown } }; + expect(arg.method).toBe("textDocument/hover"); + expect(arg.params.position).toEqual({ line: 4, character: 2 }); + }); + + it("references: includes declaration context", async () => { + const request = vi.fn(async () => []); + const manager = makeManager({ request: request as never }); + const tool = createLspTool(ctx(manager)); + await tool.execute({ operation: "references", path: "a.luau", line: 1, character: 1 }); + const arg = request.mock.calls[0]?.[0] as { params: { context?: unknown } }; + expect(arg.params.context).toEqual({ includeDeclaration: true }); + }); + + it("documentSymbol: does not require a position", async () => { + const request = vi.fn(async () => [{ name: "foo" }]); + const manager = makeManager({ request: request as never }); + const tool = createLspTool(ctx(manager)); + const out = await tool.execute({ operation: "documentSymbol", path: "a.luau" }); + const arg = request.mock.calls[0]?.[0] as { method: string }; + expect(arg.method).toBe("textDocument/documentSymbol"); + expect(out).toContain("foo"); + }); +}); diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts index f071e12..0dedbfc 100644 --- a/packages/core/tests/tools/write-file.test.ts +++ b/packages/core/tests/tools/write-file.test.ts @@ -103,4 +103,50 @@ describe("write_file tool", () => { expect(entries).toEqual([]); }); }); + + describe("onAfterWrite hook", () => { + it("appends the hook's returned string to a successful write", async () => { + const tool = createWriteFileTool(workDir, async (abs) => `DIAGNOSTICS for ${abs}`); + const result = await tool.execute({ path: "a.luau", content: "local x = 1" }); + expect(result).toMatch(/successfully wrote/i); + expect(result).toContain("DIAGNOSTICS for"); + expect(result).toContain(join(workDir, "a.luau")); + }); + + it("does not append when the hook returns empty string", async () => { + const tool = createWriteFileTool(workDir, async () => ""); + const result = await tool.execute({ path: "a.luau", content: "local x = 1" }); + expect(result.trim()).toMatch(/^Successfully wrote to "a\.luau"\.$/); + }); + + it("does not run the hook when the write is blocked (traversal)", async () => { + let called = false; + const tool = createWriteFileTool(workDir, async () => { + called = true; + return "should not appear"; + }); + const result = await tool.execute({ path: "../evil.txt", content: "bad" }); + expect(result).toMatch(/outside the working directory/i); + expect(called).toBe(false); + }); + + it("swallows hook errors so a throwing hook never fails the write", async () => { + const tool = createWriteFileTool(workDir, async () => { + throw new Error("lsp blew up"); + }); + const result = await tool.execute({ path: "a.luau", content: "local x = 1" }); + expect(result).toMatch(/successfully wrote/i); + expect(result).not.toContain("lsp blew up"); + }); + + it("passes the canonical absolute path to the hook", async () => { + let seen = ""; + const tool = createWriteFileTool(workDir, async (abs) => { + seen = abs; + return ""; + }); + await tool.execute({ path: "nested/b.luau", content: "x" }); + expect(seen).toBe(join(workDir, "nested/b.luau")); + }); + }); }); |
