summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/tools
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 17:52:14 +0900
committerAdam Malczewski <[email protected]>2026-06-02 17:52:14 +0900
commit062d01bd2f5c3ab6de7747dc5028e66b81dac6f5 (patch)
tree6097df0d53265f1a5e734aadab75c0334cb8e0e7 /packages/core/tests/tools
parentb3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (diff)
downloaddispatch-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.ts110
-rw-r--r--packages/core/tests/tools/write-file.test.ts46
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"));
+ });
+ });
});