diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 21:10:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 21:10:09 +0900 |
| commit | d9f53727845dface3e6d8a84ba2270b1de55482b (patch) | |
| tree | 6d42f0a0fbda15057296992e78c4b4e12046f9ed /packages/core/tests | |
| parent | 80212bfb009eaf71a4743310dee6ed08b8f7e1da (diff) | |
| parent | 9d8cf7005ba4c0bb8ade0775f54c2557aa1c5683 (diff) | |
| download | dispatch-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.ts | 110 | ||||
| -rw-r--r-- | packages/core/tests/credentials/wake-probe.test.ts | 26 | ||||
| -rw-r--r-- | packages/core/tests/fixture/lsp/fake-lsp-server.js | 195 | ||||
| -rw-r--r-- | packages/core/tests/lsp/client.test.ts | 146 | ||||
| -rw-r--r-- | packages/core/tests/lsp/diagnostic.test.ts | 67 | ||||
| -rw-r--r-- | packages/core/tests/lsp/luau-lsp.smoke.test.ts | 63 | ||||
| -rw-r--r-- | packages/core/tests/lsp/manager.test.ts | 120 | ||||
| -rw-r--r-- | packages/core/tests/lsp/server.test.ts | 41 | ||||
| -rw-r--r-- | packages/core/tests/tools/lsp-tool.test.ts | 110 | ||||
| -rw-r--r-- | packages/core/tests/tools/send-to-tab.test.ts | 47 | ||||
| -rw-r--r-- | packages/core/tests/tools/summon.test.ts | 108 | ||||
| -rw-r--r-- | packages/core/tests/tools/write-file.test.ts | 46 |
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")); + }); + }); }); |
