summaryrefslogtreecommitdiffhomepage
path: root/packages/lsp/src/tool.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
committerAdam Malczewski <[email protected]>2026-06-11 21:12:03 +0900
commite7eada4802ceebd86c83bcd6e3eca70152e7f331 (patch)
tree447095fd60b43980358d1565506f3ae2430e5f29 /packages/lsp/src/tool.ts
parent35937cee7f838e414eb8147c67205e01d85a4da0 (diff)
downloaddispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.tar.gz
dispatch-e7eada4802ceebd86c83bcd6e3eca70152e7f331.zip
feat(lsp,cwd): LSP integration + per-conversation cwd; fix cache-warming cache bust
LSP + per-conversation CWD feature: - new bundled `lsp` extension: hand-rolled JSON-RPC codec (framing/rpc), lazy one-server-per-(serverID,root), per-cwd config resolution, on-demand `lsp` tool - `conversation-store`: getCwd/setCwd (cwdKey); `session-orchestrator` defaults a turn's cwd from the store - `transport-http`: cwd + lsp status endpoints; wire types in transport-contract - host-bin: register lsp; config wiring Cache-warming fix (the warm read 0% on the first reheat after a message): - warm assembled tools under a different cwd than the real turn (a reheat sends no cwd, and the warm service had no store fallback). The skills filter rewrites the cwd-sensitive `load_skill` description, so the tools block โ€” the first bytes of the prompt-cache prefix โ€” diverged and the cache missed entirely. Warm now resolves cwd as opts.cwd ?? conversationStore.getCwd(), mirroring handleMessage. - capture warm sends as `provider.request` spans flagged `warm:true` (thread a child logger into providerOpts) so warm vs real bodies are diffable (obs ยง3.1). - kernel logger: span-close now merges child-bound attrs like span-open, so a `warm:true` query finds the closed span (with usage/status), not just the open. Tests: warm forwards a warm-flagged logger; warm falls back to stored cwd; logger open/close attr consistency. Full suite green (873).
Diffstat (limited to 'packages/lsp/src/tool.ts')
-rw-r--r--packages/lsp/src/tool.ts225
1 files changed, 225 insertions, 0 deletions
diff --git a/packages/lsp/src/tool.ts b/packages/lsp/src/tool.ts
new file mode 100644
index 0000000..bc4b41f
--- /dev/null
+++ b/packages/lsp/src/tool.ts
@@ -0,0 +1,225 @@
+/**
+ * The lsp tool โ€” model-facing tool contract.
+ *
+ * Operations: diagnostics, hover, definition, references, documentSymbol.
+ */
+
+import { resolve } from "node:path";
+import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel";
+import type { LspManager } from "./manager.js";
+
+type Operation = "diagnostics" | "hover" | "definition" | "references" | "documentSymbol";
+
+const POSITION_OPS: ReadonlySet<string> = new Set(["hover", "definition", "references"]);
+
+interface ValidatedArgs {
+ readonly operation: Operation;
+ readonly path: string;
+ readonly line?: number | undefined;
+ readonly character?: number | undefined;
+}
+
+function validateArgs(args: unknown): { readonly error: string } | ValidatedArgs {
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
+
+ const rawOp = obj.operation;
+ if (typeof rawOp !== "string") {
+ return { error: 'Error: Missing "operation" parameter (must be a string).' };
+ }
+ const validOps: ReadonlySet<string> = new Set([
+ "diagnostics",
+ "hover",
+ "definition",
+ "references",
+ "documentSymbol",
+ ]);
+ if (!validOps.has(rawOp)) {
+ return {
+ error: `Error: Invalid operation "${rawOp}". Must be one of: diagnostics, hover, definition, references, documentSymbol.`,
+ };
+ }
+ const operation = rawOp as Operation;
+
+ const rawPath = obj.path;
+ if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
+ return { error: 'Error: Missing or empty "path" parameter (must be a non-empty string).' };
+ }
+
+ let line: number | undefined;
+ let character: number | undefined;
+
+ if (obj.line !== undefined) {
+ const n = Number(obj.line);
+ if (!Number.isFinite(n) || n < 1) {
+ return { error: 'Error: Invalid "line" parameter (must be a positive number, 1-based).' };
+ }
+ line = Math.floor(n);
+ }
+
+ if (obj.character !== undefined) {
+ const n = Number(obj.character);
+ if (!Number.isFinite(n) || n < 1) {
+ return {
+ error: 'Error: Invalid "character" parameter (must be a positive number, 1-based).',
+ };
+ }
+ character = Math.floor(n);
+ }
+
+ const result: ValidatedArgs = { operation, path: rawPath };
+ if (line !== undefined) {
+ (result as { line?: number }).line = line;
+ }
+ if (character !== undefined) {
+ (result as { character?: number }).character = character;
+ }
+ return result;
+}
+
+function resolveFilePath(filePath: string, cwd: string): string {
+ const resolved = resolve(cwd, filePath);
+ const normalizedCwd = resolve(cwd);
+ if (!resolved.startsWith(normalizedCwd)) {
+ return normalizedCwd;
+ }
+ return resolved;
+}
+
+/** Convert validated 1-based line/character to an LSP 0-based position. */
+function toPosition(
+ line: number | undefined,
+ character: number | undefined,
+): { readonly line: number; readonly character: number } {
+ if (line === undefined || character === undefined) {
+ throw new Error("Position operations require both line and character.");
+ }
+ return { line: line - 1, character: character - 1 };
+}
+
+export function createLspTool(manager: LspManager): ToolContract {
+ return {
+ name: "lsp",
+ description:
+ "Query language servers for diagnostics, hover information, symbol definitions, references, and document symbols.",
+ parameters: {
+ type: "object",
+ properties: {
+ operation: {
+ type: "string",
+ enum: ["diagnostics", "hover", "definition", "references", "documentSymbol"],
+ description: "The LSP operation to perform.",
+ },
+ path: {
+ type: "string",
+ description: "File path relative to the workspace.",
+ },
+ line: {
+ type: "number",
+ description: "Line number (1-based). Required for hover, definition, references.",
+ },
+ character: {
+ type: "number",
+ description: "Character position (1-based). Required for hover, definition, references.",
+ },
+ },
+ required: ["operation", "path"],
+ },
+ concurrencySafe: true,
+ async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+
+ const { operation, path: filePath, line, character } = validated;
+ const cwd = ctx.cwd ?? process.cwd();
+ const absolutePath = resolveFilePath(filePath, cwd);
+
+ if (POSITION_OPS.has(operation)) {
+ if (line === undefined || character === undefined) {
+ return {
+ content: `Error: "${operation}" requires both "line" and "character" parameters (1-based).`,
+ isError: true,
+ };
+ }
+ }
+
+ try {
+ const statuses = await manager.status(cwd);
+ if (statuses.length === 0) {
+ return { content: "No language server configured for this workspace.", isError: true };
+ }
+
+ const connected = statuses.find((s) => s.state === "connected");
+ if (!connected) {
+ const first = statuses[0];
+ const detail = first
+ ? `"${first.name}" is not connected (state: ${first.state})`
+ : "is not connected";
+ return {
+ content: `Language server ${detail}.`,
+ isError: true,
+ };
+ }
+
+ // Find the client for this server
+ const client = manager.getClient(connected.id, connected.root);
+ if (!client) {
+ return { content: "Language server client not available.", isError: true };
+ }
+
+ switch (operation) {
+ case "diagnostics": {
+ const diags = await client.waitForDiagnostics(absolutePath);
+ return { content: diags || "No diagnostics found." };
+ }
+ case "hover": {
+ const result = await client.request("textDocument/hover", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ });
+ if (!result) return { content: "No hover information available." };
+ const hover = result as { readonly contents?: { readonly value?: string } | string };
+ const content =
+ typeof hover.contents === "string"
+ ? hover.contents
+ : (hover.contents?.value ?? "No hover information available.");
+ return { content };
+ }
+ case "definition": {
+ const result = await client.request("textDocument/definition", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ });
+ if (!result) return { content: "No definition found." };
+ return { content: JSON.stringify(result) };
+ }
+ case "references": {
+ const result = await client.request("textDocument/references", {
+ textDocument: { uri: `file://${absolutePath}` },
+ position: toPosition(line, character),
+ context: { includeDeclaration: true },
+ });
+ if (!result) return { content: "No references found." };
+ return { content: JSON.stringify(result) };
+ }
+ case "documentSymbol": {
+ const result = await client.request("textDocument/documentSymbol", {
+ textDocument: { uri: `file://${absolutePath}` },
+ });
+ if (!result) return { content: "No symbols found." };
+ return { content: JSON.stringify(result) };
+ }
+ }
+ } catch (err: unknown) {
+ return {
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+ },
+ };
+}