summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 21:10:09 +0900
committerAdam Malczewski <[email protected]>2026-06-02 21:10:09 +0900
commitd9f53727845dface3e6d8a84ba2270b1de55482b (patch)
tree6d42f0a0fbda15057296992e78c4b4e12046f9ed /packages/core/tests
parent80212bfb009eaf71a4743310dee6ed08b8f7e1da (diff)
parent9d8cf7005ba4c0bb8ade0775f54c2557aa1c5683 (diff)
downloaddispatch-d9f53727845dface3e6d8a84ba2270b1de55482b.tar.gz
dispatch-d9f53727845dface3e6d8a84ba2270b1de55482b.zip
Merge branch 'dev' into feat/cs-code-search-tool
# Conflicts: # packages/api/src/agent-manager.ts # packages/api/tests/agent-manager.test.ts # packages/frontend/src/lib/components/ToolPermissions.svelte # packages/frontend/src/lib/settings.svelte.ts
Diffstat (limited to 'packages/core/tests')
-rw-r--r--packages/core/tests/config/lsp-schema.test.ts110
-rw-r--r--packages/core/tests/credentials/wake-probe.test.ts26
-rw-r--r--packages/core/tests/fixture/lsp/fake-lsp-server.js195
-rw-r--r--packages/core/tests/lsp/client.test.ts146
-rw-r--r--packages/core/tests/lsp/diagnostic.test.ts67
-rw-r--r--packages/core/tests/lsp/luau-lsp.smoke.test.ts63
-rw-r--r--packages/core/tests/lsp/manager.test.ts120
-rw-r--r--packages/core/tests/lsp/server.test.ts41
-rw-r--r--packages/core/tests/tools/lsp-tool.test.ts110
-rw-r--r--packages/core/tests/tools/send-to-tab.test.ts47
-rw-r--r--packages/core/tests/tools/summon.test.ts108
-rw-r--r--packages/core/tests/tools/write-file.test.ts46
12 files changed, 1076 insertions, 3 deletions
diff --git a/packages/core/tests/config/lsp-schema.test.ts b/packages/core/tests/config/lsp-schema.test.ts
new file mode 100644
index 0000000..2b71cc2
--- /dev/null
+++ b/packages/core/tests/config/lsp-schema.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from "vitest";
+import { validateConfig } from "../../src/config/schema.js";
+
+describe("config schema — [lsp] block", () => {
+ it("parses a valid custom server entry", () => {
+ const { config, errors } = validateConfig({
+ permissions: {},
+ lsp: {
+ "luau-lsp": {
+ command: ["luau-lsp", "lsp"],
+ extensions: [".luau"],
+ initialization: { "luau-lsp": { platform: { type: "roblox" } } },
+ },
+ },
+ });
+ expect(errors).toHaveLength(0);
+ expect(config.lsp).toBeDefined();
+ const entry = config.lsp?.["luau-lsp"];
+ expect(entry?.command).toEqual(["luau-lsp", "lsp"]);
+ expect(entry?.extensions).toEqual([".luau"]);
+ expect(entry?.initialization).toEqual({
+ "luau-lsp": { platform: { type: "roblox" } },
+ });
+ });
+
+ it("preserves env and nested initialization verbatim", () => {
+ const { config } = validateConfig({
+ permissions: {},
+ lsp: {
+ "luau-lsp": {
+ command: ["luau-lsp", "lsp"],
+ extensions: [".luau"],
+ env: { PATH: "/custom/bin" },
+ initialization: {
+ "luau-lsp": {
+ sourcemap: { enabled: true, autogenerate: true },
+ diagnostics: { strictDatamodelTypes: false },
+ },
+ },
+ },
+ },
+ });
+ const entry = config.lsp?.["luau-lsp"];
+ expect(entry?.env).toEqual({ PATH: "/custom/bin" });
+ expect(entry?.initialization).toEqual({
+ "luau-lsp": {
+ sourcemap: { enabled: true, autogenerate: true },
+ diagnostics: { strictDatamodelTypes: false },
+ },
+ });
+ });
+
+ it("rejects a custom server missing command", () => {
+ const { config, errors } = validateConfig({
+ permissions: {},
+ lsp: { broken: { extensions: [".luau"] } },
+ });
+ expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true);
+ expect(config.lsp).toBeUndefined();
+ });
+
+ it("rejects a custom server missing extensions", () => {
+ const { errors } = validateConfig({
+ permissions: {},
+ lsp: { broken: { command: ["x"] } },
+ });
+ expect(errors.some((e) => e.path === "lsp.broken.extensions")).toBe(true);
+ });
+
+ it("rejects an empty command array", () => {
+ const { errors } = validateConfig({
+ permissions: {},
+ lsp: { broken: { command: [], extensions: [".luau"] } },
+ });
+ expect(errors.some((e) => e.path === "lsp.broken.command")).toBe(true);
+ });
+
+ it("keeps a disabled entry without requiring command/extensions", () => {
+ const { config, errors } = validateConfig({
+ permissions: {},
+ lsp: { "luau-lsp": { disabled: true } },
+ });
+ expect(errors).toHaveLength(0);
+ expect(config.lsp?.["luau-lsp"]?.disabled).toBe(true);
+ });
+
+ it("skips a malformed entry but keeps valid siblings", () => {
+ const { config, errors } = validateConfig({
+ permissions: {},
+ lsp: {
+ good: { command: ["a"], extensions: [".luau"] },
+ bad: { extensions: [".luau"] },
+ },
+ });
+ expect(config.lsp?.good).toBeDefined();
+ expect(config.lsp?.bad).toBeUndefined();
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ it("omits lsp entirely when not present", () => {
+ const { config, errors } = validateConfig({ permissions: {} });
+ expect(errors).toHaveLength(0);
+ expect(config.lsp).toBeUndefined();
+ });
+
+ it("flags a non-object lsp value", () => {
+ const { errors } = validateConfig({ permissions: {}, lsp: "nope" });
+ expect(errors.some((e) => e.path === "lsp")).toBe(true);
+ });
+});
diff --git a/packages/core/tests/credentials/wake-probe.test.ts b/packages/core/tests/credentials/wake-probe.test.ts
index 253efec..a97a00c 100644
--- a/packages/core/tests/credentials/wake-probe.test.ts
+++ b/packages/core/tests/credentials/wake-probe.test.ts
@@ -9,7 +9,7 @@ vi.mock("../../src/db/index.js", () => ({
}),
}));
-const { buildWakeProbeBody } = await import("../../src/credentials/claude.js");
+const { buildWakeProbeBody, selectHaikuModel } = await import("../../src/credentials/claude.js");
const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude.";
@@ -47,3 +47,27 @@ describe("buildWakeProbeBody", () => {
expect(a).toEqual(b);
});
});
+describe("selectHaikuModel", () => {
+ it("returns the id whose name contains 'haiku'", () => {
+ const models = ["claude-sonnet-4-20250514", "claude-haiku-4-5-20251001"];
+ expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
+ });
+
+ it("matches case-insensitively", () => {
+ expect(selectHaikuModel(["Claude-HAIKU-Latest"])).toBe("Claude-HAIKU-Latest");
+ });
+
+ it("returns the FIRST match when several models contain 'haiku'", () => {
+ // `/v1/models` returns newest-first, so first-match prefers the newest.
+ const models = ["claude-haiku-4-5-20251001", "claude-3-5-haiku-20241022"];
+ expect(selectHaikuModel(models)).toBe("claude-haiku-4-5-20251001");
+ });
+
+ it("returns null when no model contains 'haiku'", () => {
+ expect(selectHaikuModel(["claude-sonnet-4-20250514", "claude-opus-4-20250514"])).toBeNull();
+ });
+
+ it("returns null for an empty list", () => {
+ expect(selectHaikuModel([])).toBeNull();
+ });
+});
diff --git a/packages/core/tests/fixture/lsp/fake-lsp-server.js b/packages/core/tests/fixture/lsp/fake-lsp-server.js
new file mode 100644
index 0000000..d771ebd
--- /dev/null
+++ b/packages/core/tests/fixture/lsp/fake-lsp-server.js
@@ -0,0 +1,195 @@
+// Minimal JSON-RPC 2.0 LSP-like fake server over stdio, for testing the LSP
+// client without a real language server binary. Ported from opencode's
+// test/fixture/lsp/fake-lsp-server.js (trimmed to what dispatch's client and
+// manager exercise: initialize, didOpen/didChange, push + pull diagnostics).
+//
+// Test hooks (custom JSON-RPC methods the test driver can call):
+// test/get-initialize-params → returns the params sent to `initialize`
+// test/get-last-change → returns the last `didChange` params
+// test/publish-diagnostics → forwards a `publishDiagnostics` push
+// test/configure-pull-diagnostics → sets up pull-diagnostic responses
+// test/get-diagnostic-request-count→ how many pull requests were received
+
+let nextId = 1;
+let readBuffer = Buffer.alloc(0);
+let lastChange = null;
+let initializeParams = null;
+let diagnosticRequestCount = 0;
+let registeredCapability = false;
+let pullConfig = {
+ registerOn: undefined,
+ registrations: [],
+ documentDiagnostics: [],
+ workspaceDiagnostics: [],
+ hasDiagnosticProvider: false,
+};
+
+function encode(message) {
+ const json = JSON.stringify(message);
+ const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n`;
+ return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]);
+}
+
+function decodeFrames(buffer) {
+ const results = [];
+ while (true) {
+ const idx = buffer.indexOf("\r\n\r\n");
+ if (idx === -1) break;
+ const header = buffer.slice(0, idx).toString("utf8");
+ const match = /Content-Length:\s*(\d+)/i.exec(header);
+ const length = match ? parseInt(match[1], 10) : 0;
+ const bodyStart = idx + 4;
+ const bodyEnd = bodyStart + length;
+ if (buffer.length < bodyEnd) break;
+ results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"));
+ buffer = buffer.slice(bodyEnd);
+ }
+ return { messages: results, rest: buffer };
+}
+
+function send(message) {
+ process.stdout.write(encode(message));
+}
+function sendRequest(method, params) {
+ const id = nextId++;
+ send({ jsonrpc: "2.0", id, method, params });
+ return id;
+}
+function sendResponse(id, result) {
+ send({ jsonrpc: "2.0", id, result });
+}
+function sendNotification(method, params) {
+ send({ jsonrpc: "2.0", method, params });
+}
+
+function maybeRegister(method) {
+ if (pullConfig.registerOn !== method || registeredCapability) return;
+ registeredCapability = true;
+ sendRequest("client/registerCapability", {
+ registrations: pullConfig.registrations.map((registration, index) => ({
+ id: registration.id ?? `pull-${index}`,
+ method: registration.method ?? "textDocument/diagnostic",
+ registerOptions: registration.registerOptions ?? registration,
+ })),
+ });
+}
+
+function handle(raw) {
+ let data;
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ return;
+ }
+
+ if (data.method === "initialize") {
+ initializeParams = data.params;
+ sendResponse(data.id, {
+ capabilities: {
+ textDocumentSync: { change: 2, openClose: true },
+ ...(pullConfig.hasDiagnosticProvider
+ ? {
+ diagnosticProvider: {
+ identifier: "fake",
+ interFileDependencies: false,
+ workspaceDiagnostics: false,
+ },
+ }
+ : {}),
+ },
+ });
+ return;
+ }
+
+ if (data.method === "test/get-initialize-params") {
+ sendResponse(data.id, initializeParams);
+ return;
+ }
+
+ if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
+ return;
+ }
+
+ if (data.method === "textDocument/didOpen") {
+ maybeRegister("didOpen");
+ return;
+ }
+
+ if (data.method === "textDocument/didChange") {
+ lastChange = data.params;
+ maybeRegister("didChange");
+ return;
+ }
+
+ if (data.method === "workspace/didChangeWatchedFiles") {
+ return;
+ }
+
+ if (data.method === "test/configure-pull-diagnostics") {
+ pullConfig = {
+ registerOn: data.params?.registerOn,
+ registrations: data.params?.registrations ?? [],
+ documentDiagnostics: data.params?.documentDiagnostics ?? [],
+ workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
+ hasDiagnosticProvider: data.params?.hasDiagnosticProvider ?? false,
+ };
+ registeredCapability = false;
+ sendResponse(data.id, null);
+ return;
+ }
+
+ if (data.method === "test/publish-diagnostics") {
+ sendNotification("textDocument/publishDiagnostics", data.params);
+ sendResponse(data.id, null);
+ return;
+ }
+
+ if (data.method === "test/get-last-change") {
+ sendResponse(data.id, lastChange);
+ return;
+ }
+
+ if (data.method === "test/get-diagnostic-request-count") {
+ sendResponse(data.id, diagnosticRequestCount);
+ return;
+ }
+
+ if (data.method === "textDocument/diagnostic") {
+ diagnosticRequestCount += 1;
+ sendResponse(data.id, { kind: "full", items: pullConfig.documentDiagnostics });
+ return;
+ }
+
+ if (data.method === "workspace/diagnostic") {
+ diagnosticRequestCount += 1;
+ sendResponse(data.id, { items: pullConfig.workspaceDiagnostics });
+ return;
+ }
+
+ if (data.method === "textDocument/hover") {
+ sendResponse(data.id, { contents: { kind: "plaintext", value: "fake hover" } });
+ return;
+ }
+
+ if (data.method === "textDocument/definition") {
+ sendResponse(data.id, [
+ {
+ uri: data.params?.textDocument?.uri,
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
+ },
+ ]);
+ return;
+ }
+
+ // Default: respond null to any other request so the client never hangs.
+ if (typeof data.id !== "undefined") {
+ sendResponse(data.id, null);
+ }
+}
+
+process.stdin.on("data", (chunk) => {
+ readBuffer = Buffer.concat([readBuffer, chunk]);
+ const { messages, rest } = decodeFrames(readBuffer);
+ readBuffer = rest;
+ for (const message of messages) handle(message);
+});
diff --git a/packages/core/tests/lsp/client.test.ts b/packages/core/tests/lsp/client.test.ts
new file mode 100644
index 0000000..8daf8ab
--- /dev/null
+++ b/packages/core/tests/lsp/client.test.ts
@@ -0,0 +1,146 @@
+import { spawn } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { Diagnostic } from "vscode-languageserver-types";
+import { createLspClient, type LspServerHandle } from "../../src/lsp/client.js";
+
+const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
+
+function spawnFakeServer(): LspServerHandle {
+ const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
+ return { process: proc as LspServerHandle["process"] };
+}
+
+const ERROR_DIAG: Diagnostic = {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 5 } },
+ severity: 1,
+ message: "fake type error",
+ source: "Fake",
+};
+
+describe("lsp/client (fake server)", () => {
+ let workDir: string;
+
+ beforeEach(async () => {
+ workDir = await mkdtemp(join(tmpdir(), "dispatch-lsp-"));
+ });
+ afterEach(async () => {
+ await rm(workDir, { recursive: true, force: true });
+ });
+
+ it("completes the initialize handshake and forwards initializationOptions", async () => {
+ const handle = spawnFakeServer();
+ handle.initialization = { "luau-lsp": { platform: { type: "roblox" } } };
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+
+ const params = await client.connection.sendRequest<{ initializationOptions?: unknown }>(
+ "test/get-initialize-params",
+ {},
+ );
+ expect(params.initializationOptions).toEqual({
+ "luau-lsp": { platform: { type: "roblox" } },
+ });
+ await client.shutdown();
+ });
+
+ it("opens a file and receives push diagnostics", async () => {
+ const handle = spawnFakeServer();
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+
+ const file = join(workDir, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ const version = await client.notifyOpen(file);
+ expect(version).toBe(0);
+
+ // Drive a push from the fake server, then assert it lands in the map.
+ await client.connection.sendRequest("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ diagnostics: [ERROR_DIAG],
+ });
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(client.diagnostics.get(file)?.[0]?.message).toBe("fake type error");
+ await client.shutdown();
+ });
+
+ it("bumps the document version on re-open (didChange)", async () => {
+ const handle = spawnFakeServer();
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+ const file = join(workDir, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ expect(await client.notifyOpen(file)).toBe(0);
+ await writeFile(file, "local x = 2\n");
+ expect(await client.notifyOpen(file)).toBe(1);
+
+ const lastChange = await client.connection.sendRequest<{ textDocument?: { version?: number } }>(
+ "test/get-last-change",
+ {},
+ );
+ expect(lastChange?.textDocument?.version).toBe(1);
+ await client.shutdown();
+ });
+
+ it("waits for pull diagnostics when the server advertises a diagnostic provider", async () => {
+ const handle = spawnFakeServer();
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+ // Tell the fake server (before initialize? no — it persists) to answer
+ // pull requests. We configure AFTER connect; the static provider flag is
+ // read at initialize, so this test exercises the dynamic registration
+ // path instead.
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registerOn: "didOpen",
+ registrations: [{ id: "d1", registerOptions: { identifier: "fake" } }],
+ documentDiagnostics: [ERROR_DIAG],
+ });
+
+ const file = join(workDir, "a.luau");
+ await writeFile(file, "bad\n");
+ const version = await client.notifyOpen(file);
+ await client.waitForDiagnostics({ path: file, version, mode: "document" });
+
+ expect(client.diagnostics.get(file)?.some((d) => d.message === "fake type error")).toBe(true);
+ await client.shutdown();
+ });
+
+ it("request() passes through to the server (hover)", async () => {
+ const handle = spawnFakeServer();
+ const client = await createLspClient({
+ serverID: "fake",
+ server: handle,
+ root: workDir,
+ directory: workDir,
+ });
+ const file = join(workDir, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ await client.notifyOpen(file);
+ const hover = await client.request<{ contents?: { value?: string } }>("textDocument/hover", {
+ textDocument: { uri: pathToFileURL(file).href },
+ position: { line: 0, character: 6 },
+ });
+ expect(hover?.contents?.value).toBe("fake hover");
+ await client.shutdown();
+ });
+});
diff --git a/packages/core/tests/lsp/diagnostic.test.ts b/packages/core/tests/lsp/diagnostic.test.ts
new file mode 100644
index 0000000..93ffde9
--- /dev/null
+++ b/packages/core/tests/lsp/diagnostic.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import type { Diagnostic } from "vscode-languageserver-types";
+import { pretty, report } from "../../src/lsp/diagnostic.js";
+
+function diag(partial: Partial<Diagnostic> & { message: string }): Diagnostic {
+ return {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
+ severity: 1,
+ ...partial,
+ };
+}
+
+describe("lsp/diagnostic", () => {
+ describe("pretty", () => {
+ it("renders 1-based line/col with severity label", () => {
+ const out = pretty(
+ diag({
+ message: "Expected number",
+ range: { start: { line: 4, character: 2 }, end: { line: 4, character: 8 } },
+ }),
+ );
+ expect(out).toBe("ERROR [5:3] Expected number");
+ });
+
+ it("maps severities to labels", () => {
+ expect(pretty(diag({ message: "w", severity: 2 }))).toMatch(/^WARN /);
+ expect(pretty(diag({ message: "i", severity: 3 }))).toMatch(/^INFO /);
+ expect(pretty(diag({ message: "h", severity: 4 }))).toMatch(/^HINT /);
+ });
+
+ it("defaults missing severity to ERROR", () => {
+ expect(pretty(diag({ message: "x", severity: undefined }))).toMatch(/^ERROR /);
+ });
+ });
+
+ describe("report", () => {
+ it("returns empty string when there are no errors", () => {
+ expect(report("a.luau", [])).toBe("");
+ // Warnings only → still empty (errors-only).
+ expect(report("a.luau", [diag({ message: "w", severity: 2 })])).toBe("");
+ });
+
+ it("wraps errors in a <diagnostics file> block", () => {
+ const out = report("src/a.luau", [diag({ message: "boom" })]);
+ expect(out).toContain('<diagnostics file="src/a.luau">');
+ expect(out).toContain("ERROR [1:1] boom");
+ expect(out).toContain("</diagnostics>");
+ });
+
+ it("filters out non-error severities", () => {
+ const out = report("a.luau", [
+ diag({ message: "err" }),
+ diag({ message: "warn", severity: 2 }),
+ ]);
+ expect(out).toContain("err");
+ expect(out).not.toContain("warn");
+ });
+
+ it("caps at 20 and notes the remainder", () => {
+ const issues = Array.from({ length: 25 }, (_, i) => diag({ message: `e${i}` }));
+ const out = report("a.luau", issues);
+ expect(out).toContain("... and 5 more");
+ expect(out).toContain("e0");
+ expect(out).not.toContain("e24");
+ });
+ });
+});
diff --git a/packages/core/tests/lsp/luau-lsp.smoke.test.ts b/packages/core/tests/lsp/luau-lsp.smoke.test.ts
new file mode 100644
index 0000000..381435b
--- /dev/null
+++ b/packages/core/tests/lsp/luau-lsp.smoke.test.ts
@@ -0,0 +1,63 @@
+import { execSync } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { LspManager } from "../../src/lsp/manager.js";
+import { resolveServersFromConfig } from "../../src/lsp/server.js";
+
+/**
+ * Opt-in smoke test against the REAL luau-lsp binary. Skipped automatically
+ * (never fails CI) when `luau-lsp` is not on PATH — mirrors opencode's
+ * platform-guarded launch test. When the binary IS present, it proves the
+ * end-to-end path: spawn → initialize handshake → didOpen → real diagnostics.
+ */
+function hasLuauLsp(): boolean {
+ try {
+ execSync("luau-lsp --version", { stdio: "ignore" });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+const RUN = hasLuauLsp();
+
+describe.skipIf(!RUN)("luau-lsp real-binary smoke", () => {
+ let root: string;
+ let manager: LspManager;
+
+ beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), "dispatch-luau-smoke-"));
+ manager = new LspManager();
+ });
+ afterEach(async () => {
+ await manager.shutdownAll();
+ await rm(root, { recursive: true, force: true });
+ });
+
+ it("reports a real type error for a bad .luau file", async () => {
+ const servers = resolveServersFromConfig({
+ "luau-lsp": {
+ command: ["luau-lsp", "lsp"],
+ extensions: [".luau"],
+ initialization: {
+ "luau-lsp": {
+ platform: { type: "roblox" },
+ diagnostics: { strictDatamodelTypes: false },
+ },
+ },
+ },
+ });
+
+ const file = join(root, "bad.luau");
+ await writeFile(file, 'local x: number = "not a number"\nprint(x)\n');
+
+ await manager.touchFile({ file, root, servers, mode: "document" });
+ const diagnostics = manager.getDiagnostics({ root, servers, file });
+ const messages = (diagnostics[file] ?? []).map((d) => d.message).join("\n");
+
+ expect(messages.length).toBeGreaterThan(0);
+ expect(messages.toLowerCase()).toContain("number");
+ }, 60_000);
+});
diff --git a/packages/core/tests/lsp/manager.test.ts b/packages/core/tests/lsp/manager.test.ts
new file mode 100644
index 0000000..e720413
--- /dev/null
+++ b/packages/core/tests/lsp/manager.test.ts
@@ -0,0 +1,120 @@
+import { spawn } from "node:child_process";
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import type { Diagnostic } from "vscode-languageserver-types";
+import { LspManager } from "../../src/lsp/manager.js";
+import type { ResolvedLspServer } from "../../src/lsp/server.js";
+
+const FIXTURE = join(dirname(fileURLToPath(import.meta.url)), "../fixture/lsp/fake-lsp-server.js");
+
+function makeServer(id: string, extensions: string[]) {
+ const counter = { count: 0 };
+ const server: ResolvedLspServer = {
+ id,
+ extensions,
+ spawn() {
+ counter.count += 1;
+ const proc = spawn(process.execPath, [FIXTURE], { stdio: "pipe" });
+ return { process: proc as never };
+ },
+ };
+ return { server, counter };
+}
+
+describe("lsp/manager (fake server)", () => {
+ let root: string;
+ let manager: LspManager;
+
+ beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), "dispatch-lspmgr-"));
+ manager = new LspManager();
+ });
+ afterEach(async () => {
+ await manager.shutdownAll();
+ await rm(root, { recursive: true, force: true });
+ });
+
+ it("hasServerForFile matches by extension", () => {
+ const { server } = makeServer("fake", [".luau"]);
+ expect(manager.hasServerForFile(join(root, "a.luau"), [server])).toBe(true);
+ expect(manager.hasServerForFile(join(root, "a.ts"), [server])).toBe(false);
+ });
+
+ it("spawns lazily and reuses the client across calls", async () => {
+ const { server, counter } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.luau");
+ await writeFile(file, "local x = 1\n");
+
+ const c1 = await manager.getClients({ file, root, servers: [server] });
+ const c2 = await manager.getClients({ file, root, servers: [server] });
+ expect(c1).toHaveLength(1);
+ expect(c2).toHaveLength(1);
+ expect(c1[0]).toBe(c2[0]);
+ expect(counter.count).toBe(1);
+ });
+
+ it("does not spawn for a non-matching extension", async () => {
+ const { server, counter } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.ts");
+ await writeFile(file, "const x = 1\n");
+ const clients = await manager.getClients({ file, root, servers: [server] });
+ expect(clients).toHaveLength(0);
+ expect(counter.count).toBe(0);
+ });
+
+ it("touchFile + getDiagnostics surfaces a pushed diagnostic", async () => {
+ const { server } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.luau");
+ await writeFile(file, "bad code\n");
+
+ await manager.touchFile({ file, root, servers: [server] });
+ const [client] = await manager.getClients({ file, root, servers: [server] });
+ // Drive a push through the fake server.
+ const diag: Diagnostic = {
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } },
+ severity: 1,
+ message: "manager error",
+ };
+ await client.connection.sendRequest("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ diagnostics: [diag],
+ });
+ await new Promise((r) => setTimeout(r, 50));
+
+ const result = manager.getDiagnostics({ root, servers: [server], file });
+ expect(result[file]?.[0]?.message).toBe("manager error");
+ });
+
+ it("request() forwards to clients and flattens results", async () => {
+ const { server } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ await manager.touchFile({ file, root, servers: [server] });
+
+ const results = await manager.request({
+ file,
+ root,
+ servers: [server],
+ method: "textDocument/definition",
+ params: {
+ textDocument: { uri: pathToFileURL(file).href },
+ position: { line: 0, character: 6 },
+ },
+ });
+ expect(results.length).toBeGreaterThan(0);
+ });
+
+ it("shutdownAll clears state so the next call respawns", async () => {
+ const { server, counter } = makeServer("fake", [".luau"]);
+ const file = join(root, "a.luau");
+ await writeFile(file, "local x = 1\n");
+ await manager.getClients({ file, root, servers: [server] });
+ expect(counter.count).toBe(1);
+ await manager.shutdownAll();
+ await manager.getClients({ file, root, servers: [server] });
+ expect(counter.count).toBe(2);
+ });
+});
diff --git a/packages/core/tests/lsp/server.test.ts b/packages/core/tests/lsp/server.test.ts
new file mode 100644
index 0000000..bdaf83d
--- /dev/null
+++ b/packages/core/tests/lsp/server.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+import { resolveServersFromConfig } from "../../src/lsp/server.js";
+
+describe("lsp/server resolveServersFromConfig", () => {
+ it("returns [] for undefined config", () => {
+ expect(resolveServersFromConfig(undefined)).toEqual([]);
+ });
+
+ it("resolves a server entry with id + extensions", () => {
+ const servers = resolveServersFromConfig({
+ "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"] },
+ });
+ expect(servers).toHaveLength(1);
+ expect(servers[0]?.id).toBe("luau-lsp");
+ expect(servers[0]?.extensions).toEqual([".luau"]);
+ expect(typeof servers[0]?.spawn).toBe("function");
+ });
+
+ it("skips disabled entries", () => {
+ const servers = resolveServersFromConfig({
+ "luau-lsp": { command: ["luau-lsp", "lsp"], extensions: [".luau"], disabled: true },
+ });
+ expect(servers).toEqual([]);
+ });
+
+ it("skips entries with empty command or extensions", () => {
+ const servers = resolveServersFromConfig({
+ noCommand: { command: [], extensions: [".luau"] },
+ noExt: { command: ["x"], extensions: [] },
+ });
+ expect(servers).toEqual([]);
+ });
+
+ it("resolves multiple servers", () => {
+ const servers = resolveServersFromConfig({
+ a: { command: ["a"], extensions: [".luau"] },
+ b: { command: ["b"], extensions: [".lua"] },
+ });
+ expect(servers.map((s) => s.id).sort()).toEqual(["a", "b"]);
+ });
+});
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/send-to-tab.test.ts b/packages/core/tests/tools/send-to-tab.test.ts
index 4450fc5..21d8032 100644
--- a/packages/core/tests/tools/send-to-tab.test.ts
+++ b/packages/core/tests/tools/send-to-tab.test.ts
@@ -14,6 +14,7 @@ function makeCallbacks(overrides: Partial<SendToTabCallbacks> = {}): SendToTabCa
deliver: () => ({ status: "started" }),
listOpenHandles: () => [{ handle: "targ", title: "Target" }],
self: { id: "self-id", handle: "self" },
+ canReadTab: true,
...overrides,
};
}
@@ -24,6 +25,22 @@ describe("createSendToTabTool — schema & description", () => {
expect(tool.name).toBe("send_to_tab");
expect(tool.description).toContain("fire-and-forget");
expect(tool.description.toLowerCase()).toContain("queued");
+ // Description must steer the model away from busy-waiting for a reply.
+ expect(tool.description.toLowerCase()).toContain("do not sleep");
+ expect(tool.description.toLowerCase()).toContain("end your turn");
+ });
+
+ it("mentions read_tab in the description only when canReadTab is true", () => {
+ const tool = createSendToTabTool(makeCallbacks({ canReadTab: true }));
+ expect(tool.description).toContain("read_tab");
+ });
+
+ it("never mentions read_tab in the description when canReadTab is false", () => {
+ const tool = createSendToTabTool(makeCallbacks({ canReadTab: false }));
+ expect(tool.description).not.toContain("read_tab");
+ // Still tells the agent a reply will wake it + to end its turn.
+ expect(tool.description.toLowerCase()).toContain("wake you with a new message");
+ expect(tool.description.toLowerCase()).toContain("end your turn");
});
});
@@ -35,11 +52,37 @@ describe("createSendToTabTool — execute()", () => {
expect(deliver).toHaveBeenCalledTimes(1);
const [targetId, delivered] = deliver.mock.calls[0] ?? [];
expect(targetId).toBe("target-id");
- // Provenance prefix names the sending tab's handle.
- expect(delivered).toContain("[message from tab self]");
+ // Provenance header names the sending tab's handle and marks it as a
+ // peer agent (not the recipient's own user).
+ expect(delivered).toContain("[message from tab self");
+ expect(delivered).toContain("another agent");
expect(delivered).toContain("hello there");
+ // Reply contract: the recipient must answer via send_to_tab back to the
+ // sender's handle, not as a plain text reply to its own user.
+ expect(delivered).toContain('send_to_tab tool with tab_id "self"');
+ expect(delivered).toContain("ONLY reply if");
expect(out).toContain("idle");
expect(out).toContain("targ");
+ // Sender is steered away from busy-waiting and told to end its turn.
+ expect(out.toLowerCase()).toContain("do not sleep");
+ expect(out.toLowerCase()).toContain("end your turn");
+ });
+
+ it("points the sender at read_tab in the result only when canReadTab is true", async () => {
+ const deliver = vi.fn(() => ({ status: "started" as const }));
+ const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: true }));
+ const out = await tool.execute({ tab_id: "targ", message: "hi" });
+ expect(out).toContain("read_tab");
+ });
+
+ it("omits read_tab from the result when canReadTab is false", async () => {
+ const deliver = vi.fn(() => ({ status: "started" as const }));
+ const tool = createSendToTabTool(makeCallbacks({ deliver, canReadTab: false }));
+ const out = await tool.execute({ tab_id: "targ", message: "hi" });
+ expect(out).not.toContain("read_tab");
+ // Still steers away from busy-waiting and toward ending the turn.
+ expect(out.toLowerCase()).toContain("do not sleep");
+ expect(out.toLowerCase()).toContain("end your turn");
});
it("reports the queued status when the target is busy", async () => {
diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts
index f59f345..4885a94 100644
--- a/packages/core/tests/tools/summon.test.ts
+++ b/packages/core/tests/tools/summon.test.ts
@@ -239,3 +239,111 @@ describe("createSummonTool — execute() argument forwarding", () => {
expect(getResult).toHaveBeenCalled();
});
});
+
+describe("createSummonTool — user-agent-only mode (perm_user_agent without perm_summon)", () => {
+ // userAgentEnabled=true, subagentEnabled=false → the tool spawns ONLY
+ // top-level user agents. `top_level` is implied (and forced), the
+ // subagent/parallel-work prose is dropped, and only the user-agent
+ // catalog group is shown.
+ const subagents: AvailableAgent[] = [
+ {
+ slug: "programmer",
+ name: "Programmer",
+ description: "Codes things",
+ path: "/agents/programmer.toml",
+ },
+ ];
+ const userAgents: AvailableAgent[] = [
+ {
+ slug: "default",
+ name: "Default",
+ description: "Default agent",
+ path: "/agents/default.toml",
+ },
+ ];
+
+ function userAgentOnlyTool(
+ spawn = vi.fn(async () => "ua-1"),
+ getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" })),
+ ) {
+ return {
+ spawn,
+ getResult,
+ tool: createSummonTool(
+ "/tmp/work",
+ { spawn, getResult },
+ subagents,
+ userAgents,
+ ["/agents"],
+ true, // userAgentEnabled
+ false, // subagentEnabled
+ ),
+ };
+ }
+
+ it("describes spawning user agents and omits subagent/parallel-work prose", () => {
+ const { tool } = userAgentOnlyTool();
+ expect(tool.description).toContain("Spawn an independent top-level user agent");
+ expect(tool.description).toContain("fire-and-forget");
+ expect(tool.description).not.toContain("Pattern for parallel work");
+ expect(tool.description).not.toContain("Set background=true");
+ });
+
+ it("lists only the user-agent catalog group, not subagents", () => {
+ const { tool } = userAgentOnlyTool();
+ expect(tool.description).toContain("User agents (spawned as independent top-level tabs):");
+ expect(tool.description).toContain("default");
+ // Subagents must not be advertised in user-agent-only mode.
+ expect(tool.description).not.toContain("Subagents (spawned as child tabs):");
+ expect(tool.description).not.toContain("- programmer: Programmer");
+ });
+
+ it("only lists user-agent slugs in the 'agent' parameter description", () => {
+ const { tool } = userAgentOnlyTool();
+ const agentParam = (tool.parameters as unknown as { shape: { agent: { description: string } } })
+ .shape.agent;
+ expect(agentParam.description).toContain("default");
+ expect(agentParam.description).not.toContain("programmer");
+ });
+
+ it("omits the top_level parameter (it is implied)", () => {
+ const { tool } = userAgentOnlyTool();
+ const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
+ expect("top_level" in shape).toBe(false);
+ });
+
+ it("omits the background parameter (user agents are fire-and-forget)", () => {
+ const { tool } = userAgentOnlyTool();
+ const shape = (tool.parameters as unknown as { shape: Record<string, unknown> }).shape;
+ expect("background" in shape).toBe(false);
+ });
+
+ it("forces topLevel=true on spawn even when top_level is not passed", async () => {
+ const spawn = vi.fn(async () => "ua-99");
+ const getResult = vi.fn(async () => ({ status: "done" as const, result: "nope" }));
+ const { tool } = userAgentOnlyTool(spawn, getResult);
+ const out = await tool.execute({ task: "do stuff", agent: "default" });
+ expect(out).toContain("User agent spawned successfully");
+ expect(out).toContain("ua-99");
+ expect(out).toContain("fire-and-forget");
+ // Never blocks on a result for fire-and-forget user agents.
+ expect(getResult).not.toHaveBeenCalled();
+ const callArg = spawn.mock.calls[0]?.[0];
+ expect(callArg).toMatchObject({ topLevel: true, agentSlug: "default" });
+ });
+});
+
+describe("createSummonTool — subagentEnabled defaults preserve legacy behavior", () => {
+ it("defaults subagentEnabled=true so omitting it keeps subagent spawning", async () => {
+ const spawn = vi.fn(async () => "tab-1");
+ const getResult = vi.fn(async () => ({ status: "done" as const, result: "child" }));
+ // No userAgentEnabled/subagentEnabled args → legacy subagent-only mode.
+ const tool = createSummonTool("/tmp/work", { spawn, getResult }, [], []);
+ const out = await tool.execute({ task: "x", agent: "programmer" });
+ // Foreground subagent summon blocks and returns the child result.
+ expect(out).toBe("agent_id: tab-1\n\nchild");
+ expect(getResult).toHaveBeenCalled();
+ const callArg = spawn.mock.calls[0]?.[0];
+ expect(callArg).not.toHaveProperty("topLevel");
+ });
+});
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"));
+ });
+ });
});